mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Merge branch 'main' into fix/keyless-unicode-search
This commit is contained in:
174 files changed
+28050
-542
No files matched your search
@@ -2,7 +2,20 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createApplication } from './application.js';
|
||||
import { installTrackpadPinchZoom } from './viewer.js';
|
||||
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
installFreeLookHeightLimit,
|
||||
installGlobeInViewRecovery,
|
||||
installTrackpadPinchZoom,
|
||||
} from './viewer.js';
|
||||
import {
|
||||
FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
globeFrameShare,
|
||||
globeOutOfFrame,
|
||||
lookAngleWithinFrame,
|
||||
lookStepLosesGlobe,
|
||||
} from '../globeInView.js';
|
||||
|
||||
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
|
||||
function fixture(overrides = {}) {
|
||||
@@ -343,3 +356,652 @@ test('trackpad pinch falls back to the original event when relay construction fa
|
||||
assert.equal(event.propagationStopped, false);
|
||||
assert.equal(f.relayed.length, 0);
|
||||
});
|
||||
|
||||
function freeLookFixture({ enableLook = true, height = 0 } = {}) {
|
||||
const listeners = new Set();
|
||||
const controller = { enableLook, minimumTrackBallHeight: 7_500_000 };
|
||||
const camera = { positionCartographic: { height } };
|
||||
const viewer = {
|
||||
camera,
|
||||
scene: {
|
||||
screenSpaceCameraController: controller,
|
||||
preUpdate: {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const frame = (nextHeight) => {
|
||||
camera.positionCartographic = { height: nextHeight };
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
return { viewer, controller, listeners, frame };
|
||||
}
|
||||
|
||||
test('free look follows camera height and is restored on dispose', () => {
|
||||
const f = freeLookFixture({ height: 500 });
|
||||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1);
|
||||
assert.equal(f.controller.enableLook, false);
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS);
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
f.frame(Number.NaN);
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||||
dispose();
|
||||
dispose();
|
||||
assert.equal(f.controller.enableLook, true);
|
||||
assert.equal(f.listeners.size, 0);
|
||||
});
|
||||
|
||||
test('drags that start on the stars orbit above the free-look height', () => {
|
||||
// Below Cesium's trackball height such a drag turns the camera in place.
|
||||
const f = freeLookFixture({ height: 500 });
|
||||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||||
assert.equal(
|
||||
f.controller.minimumTrackBallHeight,
|
||||
FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
);
|
||||
dispose();
|
||||
assert.equal(f.controller.minimumTrackBallHeight, 7_500_000);
|
||||
|
||||
const lower = freeLookFixture({ height: 500 });
|
||||
lower.controller.minimumTrackBallHeight = 500_000;
|
||||
const disposeLower = installFreeLookHeightLimit(lower.viewer);
|
||||
assert.equal(
|
||||
lower.controller.minimumTrackBallHeight,
|
||||
500_000,
|
||||
'never raised',
|
||||
);
|
||||
disposeLower();
|
||||
assert.equal(lower.controller.minimumTrackBallHeight, 500_000);
|
||||
|
||||
const later = freeLookFixture({ height: 500 });
|
||||
const disposeLater = installFreeLookHeightLimit(later.viewer);
|
||||
later.controller.minimumTrackBallHeight = 2_000_000;
|
||||
disposeLater();
|
||||
assert.equal(
|
||||
later.controller.minimumTrackBallHeight,
|
||||
2_000_000,
|
||||
'dispose keeps a value set after install',
|
||||
);
|
||||
});
|
||||
|
||||
test('free look keeps a setting other code makes after it is installed', () => {
|
||||
const f = freeLookFixture({ height: 500 });
|
||||
const dispose = installFreeLookHeightLimit(f.viewer);
|
||||
f.controller.enableLook = false;
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, false, 'turned off stays off');
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, false, 'and stays off after orbit');
|
||||
f.controller.enableLook = true;
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, true, 'turned back on');
|
||||
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
|
||||
assert.equal(f.controller.enableLook, false, 'still limited from orbit');
|
||||
f.frame(10);
|
||||
f.controller.enableLook = false;
|
||||
dispose();
|
||||
assert.equal(f.controller.enableLook, false, 'dispose keeps a later setting');
|
||||
});
|
||||
|
||||
test('free look stays off when the viewer had it disabled', () => {
|
||||
const f = freeLookFixture({ enableLook: false, height: 500 });
|
||||
installFreeLookHeightLimit(f.viewer);
|
||||
f.frame(10);
|
||||
assert.equal(f.controller.enableLook, false);
|
||||
});
|
||||
|
||||
// A real Cesium camera on the smallest scene its constructor reads, so the
|
||||
// frustum test runs on Cesium's own view math rather than a stub.
|
||||
function realCamera({ width = 1600, height = 900, mode } = {}) {
|
||||
const scene = {
|
||||
canvas: { clientWidth: width, clientHeight: height },
|
||||
drawingBufferWidth: width,
|
||||
drawingBufferHeight: height,
|
||||
mapProjection: new Cesium.GeographicProjection(),
|
||||
mode: mode ?? Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
tweens: new Cesium.TweenCollection(),
|
||||
renders: 0,
|
||||
requestRender() {
|
||||
this.renders += 1;
|
||||
},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
camera.frustum.aspectRatio = width / height;
|
||||
return { camera, scene, viewer: { camera, scene } };
|
||||
}
|
||||
|
||||
function aim(
|
||||
camera,
|
||||
{ lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 },
|
||||
) {
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(heading),
|
||||
pitch: Cesium.Math.toRadians(pitch),
|
||||
roll: Cesium.Math.toRadians(roll),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const reportedStuckView = {
|
||||
alt: 17_996_811,
|
||||
heading: 180,
|
||||
pitch: -42,
|
||||
roll: 180,
|
||||
};
|
||||
// Oblique poses that still show part of the planet: Director recipe shots
|
||||
// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit.
|
||||
const partialGlobeViews = [
|
||||
{ lon: -30, lat: 20, alt: 19_000_000, pitch: -65 },
|
||||
{ lon: 2, lat: 46, alt: 8_000_000, pitch: -52 },
|
||||
{ lon: 110, lat: 20, alt: 9_500_000, pitch: -66 },
|
||||
{ lon: 0, lat: 0, alt: 2_000_000, pitch: -30 },
|
||||
];
|
||||
|
||||
// Stars and a few pixels of limb, no surface: where a frame limit measured
|
||||
// against a sphere of the planet's largest radius used to stop a voice tilt.
|
||||
const limbOnlyView = { alt: 18_000_000, pitch: -56.75 };
|
||||
|
||||
/**
|
||||
* An oracle independent of the code under test: the share of an 80×45 grid of
|
||||
* Cesium pick rays across the canvas that hit the ellipsoid surface.
|
||||
*/
|
||||
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
|
||||
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
|
||||
let hits = 0;
|
||||
let counted = 0;
|
||||
for (let row = 0; row < 45; row += 1) {
|
||||
for (let column = 0; column < 80; column += 1) {
|
||||
const x = ((column + 0.5) * width) / 80;
|
||||
const y = ((row + 0.5) * height) / 45;
|
||||
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
|
||||
continue;
|
||||
counted += 1;
|
||||
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
|
||||
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
return hits / counted;
|
||||
}
|
||||
|
||||
test('the globe is out of frame only when no surface is on screen', () => {
|
||||
const cases = [
|
||||
[reportedStuckView, 1225, 923, true],
|
||||
[limbOnlyView, 1600, 900, true],
|
||||
[{ alt: 17_996_811, pitch: -90 }, 1225, 923, false],
|
||||
...partialGlobeViews.map((view) => [view, 1600, 900, false]),
|
||||
];
|
||||
for (const [view, width, height, out] of cases) {
|
||||
const { camera } = realCamera({ width, height });
|
||||
aim(camera, view);
|
||||
const label = JSON.stringify(view);
|
||||
assert.equal(globeOutOfFrame(camera), out, label);
|
||||
assert.equal(surfacePixelShare(camera, width, height) === 0, out, label);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
globeFrameShare(camera) - surfacePixelShare(camera, width, height),
|
||||
) < 0.03,
|
||||
`${label}: the frame share matches the pick-ray oracle`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a move that ends with the globe out of frame turns back to nadir', () => {
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
const dispose = installGlobeInViewRecovery(viewer);
|
||||
aim(camera, reportedStuckView);
|
||||
const position = Cesium.Cartesian3.clone(camera.positionWC);
|
||||
const heading = camera.heading;
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6));
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3),
|
||||
);
|
||||
assert.equal(globeOutOfFrame(camera), false);
|
||||
assert.equal(scene.renders, 1);
|
||||
|
||||
// Looking up near the ground, then zoomed out past the limit.
|
||||
aim(camera, { alt: 3_834_000, pitch: 10 });
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
|
||||
// Only the limb on a 16:9 screen still counts as lost.
|
||||
const wide = realCamera({ width: 1600, height: 900 });
|
||||
const disposeWide = installGlobeInViewRecovery(wide.viewer);
|
||||
aim(wide.camera, limbOnlyView);
|
||||
wide.camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6));
|
||||
disposeWide();
|
||||
|
||||
dispose();
|
||||
dispose();
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert');
|
||||
});
|
||||
|
||||
// True when a moveEnd leaves the camera's view direction as it was.
|
||||
function moveEndKeepsView(camera) {
|
||||
const direction = Cesium.Cartesian3.clone(camera.directionWC);
|
||||
camera.moveEnd.raiseEvent();
|
||||
return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9);
|
||||
}
|
||||
|
||||
test('globe recovery leaves partial, low, locked and 2D views alone', () => {
|
||||
for (const view of partialGlobeViews) {
|
||||
const { camera, viewer } = realCamera();
|
||||
installGlobeInViewRecovery(viewer);
|
||||
aim(camera, view);
|
||||
assert.ok(moveEndKeepsView(camera), JSON.stringify(view));
|
||||
}
|
||||
|
||||
const low = realCamera();
|
||||
installGlobeInViewRecovery(low.viewer);
|
||||
aim(low.camera, { alt: 900_000, pitch: 85 });
|
||||
assert.equal(globeOutOfFrame(low.camera), true);
|
||||
assert.ok(moveEndKeepsView(low.camera), 'below the limit');
|
||||
|
||||
// Locked on a point in space, between it and the planet, facing away.
|
||||
const locked = realCamera();
|
||||
installGlobeInViewRecovery(locked.viewer);
|
||||
locked.camera.lookAtTransform(
|
||||
Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)),
|
||||
new Cesium.Cartesian3(-1_000_000, 0, 0),
|
||||
);
|
||||
assert.equal(globeOutOfFrame(locked.camera), true);
|
||||
assert.ok(moveEndKeepsView(locked.camera), 'locked camera');
|
||||
|
||||
// Tracking an entity that has no position yet keeps an identity transform.
|
||||
const tracked = realCamera();
|
||||
tracked.viewer.trackedEntity = { id: 'pending-fix' };
|
||||
installGlobeInViewRecovery(tracked.viewer);
|
||||
aim(tracked.camera, reportedStuckView);
|
||||
assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera');
|
||||
|
||||
const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW });
|
||||
installGlobeInViewRecovery(flat.viewer);
|
||||
flat.scene.mode = Cesium.SceneMode.SCENE3D;
|
||||
aim(flat.camera, reportedStuckView);
|
||||
flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
|
||||
assert.ok(moveEndKeepsView(flat.camera), 'Columbus view');
|
||||
});
|
||||
|
||||
test('the look limit from orbit tolerates grid noise and never reaches zero', () => {
|
||||
const radians = Cesium.Math.toRadians;
|
||||
// About 2% of a 16:9 frame on the planet, below the 10% limit.
|
||||
const sliver = { alt: 18_000_000, pitch: -60 };
|
||||
const { camera } = realCamera();
|
||||
aim(camera, sliver);
|
||||
const start = surfacePixelShare(camera, 1600, 900);
|
||||
assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`);
|
||||
|
||||
// Sideways across the sliver: the share wobbles by a cell, the planet stays.
|
||||
assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false);
|
||||
const startShare = globeFrameShare(camera);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), {
|
||||
startShare,
|
||||
});
|
||||
assert.equal(step, radians(0.25), `sideways step ${i + 1}`);
|
||||
camera.lookLeft(step);
|
||||
}
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
|
||||
// Toward the planet is always allowed.
|
||||
aim(camera, sliver);
|
||||
assert.equal(
|
||||
lookAngleWithinFrame(camera, 'lookDown', radians(10)),
|
||||
radians(10),
|
||||
);
|
||||
|
||||
// Away from it stops short of losing it.
|
||||
aim(camera, sliver);
|
||||
const away = lookAngleWithinFrame(camera, 'lookUp', radians(30));
|
||||
assert.ok(away < radians(30));
|
||||
camera.lookUp(away);
|
||||
assert.ok(globeFrameShare(camera) > 0);
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
});
|
||||
|
||||
test('the look limit leaves a view with no surface to the move-end recovery', () => {
|
||||
const { camera } = realCamera({ width: 1225, height: 923 });
|
||||
aim(camera, reportedStuckView);
|
||||
assert.equal(
|
||||
lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)),
|
||||
Cesium.Math.toRadians(5),
|
||||
);
|
||||
});
|
||||
|
||||
// Surface only in the corners of a 16:9 frame, all of it outside the scope's
|
||||
// keyhole: the full frame shows some planet, the scope shows none.
|
||||
const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 };
|
||||
|
||||
test('with a scope keyhole, surface hidden in the masked corners counts as none', () => {
|
||||
const { camera } = realCamera();
|
||||
aim(camera, cornerOnlyView);
|
||||
const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS };
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0);
|
||||
assert.equal(globeOutOfFrame(camera), false);
|
||||
assert.equal(globeOutOfFrame(camera, keyhole), true);
|
||||
aim(camera, { alt: 18_000_000, pitch: -90 });
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
globeFrameShare(camera, keyhole) -
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: true }),
|
||||
) < 0.03,
|
||||
'the keyhole share matches the pick-ray oracle',
|
||||
);
|
||||
});
|
||||
|
||||
test('the recovery follows the scope: corner-only views are turned back only behind it', () => {
|
||||
for (const [scopeOn, expectRecovery] of [
|
||||
[true, true],
|
||||
[false, false],
|
||||
]) {
|
||||
const { camera, viewer } = realCamera();
|
||||
installGlobeInViewRecovery(viewer, {
|
||||
keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0),
|
||||
});
|
||||
aim(camera, cornerOnlyView);
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
expectRecovery,
|
||||
`scope ${scopeOn ? 'on' : 'off'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the recovery leaves a camera flight and an authored scene alone', () => {
|
||||
let playing = true;
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
scene.camera = camera;
|
||||
scene.screenSpaceCameraController = { enableInputs: true };
|
||||
installGlobeInViewRecovery(viewer, { paused: () => playing });
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'a playing scene is left alone');
|
||||
playing = false;
|
||||
|
||||
// A flight that pauses looking away is left alone until it ends.
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(10, 20, 18_000_000),
|
||||
duration: 5,
|
||||
});
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'a flight is left alone');
|
||||
camera.cancelFlight();
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
});
|
||||
|
||||
test('a check a scene or flight defers runs once it ends', () => {
|
||||
for (const viaMoveEnd of [false, true]) {
|
||||
let playing = true;
|
||||
const timers = fakeTimers();
|
||||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(viewer, {
|
||||
paused: () => playing,
|
||||
...timers,
|
||||
});
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
// A hold on a stars-only shot: quiet, with or without Cesium's moveEnd.
|
||||
for (let i = 0; i < 4; i += 1) timers.tick();
|
||||
if (viaMoveEnd) camera.moveEnd.raiseEvent();
|
||||
// Longer than the settle check's five-minute bound.
|
||||
for (let i = 0; i < 1500; i += 1) timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays');
|
||||
assert.equal(timers.pending(), 1, 'still watching');
|
||||
playing = false;
|
||||
timers.tick();
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
`recovered once the scene ends (${viaMoveEnd ? 'after moveEnd' : 'no moveEnd'})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Holding a drag still for half a second on a view that already showed no
|
||||
// surface looked like a settled camera, and the view was turned down under
|
||||
// the user's hand.
|
||||
test('the recovery waits while a pointer is held on the globe', () => {
|
||||
const timers = fakeTimers();
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
const document = new EventTarget();
|
||||
scene.canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1225,
|
||||
clientHeight: 923,
|
||||
ownerDocument: document,
|
||||
});
|
||||
const dispose = installGlobeInViewRecovery(viewer, timers);
|
||||
aim(camera, reportedStuckView);
|
||||
scene.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
camera.moveEnd.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'left alone while held');
|
||||
document.dispatchEvent(new Event('pointerup'));
|
||||
for (let i = 0; i < 3; i += 1) timers.tick();
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
'recovered once the pointer is released',
|
||||
);
|
||||
dispose();
|
||||
aim(camera, reportedStuckView);
|
||||
scene.canvas.dispatchEvent(new Event('pointerdown'));
|
||||
document.dispatchEvent(new Event('pointerup'));
|
||||
assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners');
|
||||
});
|
||||
|
||||
/** A held-pointer recovery over a canvas in a document in a window. */
|
||||
function heldPointerRecovery() {
|
||||
const timers = fakeTimers();
|
||||
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
const window = new EventTarget();
|
||||
const document = Object.assign(new EventTarget(), {
|
||||
defaultView: window,
|
||||
visibilityState: 'visible',
|
||||
});
|
||||
scene.canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1225,
|
||||
clientHeight: 923,
|
||||
ownerDocument: document,
|
||||
});
|
||||
const dispose = installGlobeInViewRecovery(viewer, timers);
|
||||
const pointer = (target, type, pointerId, init = {}) =>
|
||||
target.dispatchEvent(
|
||||
Object.assign(new Event(type), { pointerId, ...init }),
|
||||
);
|
||||
/** Press, move and hold still: the check defers while a pointer is held. */
|
||||
const holdStill = () => {
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
camera.moveEnd.raiseEvent();
|
||||
for (let i = 0; i < 8; i += 1) timers.tick();
|
||||
};
|
||||
const settle = () => {
|
||||
for (let i = 0; i < 3; i += 1) timers.tick();
|
||||
};
|
||||
const recovered = () =>
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6);
|
||||
return {
|
||||
camera,
|
||||
document,
|
||||
window,
|
||||
dispose,
|
||||
pointer,
|
||||
holdStill,
|
||||
settle,
|
||||
recovered,
|
||||
canvas: scene.canvas,
|
||||
};
|
||||
}
|
||||
|
||||
// Releasing one of two fingers cleared the held flag, and the recovery
|
||||
// turned the view down under the finger still on the glass.
|
||||
test('the recovery waits until every held pointer is released', () => {
|
||||
const h = heldPointerRecovery();
|
||||
h.pointer(h.canvas, 'pointerdown', 1);
|
||||
h.pointer(h.canvas, 'pointerdown', 2);
|
||||
h.holdStill();
|
||||
h.pointer(h.document, 'pointerup', 1);
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), false, 'left alone under the second finger');
|
||||
assert.equal(globeOutOfFrame(h.camera), true);
|
||||
h.pointer(h.document, 'pointercancel', 2);
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), true, 'recovered once both are released');
|
||||
h.dispose();
|
||||
});
|
||||
|
||||
// A release the page never sees (the pointer let go outside the window)
|
||||
// must not hold the recovery off for good.
|
||||
for (const [label, lose] of [
|
||||
['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))],
|
||||
[
|
||||
'the page is hidden',
|
||||
(h) => {
|
||||
h.document.visibilityState = 'hidden';
|
||||
h.document.dispatchEvent(new Event('visibilitychange'));
|
||||
},
|
||||
],
|
||||
[
|
||||
'the canvas loses the pointer capture',
|
||||
(h) => h.pointer(h.canvas, 'lostpointercapture', 1),
|
||||
],
|
||||
[
|
||||
'the pointer moves with no button down',
|
||||
(h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }),
|
||||
],
|
||||
]) {
|
||||
test(`a held pointer whose release is lost stops deferring when ${label}`, () => {
|
||||
const h = heldPointerRecovery();
|
||||
h.pointer(h.canvas, 'pointerdown', 1);
|
||||
h.holdStill();
|
||||
h.pointer(h.document, 'pointermove', 1, { buttons: 1 });
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), false, 'left alone while held');
|
||||
lose(h);
|
||||
h.settle();
|
||||
assert.equal(h.recovered(), true, 'recovered once the hold is let go');
|
||||
h.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
function fakeTimers() {
|
||||
let next = 1;
|
||||
const pending = new Map();
|
||||
return {
|
||||
setTimeout: (callback) => {
|
||||
const id = next++;
|
||||
pending.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => pending.delete(id),
|
||||
pending: () => pending.size,
|
||||
/** Run the earliest pending callback, as 250 ms passing would. */
|
||||
tick() {
|
||||
const [id, callback] = pending.entries().next().value ?? [];
|
||||
if (id === undefined) return false;
|
||||
pending.delete(id);
|
||||
callback();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => {
|
||||
// At this pose the camera can drift by a rounding error every frame, so
|
||||
// Cesium never sees it stop and never raises moveEnd for it.
|
||||
const timers = fakeTimers();
|
||||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(viewer, timers);
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough');
|
||||
// A rounding-error drift between samples still counts as at rest.
|
||||
camera.lookUp(1e-15);
|
||||
timers.tick();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
// Under that drift Cesium raises no new moveStart, so a later move that
|
||||
// ends looking away is caught by the same check.
|
||||
aim(camera, reportedStuckView);
|
||||
timers.tick();
|
||||
timers.tick();
|
||||
assert.equal(globeOutOfFrame(camera), true, 'not yet at rest');
|
||||
timers.tick();
|
||||
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
|
||||
camera.moveEnd.raiseEvent();
|
||||
assert.equal(timers.pending(), 0, 'moveEnd ends the check');
|
||||
});
|
||||
|
||||
test('the settle check follows a long move and gives up five minutes after the last one', () => {
|
||||
// A drag of half a minute that stops at a stuck pose is still recovered.
|
||||
const long = fakeTimers();
|
||||
const dragged = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(dragged.viewer, long);
|
||||
aim(dragged.camera, reportedStuckView);
|
||||
dragged.camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
dragged.camera.moveLeft(0.01);
|
||||
long.tick();
|
||||
}
|
||||
assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving');
|
||||
while (long.tick());
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6),
|
||||
'recovered once it stopped',
|
||||
);
|
||||
|
||||
// A camera that keeps moving is followed, and left alone, for as long as it
|
||||
// moves; once it rests the check gives up five minutes later.
|
||||
const timers = fakeTimers();
|
||||
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
|
||||
installGlobeInViewRecovery(viewer, timers);
|
||||
aim(camera, reportedStuckView);
|
||||
camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 2000; i += 1) {
|
||||
camera.moveLeft(0.01);
|
||||
timers.tick();
|
||||
}
|
||||
assert.equal(timers.pending(), 1, 'still following after eight minutes');
|
||||
assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone');
|
||||
let idleSamples = 0;
|
||||
while (timers.tick()) idleSamples += 1;
|
||||
assert.ok(
|
||||
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
|
||||
'recovered once it stopped',
|
||||
);
|
||||
assert.ok(
|
||||
idleSamples >= 1200 && idleSamples <= 1205,
|
||||
`gave up ${idleSamples} samples after it stopped`,
|
||||
);
|
||||
|
||||
const disposed = fakeTimers();
|
||||
const other = realCamera({ width: 1225, height: 923 });
|
||||
const dispose = installGlobeInViewRecovery(other.viewer, disposed);
|
||||
aim(other.camera, reportedStuckView);
|
||||
other.camera.moveStart.raiseEvent();
|
||||
dispose();
|
||||
assert.equal(disposed.pending(), 0, 'dispose cancels the pending check');
|
||||
});
|
||||
@@ -5,6 +5,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
|
||||
satellitesLayer: 'satellites',
|
||||
cctvLayer: 'cctv',
|
||||
streetLevelLayer: 'street-level',
|
||||
wildlifeSightingsLayer: 'wildlife-sightings',
|
||||
radioLayer: 'radio',
|
||||
bikeshareLayer: 'bikeshare',
|
||||
transitLayer: 'transit',
|
||||
|
||||
@@ -139,6 +139,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
'rocket-launches',
|
||||
'street-level',
|
||||
'local-adsb',
|
||||
'wildlife-sightings',
|
||||
];
|
||||
const layers = ids.map((id) => ({ id }));
|
||||
const catalog = createLayerCatalog(layers, metadata(layers));
|
||||
@@ -147,6 +148,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
assert.equal(services.transitLayer, layers[7]);
|
||||
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
||||
assert.equal(services.localAdsbLayer, layers[13]);
|
||||
assert.equal(services.wildlifeSightingsLayer, layers[14]);
|
||||
assert.equal(new Set(Object.values(services)).size, layers.length);
|
||||
assert.throws(
|
||||
() => catalogControlServices(createLayerCatalog([], [])),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 37);
|
||||
assert.equal(first.layers.length, 38);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'osm-places'),
|
||||
@@ -161,7 +161,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 37);
|
||||
assert.equal(catalog.layers.length, 38);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createWildlifeSightingsLayer } from '../../layers/wildlifeSightings/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
import * as focus from '../../worldFocus.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Bind the wildlife layer to the application's scene and selection owners. */
|
||||
export function createApplicationWildlifeSightings(options) {
|
||||
return createWildlifeSightingsLayer({
|
||||
overlayHost: {
|
||||
setEntries: overlays.setOverlayEntries,
|
||||
setVisible: overlays.setOverlaySourceVisible,
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
sprites,
|
||||
render,
|
||||
context,
|
||||
focus,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createApplicationOperations } from './operations.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createApplicationViewer,
|
||||
installFreeLookHeightLimit,
|
||||
installTrackpadPinchZoom,
|
||||
} from '../app/viewer.js';
|
||||
import { registerDataCredits } from '../data/dataCredits.js';
|
||||
@@ -58,6 +59,7 @@ export async function createApplicationScene({
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
});
|
||||
defer(installTrackpadPinchZoom(viewer));
|
||||
defer(installFreeLookHeightLimit(viewer));
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||
|
||||
@@ -50,6 +50,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
|
||||
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
|
||||
'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 37);
|
||||
assert.equal(actual.layers.length, 38);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
+15
-1
@@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||
import { installViews, isEmbeddedInline } from './embed.js';
|
||||
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
|
||||
import {
|
||||
installScopeMask,
|
||||
destroyScopeMask,
|
||||
isScopeMaskEnabled,
|
||||
} from '../scopeMask.js';
|
||||
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
|
||||
import { installGlobeInViewRecovery } from './viewer.js';
|
||||
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
|
||||
import {
|
||||
installRenderGovernor,
|
||||
@@ -44,6 +50,14 @@ export function createApplicationTools({
|
||||
.get('bhote-koshi-2026')
|
||||
?.module.attachSceneController(sceneDirector);
|
||||
defer(() => sceneDirector.destroy());
|
||||
defer(
|
||||
installGlobeInViewRecovery(viewer, {
|
||||
// The scope mask hides everything outside its keyhole.
|
||||
keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0),
|
||||
// An authored scene frames its own shots, holds included.
|
||||
paused: () => sceneDirector.running,
|
||||
}),
|
||||
);
|
||||
onSceneDirector?.(sceneDirector);
|
||||
const annotations = initAnnotations({
|
||||
viewer,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
|
||||
import { StarrySky } from '../sky/starrySky.js';
|
||||
import {
|
||||
FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
globeOutOfFrame,
|
||||
isFreeGlobeCamera,
|
||||
} from '../globeInView.js';
|
||||
|
||||
const PINCH_ZOOM_MULTIPLIER = 8;
|
||||
const MAX_PINCH_PIXEL_DELTA = 120;
|
||||
@@ -103,6 +108,231 @@ export function installTrackpadPinchZoom(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow Cesium's free look only below `maxHeight` and return its disposer.
|
||||
* Orbit, tilt and zoom stay available at every height. A value other code
|
||||
* sets later is kept as the one to allow below that height, and restored on
|
||||
* dispose; a `false` written while the camera is above `maxHeight` cannot be
|
||||
* told apart from this limit's own write, so dispose restores the earlier
|
||||
* value then. `enableLook` gates the Shift+drag binding only: below its trackball
|
||||
* height Cesium also turns the camera in place for a drag that starts on the
|
||||
* stars. That height is lowered to `maxHeight` too, so above it such a drag
|
||||
* orbits the globe, as it does above Cesium's own 7,500 km.
|
||||
*/
|
||||
export function installFreeLookHeightLimit(
|
||||
viewer,
|
||||
maxHeight = FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const controller = scene?.screenSpaceCameraController;
|
||||
const camera = viewer?.camera;
|
||||
if (!controller || !camera || !scene?.preUpdate)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
const originalTrackBallHeight = controller.minimumTrackBallHeight;
|
||||
const trackBallHeight = Math.min(originalTrackBallHeight, maxHeight);
|
||||
controller.minimumTrackBallHeight = trackBallHeight;
|
||||
let wanted = controller.enableLook;
|
||||
let written;
|
||||
const sync = () => {
|
||||
if (written !== undefined && controller.enableLook !== written)
|
||||
wanted = controller.enableLook;
|
||||
const height = camera.positionCartographic?.height;
|
||||
written = wanted && !(Number.isFinite(height) && height > maxHeight);
|
||||
controller.enableLook = written;
|
||||
};
|
||||
sync();
|
||||
const removeListener = scene.preUpdate.addEventListener(sync);
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
removeListener();
|
||||
if (controller.enableLook === written) controller.enableLook = wanted;
|
||||
if (controller.minimumTrackBallHeight === trackBallHeight)
|
||||
controller.minimumTrackBallHeight = originalTrackBallHeight;
|
||||
};
|
||||
}
|
||||
|
||||
// How often a moving camera is sampled to see whether it has come to rest, how
|
||||
// far a pose may drift between samples and still count as at rest, and how
|
||||
// long after the camera last moved the check gives up if Cesium never raises
|
||||
// moveEnd.
|
||||
const SETTLE_SAMPLE_MS = 250;
|
||||
const SETTLE_POSITION_TOLERANCE_M = 1e-3;
|
||||
const SETTLE_DIRECTION_TOLERANCE = 1e-9;
|
||||
const SETTLE_GIVE_UP_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* Turn the camera back to the planet when a move ends above `minHeight` with
|
||||
* no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about
|
||||
* one sample cell thick can count as none), and return the disposer. Views with
|
||||
* more surface, such as an oblique orbital shot, are left as they are. This
|
||||
* catches a saved or shared view that looked away, or any other move that ends
|
||||
* looking away from the planet.
|
||||
* Heading and position are kept. Tracked cameras, locked ones (non-identity
|
||||
* transform), a camera flight in progress, a pointer held down on the canvas
|
||||
* (a drag held still), the 2D and Columbus modes, and any time `paused`
|
||||
* returns true (an authored scene playing) are left alone; a check they defer
|
||||
* runs once they end, however long they last.
|
||||
* `keyholeRadius`
|
||||
* returns the radius of the visible circle when a scope mask hides the rest of
|
||||
* the frame (see `globeFrameShare`), or 0; it is read at every check.
|
||||
*
|
||||
* Cesium's `moveEnd` alone is not enough: Cesium compares the camera with the
|
||||
* last frame's to the last rounding error, and at some of these poses the
|
||||
* camera drifts by a rounding error every frame, so Cesium never sees it stop.
|
||||
* So each `moveStart` also starts a check of its own that samples the pose
|
||||
* every 250 ms, with a tolerance for that drift, and once the pose has held
|
||||
* for half a second after moving runs the same recovery. It keeps watching
|
||||
* until Cesium raises `moveEnd`, since under that drift Cesium raises no new
|
||||
* `moveStart` for a later move, and gives up five minutes after the camera
|
||||
* last moved.
|
||||
* `setTimeout` and `clearTimeout` can be injected for tests.
|
||||
*/
|
||||
export function installGlobeInViewRecovery(
|
||||
viewer,
|
||||
{
|
||||
minHeight = FREE_LOOK_MAX_HEIGHT_METERS,
|
||||
keyholeRadius = () => 0,
|
||||
paused = () => false,
|
||||
setTimeout: schedule = globalThis.setTimeout,
|
||||
clearTimeout: unschedule = globalThis.clearTimeout,
|
||||
} = {},
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const camera = viewer?.camera;
|
||||
if (!scene || !camera?.moveEnd || !camera?.moveStart)
|
||||
throw new TypeError('A complete Cesium viewer is required');
|
||||
|
||||
// A pointer held on the globe is a gesture in progress, even when it holds
|
||||
// still for half a second: the check waits until every pointer pressed on
|
||||
// it (each finger of a pinch) is released. A release the page never sees,
|
||||
// outside the window, is taken as given when the window loses focus, the
|
||||
// page is hidden, the canvas loses the pointer's capture, or the pointer
|
||||
// moves with no button down.
|
||||
const canvas = scene.canvas;
|
||||
const releaseTarget = canvas?.ownerDocument ?? canvas;
|
||||
const releaseWindow = canvas?.ownerDocument?.defaultView;
|
||||
const heldPointers = new Set();
|
||||
const onPointerDown = (event) => heldPointers.add(event?.pointerId);
|
||||
const onPointerUp = (event) => heldPointers.delete(event?.pointerId);
|
||||
const onPointerMove = (event) => {
|
||||
if (event?.buttons === 0) heldPointers.delete(event.pointerId);
|
||||
};
|
||||
const releaseAll = () => heldPointers.clear();
|
||||
const onVisibilityChange = () => {
|
||||
if (canvas.ownerDocument.visibilityState === 'hidden') releaseAll();
|
||||
};
|
||||
const listeners = [
|
||||
[canvas, 'pointerdown', onPointerDown],
|
||||
[canvas, 'lostpointercapture', onPointerUp],
|
||||
[releaseTarget, 'pointerup', onPointerUp],
|
||||
[releaseTarget, 'pointercancel', onPointerUp],
|
||||
[releaseTarget, 'pointermove', onPointerMove],
|
||||
[canvas?.ownerDocument, 'visibilitychange', onVisibilityChange],
|
||||
[releaseWindow, 'blur', releaseAll],
|
||||
];
|
||||
for (const [target, type, listener] of listeners)
|
||||
target?.addEventListener?.(type, listener);
|
||||
|
||||
// Returns false while a flight, a paused scene or a held pointer defers the
|
||||
// check, so the caller can try again once it ends.
|
||||
const recover = () => {
|
||||
if (!isFreeGlobeCamera(viewer)) return true;
|
||||
// Cesium has no public running-flight flag.
|
||||
if (camera._currentFlight || paused() || heldPointers.size > 0)
|
||||
return false;
|
||||
const height = camera.positionCartographic?.height;
|
||||
if (
|
||||
!(height > minHeight) ||
|
||||
!globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() })
|
||||
)
|
||||
return true;
|
||||
camera.setView({
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
scene.requestRender?.();
|
||||
return true;
|
||||
};
|
||||
|
||||
const lastPosition = new Cesium.Cartesian3();
|
||||
const lastDirection = new Cesium.Cartesian3();
|
||||
let settleTimer = null;
|
||||
let settleElapsed = 0;
|
||||
let restingSamples = 0;
|
||||
let movedSinceCheck = false;
|
||||
const stopSettling = () => {
|
||||
if (settleTimer !== null) unschedule(settleTimer);
|
||||
settleTimer = null;
|
||||
};
|
||||
const sample = () => {
|
||||
settleTimer = null;
|
||||
settleElapsed += SETTLE_SAMPLE_MS;
|
||||
const atRest =
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
camera.positionWC,
|
||||
lastPosition,
|
||||
0,
|
||||
SETTLE_POSITION_TOLERANCE_M,
|
||||
) &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
camera.directionWC,
|
||||
lastDirection,
|
||||
0,
|
||||
SETTLE_DIRECTION_TOLERANCE,
|
||||
);
|
||||
restingSamples = atRest ? restingSamples + 1 : 0;
|
||||
if (!atRest) {
|
||||
movedSinceCheck = true;
|
||||
settleElapsed = 0;
|
||||
}
|
||||
// Two quiet samples in a row after a move: at rest for half a second, as
|
||||
// for moveEnd.
|
||||
if (restingSamples >= 2 && movedSinceCheck) {
|
||||
if (recover()) movedSinceCheck = false;
|
||||
// A flight or a playing scene bounds its own deferral.
|
||||
else settleElapsed = 0;
|
||||
}
|
||||
if (settleElapsed >= SETTLE_GIVE_UP_MS) return;
|
||||
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
|
||||
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
|
||||
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
|
||||
};
|
||||
const startSettling = () => {
|
||||
stopSettling();
|
||||
settleElapsed = 0;
|
||||
restingSamples = 0;
|
||||
movedSinceCheck = true;
|
||||
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
|
||||
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
|
||||
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
|
||||
};
|
||||
const removeMoveEnd = camera.moveEnd.addEventListener(() => {
|
||||
if (recover()) stopSettling();
|
||||
// Deferred: keep sampling so the check runs once the flight or scene ends.
|
||||
else if (settleTimer === null) startSettling();
|
||||
});
|
||||
const removeMoveStart = camera.moveStart.addEventListener(startSettling);
|
||||
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
stopSettling();
|
||||
removeMoveEnd();
|
||||
removeMoveStart();
|
||||
for (const [target, type, listener] of listeners)
|
||||
target?.removeEventListener?.(type, listener);
|
||||
heldPointers.clear();
|
||||
};
|
||||
}
|
||||
|
||||
/** Cesium.Viewer options of the standard globe viewer. */
|
||||
export function applicationViewerOptions(creditContainer) {
|
||||
return {
|
||||
|
||||
@@ -13,8 +13,16 @@ import {
|
||||
moveCamera,
|
||||
} from './cameraVerbs.js';
|
||||
import { getRenderGovernorDiagnostics } from './renderGovernor.js';
|
||||
import {
|
||||
MIN_GLOBE_FRAME_SHARE,
|
||||
globeFrameShare,
|
||||
globeOutOfFrame,
|
||||
} from './globeInView.js';
|
||||
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
|
||||
import { setScopeMaskEnabled } from './scopeMask.js';
|
||||
|
||||
const radians = Cesium.Math.toRadians;
|
||||
const degrees = Cesium.Math.toDegrees;
|
||||
const clone = (value) => Cesium.Cartesian3.clone(value);
|
||||
const longitude = (camera) =>
|
||||
Cesium.Cartographic.fromCartesian(camera.positionWC).longitude;
|
||||
@@ -1347,3 +1355,664 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer
|
||||
samePose(h.camera, before);
|
||||
assert.equal(hasRenderHold(), false);
|
||||
});
|
||||
|
||||
/**
|
||||
* An oracle independent of the code under test: the share of an 80×45 grid of
|
||||
* Cesium pick rays across the canvas that hit the ellipsoid surface. With
|
||||
* `keyhole`, only the pixels inside the scope's visible circle count.
|
||||
*/
|
||||
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
|
||||
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
|
||||
let hits = 0;
|
||||
let counted = 0;
|
||||
for (let row = 0; row < 45; row += 1) {
|
||||
for (let column = 0; column < 80; column += 1) {
|
||||
const x = ((column + 0.5) * width) / 80;
|
||||
const y = ((row + 0.5) * height) / 45;
|
||||
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
|
||||
continue;
|
||||
counted += 1;
|
||||
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
|
||||
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
return hits / counted;
|
||||
}
|
||||
|
||||
/** Run a test with the scope mask on or off, restoring the default after. */
|
||||
function withScope(t, enabled) {
|
||||
setScopeMaskEnabled(enabled);
|
||||
t.after(() => setScopeMaskEnabled(true));
|
||||
}
|
||||
|
||||
// The frame limit is measured on a coarser grid than the oracle.
|
||||
const SHARE_SLACK = 0.02;
|
||||
// Far-out poses, height in metres and pitch in degrees, where a few tilts up
|
||||
// are accepted before the limit refuses the next one.
|
||||
const FAR_OUT_POSES = [
|
||||
[35_884_000, -86.26],
|
||||
[35_884_000, -89],
|
||||
[30_000_000, -86],
|
||||
[25_000_000, -80],
|
||||
[40_000_000, -88],
|
||||
];
|
||||
|
||||
test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// Slightly off nadir: from straight down a tilt is clamped before it moves.
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const results = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'once',
|
||||
});
|
||||
results.push(result);
|
||||
if (result.ok) finishOnce();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`after tilt ${i + 1}`,
|
||||
);
|
||||
}
|
||||
// From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down
|
||||
// to about -67°: the first tilt stops there, and later ones are refused.
|
||||
assert.deepEqual(
|
||||
results.map((result) => result.ok),
|
||||
[true, false, false, false],
|
||||
);
|
||||
assert.match(results[1].error, /globe out of view/);
|
||||
assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66));
|
||||
});
|
||||
|
||||
// The frame limit judges a tilt by the way its pitch moves, not by the
|
||||
// direction word: a tilt down or to a steeper angle turns toward the planet.
|
||||
for (const args of [{ direction: 'down' }, { pitchDeg: -80 }]) {
|
||||
test(`a tilt ${JSON.stringify(args)} from the frame limit is judged as the look down it is`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
finishOnce();
|
||||
const atLimit = camera.pitch;
|
||||
const result = moveCamera({ motion: 'tilt', ...args });
|
||||
assert.equal(result.ok, true, result.error);
|
||||
finishOnce();
|
||||
assert.ok(camera.pitch < atLimit - radians(5), 'the tilt turned down');
|
||||
});
|
||||
}
|
||||
|
||||
for (const args of [{ pitchDeg: -10 }, {}]) {
|
||||
test(`a tilt ${JSON.stringify(args)} up from the frame limit is refused, not reported as done`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
finishOnce();
|
||||
const atLimit = camera.pitch;
|
||||
// A bare tilt means -45°: up from the -67° limit.
|
||||
const result = moveCamera({ motion: 'tilt', ...args });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /globe out of view/);
|
||||
assert.equal(camera.pitch, atLimit);
|
||||
});
|
||||
}
|
||||
|
||||
// A tilt to an angle, or a bare tilt (-45°), from orbit turns in place like
|
||||
// a nudge and must keep the planet in frame the whole way, or the globe
|
||||
// recovery would snap the view back down.
|
||||
for (const [label, args, options] of [
|
||||
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
|
||||
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
|
||||
[
|
||||
'a continuous tilt up',
|
||||
{ motion: 'tilt', direction: 'up', mode: 'continuous' },
|
||||
{ pitch: -90 },
|
||||
],
|
||||
[
|
||||
'a tilt to -5° from 2,000 km',
|
||||
{ motion: 'tilt', pitchDeg: -5 },
|
||||
{ pitch: -90, height: 2_000_000 },
|
||||
],
|
||||
]) {
|
||||
test(`${label} from top-down never turns the planet out of frame`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, tick } = harness(t, {
|
||||
...options,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const startPitch = camera.pitch;
|
||||
const result = moveCamera(args);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
tick();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`frame ${i}: the planet left the frame`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
|
||||
assert.equal(globeOutOfFrame(camera), false, 'no snap-back');
|
||||
if (result.ok) assert.ok(camera.pitch > startPitch, 'the tilt moved');
|
||||
});
|
||||
}
|
||||
|
||||
// The frame limit stops a tilt to an angle short of it: the reply names the
|
||||
// angle the camera reaches, not the one asked for (a tilt to -10° from
|
||||
// top-down reported -10° and ended at -66.5°).
|
||||
for (const [label, args, options] of [
|
||||
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
|
||||
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
|
||||
[
|
||||
'a tilt to -10° from -80°',
|
||||
{ motion: 'tilt', pitchDeg: -10 },
|
||||
{ pitch: -80 },
|
||||
],
|
||||
[
|
||||
'a tilt to -5° from 2,000 km',
|
||||
{ motion: 'tilt', pitchDeg: -5 },
|
||||
{ pitch: -90, height: 2_000_000 },
|
||||
],
|
||||
// A rolled camera's look turns its pitch by less than the look angle:
|
||||
// reported -25°, ended at -27.3°.
|
||||
[
|
||||
'a tilt to -5° from 2,000 km rolled 30°',
|
||||
{ motion: 'tilt', pitchDeg: -5 },
|
||||
{ pitch: -70, roll: 30, height: 2_000_000 },
|
||||
],
|
||||
]) {
|
||||
test(`${label} from orbit reports the angle it reaches`, (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, tick } = harness(t, {
|
||||
...options,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const result = moveCamera(args);
|
||||
assert.equal(result.ok, true, result.error);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
|
||||
const reached = degrees(camera.pitch);
|
||||
assert.ok(
|
||||
Math.abs(reached - result.pitchDeg) < 1,
|
||||
`reported ${result.pitchDeg}°, reached ${reached}°`,
|
||||
);
|
||||
const asked = args.pitchDeg ?? -45;
|
||||
if (Math.abs(reached - asked) >= 1)
|
||||
assert.match(result.note ?? '', /globe out of view/);
|
||||
else assert.equal(result.note, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
// A continuous tilt promises no angle: it runs until stopped or the frame
|
||||
// limit ends it.
|
||||
test('a continuous tilt up from orbit claims no angle it may not reach', (t) => {
|
||||
withScope(t, false);
|
||||
const { tick } = harness(t, {
|
||||
pitch: -90,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'continuous',
|
||||
});
|
||||
assert.equal(result.ok, true, result.error);
|
||||
assert.equal('pitchDeg' in result, false);
|
||||
assert.equal(result.mode, 'continuous');
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'the frame limit ends it');
|
||||
});
|
||||
|
||||
test('voice turn in place from orbit stops with the planet still on screen', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
const { camera, tick } = harness(t, { pitch: -75 });
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
const heading = camera.heading;
|
||||
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
|
||||
tick();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1280, 800) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`tick ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
|
||||
assert.notEqual(camera.heading, heading, 'the turn moved before stopping');
|
||||
});
|
||||
|
||||
test('a long voice turn below the limit keeps the share it started with', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// About 2% of a 16:9 frame on the planet: the limit is that share, less two
|
||||
// grid cells of sampling noise, for the whole motion rather than per step.
|
||||
const { camera, tick } = harness(t, {
|
||||
pitch: -60,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
|
||||
tick();
|
||||
assert.ok(
|
||||
globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9,
|
||||
`tick ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
});
|
||||
|
||||
test('repeated voice turns from one spot cannot each take a little more', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// About 2% of a 16:9 frame on the planet. Every turn from this position is
|
||||
// measured against the same baseline, so asking again stops being allowed.
|
||||
const { camera, finishOnce, tick } = harness(t, {
|
||||
pitch: -60,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
const floor = start - 2 / (32 * 18) - 1e-9;
|
||||
const results = [];
|
||||
for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = moveCamera({ motion: 'rotate', direction: 'left', mode });
|
||||
results.push(result.ok);
|
||||
if (!result.ok) continue;
|
||||
if (mode === 'once') finishOnce();
|
||||
else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
|
||||
assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`);
|
||||
}
|
||||
}
|
||||
assert.ok(results.includes(false), 'a turn at the limit is refused');
|
||||
assert.equal(results.at(-1), false);
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
});
|
||||
|
||||
test('a voice turn after something else turned the camera starts over', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// The same spot, first framed straight down, then turned to about 2% of the
|
||||
// frame by something other than voice: a turn left is measured from there.
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -90,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
camera.setView({
|
||||
orientation: { heading: 0, pitch: radians(-60), roll: 0 },
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9);
|
||||
});
|
||||
|
||||
test('nudging the camera between voice turns cannot take the planet below the 1% floor', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// A wheel notch or an orbit drag between requests starts each turn afresh;
|
||||
// the 1% floor still holds, and below it a turn may only keep what it has.
|
||||
for (const [nudge, pitch] of [
|
||||
[(camera) => camera.moveBackward(2), -60],
|
||||
[(camera) => camera.rotateRight(radians(0.2)), -60],
|
||||
[(camera) => camera.moveBackward(2), -58],
|
||||
]) {
|
||||
const { camera, tick } = harness(t, {
|
||||
pitch,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const start = globeFrameShare(camera);
|
||||
const floor = Math.min(0.01, start) - 1e-9;
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
nudge(camera);
|
||||
if (
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
|
||||
.ok
|
||||
)
|
||||
for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
|
||||
assert.ok(
|
||||
globeFrameShare(camera) >= floor,
|
||||
`start ${start}, turn ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('after the frame changes size, a voice turn toward the planet is allowed', (t) => {
|
||||
withScope(t, false); // the whole frame is visible
|
||||
// A tilt stops at its limit; then the window widens at the same pose, so the
|
||||
// frame shows less than the carried limit. Toward the planet must still go.
|
||||
const { camera, canvas, viewer, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200;
|
||||
camera.frustum.aspectRatio = 3200 / 900;
|
||||
const narrowed = globeFrameShare(camera);
|
||||
assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`);
|
||||
|
||||
const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' });
|
||||
assert.equal(down.ok, true, down.error);
|
||||
finishOnce();
|
||||
assert.ok(globeFrameShare(camera) > narrowed);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
if (up.ok) finishOnce();
|
||||
assert.ok(surfacePixelShare(camera, 3200, 900) > 0);
|
||||
});
|
||||
|
||||
test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => {
|
||||
withScope(t, true);
|
||||
const { camera, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const results = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'once',
|
||||
});
|
||||
results.push(result.ok);
|
||||
if (result.ok) finishOnce();
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`after tilt ${i + 1}`,
|
||||
);
|
||||
}
|
||||
assert.equal(results[0], true);
|
||||
assert.equal(results.at(-1), false, 'a tilt past the limit is refused');
|
||||
});
|
||||
|
||||
test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => {
|
||||
// An oblique orbit view: turning in place swings the planet toward a corner,
|
||||
// where the full frame still shows it but the scope mask hides it.
|
||||
for (const [height, pitch] of [
|
||||
[5_000_000, -70],
|
||||
[3_000_000, -75],
|
||||
[18_000_000, -80],
|
||||
]) {
|
||||
withScope(t, true);
|
||||
const { camera, tick } = harness(t, {
|
||||
height,
|
||||
pitch,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
|
||||
.ok,
|
||||
true,
|
||||
);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
|
||||
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
|
||||
`${height / 1000} km at ${pitch}°`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => {
|
||||
// A small planet disc: its sampled share wobbles as it crosses the grid.
|
||||
// Whatever the limit decides, "done" must mean the camera turned, and some
|
||||
// of these tilts must be accepted for that to be checked at all.
|
||||
let accepted = 0;
|
||||
for (const [height, pitch] of FAR_OUT_POSES) {
|
||||
withScope(t, true);
|
||||
const { camera, tick } = harness(t, {
|
||||
latitude: 45.37,
|
||||
longitudeDeg: -33.33,
|
||||
height,
|
||||
heading: 89.9,
|
||||
pitch,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 1024,
|
||||
});
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const before = camera.pitch;
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'up',
|
||||
mode: 'once',
|
||||
});
|
||||
if (!result.ok) {
|
||||
assert.match(result.error, /zoom in first/);
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
|
||||
assert.ok(
|
||||
camera.pitch - before > radians(0.5) - 1e-9,
|
||||
`${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`,
|
||||
);
|
||||
}
|
||||
assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0);
|
||||
}
|
||||
assert.ok(accepted > 0, 'no far-out tilt was accepted');
|
||||
});
|
||||
|
||||
test('a voice turn reported as done turns at least the half degree its check proved', (t) => {
|
||||
// A grazing sliver: the first frame's small step lands in a dip of the
|
||||
// sampled share, which used to stop the turn before it moved at all.
|
||||
for (const scope of [true, false]) {
|
||||
withScope(t, scope);
|
||||
const { camera, tick } = harness(t, {
|
||||
latitude: 20,
|
||||
longitudeDeg: 10,
|
||||
height: 18_000_000,
|
||||
pitch: -58,
|
||||
heading: 37,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
const before = Cesium.Cartesian3.clone(camera.directionWC);
|
||||
const result = moveCamera({
|
||||
motion: 'rotate',
|
||||
direction: 'left',
|
||||
mode: 'once',
|
||||
});
|
||||
if (!result.ok) {
|
||||
assert.match(result.error, /globe out of view/);
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
|
||||
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
|
||||
assert.ok(
|
||||
turned >= radians(0.5) - 1e-9,
|
||||
`scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`,
|
||||
);
|
||||
assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0);
|
||||
}
|
||||
});
|
||||
|
||||
/** Track an entity below `camera`, locking it into that entity's frame. */
|
||||
function trackFrom(viewer, camera) {
|
||||
viewer.trackedEntity = { id: 'high-satellite' };
|
||||
const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000);
|
||||
camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below));
|
||||
}
|
||||
|
||||
test('a voice turn that releases a tracked camera is checked as released', (t) => {
|
||||
// Voice releases the follow camera inside the navigation that runs the
|
||||
// motion, so the request is judged by the pose the release will leave.
|
||||
let accepted = 0;
|
||||
for (const [scope, direction, motion] of [
|
||||
[true, 'up', 'tilt'],
|
||||
[false, 'up', 'tilt'],
|
||||
[false, 'left', 'rotate'],
|
||||
]) {
|
||||
withScope(t, scope);
|
||||
const { camera, viewer, tick } = harness(t, {
|
||||
latitude: 20,
|
||||
longitudeDeg: 10,
|
||||
height: 18_000_000,
|
||||
pitch: -58,
|
||||
heading: 37,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
trackFrom(viewer, camera);
|
||||
const releaseThenRun = (start) => {
|
||||
viewer.trackedEntity = undefined;
|
||||
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
return start();
|
||||
};
|
||||
const before = Cesium.Cartesian3.clone(camera.directionWC);
|
||||
const result = moveCamera(
|
||||
{ motion, direction, mode: 'once' },
|
||||
releaseThenRun,
|
||||
);
|
||||
const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`;
|
||||
if (!result.ok) {
|
||||
assert.match(result.error, /globe out of view/, label);
|
||||
continue;
|
||||
}
|
||||
accepted += 1;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
|
||||
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
|
||||
assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`);
|
||||
assert.ok(
|
||||
surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0,
|
||||
`${label}: the planet stays on screen`,
|
||||
);
|
||||
}
|
||||
assert.ok(accepted > 0, 'no released turn was accepted');
|
||||
});
|
||||
|
||||
test('a refused voice turn leaves a tracked camera tracking', (t) => {
|
||||
for (const scope of [true, false]) {
|
||||
withScope(t, scope);
|
||||
const { camera, viewer, tick } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
// Tilt up to the limit untracked, then track from there.
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
|
||||
break;
|
||||
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
|
||||
}
|
||||
trackFrom(viewer, camera);
|
||||
const tracked = viewer.trackedEntity;
|
||||
const transform = Cesium.Matrix4.clone(camera.transform);
|
||||
let released = false;
|
||||
const result = moveCamera(
|
||||
{ motion: 'tilt', direction: 'up', mode: 'once' },
|
||||
(start) => {
|
||||
released = true;
|
||||
viewer.trackedEntity = undefined;
|
||||
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
return start();
|
||||
},
|
||||
);
|
||||
const label = `scope ${scope ? 'on' : 'off'}`;
|
||||
assert.equal(result.ok, false, label);
|
||||
assert.match(result.error, /globe out of view/, label);
|
||||
assert.equal(released, false, label);
|
||||
assert.equal(viewer.trackedEntity, tracked, label);
|
||||
assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label);
|
||||
}
|
||||
});
|
||||
|
||||
test('a turn proven at one position is checked again after a release moves the camera', (t) => {
|
||||
withScope(t, false);
|
||||
const { camera, tick } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
// Tilt up to the limit, then check from 4,000 km closer along the same view
|
||||
// direction, where the next tilt fits; the release moves the camera back.
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
|
||||
break;
|
||||
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
|
||||
}
|
||||
camera.moveForward(4_000_000);
|
||||
const result = moveCamera(
|
||||
{ motion: 'tilt', direction: 'up', mode: 'once' },
|
||||
(start) => {
|
||||
camera.moveBackward(4_000_000);
|
||||
return start();
|
||||
},
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /globe out of view/);
|
||||
});
|
||||
|
||||
test('the voice frame limit leaves Columbus view alone', (t) => {
|
||||
// At its limit in 3D a further tilt up is refused; the same tilt in Columbus
|
||||
// view is not limited by the frame.
|
||||
withScope(t, false);
|
||||
const { viewer, finishOnce } = harness(t, {
|
||||
pitch: -80,
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
});
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
|
||||
true,
|
||||
);
|
||||
finishOnce();
|
||||
const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
assert.match(refused.error || '', /globe out of view/);
|
||||
viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
assert.doesNotMatch(result.error || '', /globe out of view/);
|
||||
});
|
||||
|
||||
test('voice tilt below the free-look height is not limited by the frame', (t) => {
|
||||
const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 });
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
|
||||
assert.equal(result.ok, true);
|
||||
finishOnce();
|
||||
near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt');
|
||||
});
|
||||
@@ -223,11 +223,20 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
'move camera',
|
||||
);
|
||||
ordered(move, [
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
|
||||
'if (argsError) return argsError;',
|
||||
'const start = () => {',
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
], 'move validation before handoff');
|
||||
const moveArgs = body(
|
||||
cameraVerbs,
|
||||
/export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/,
|
||||
'move camera argument checks',
|
||||
);
|
||||
assert.ok(
|
||||
moveArgs.includes("if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))"),
|
||||
'unknown motions are refused before handoff',
|
||||
);
|
||||
|
||||
const route = body(
|
||||
cameraVerbs,
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
adjustOrbitRange,
|
||||
getActiveCameraMotion,
|
||||
initCameraVerbs,
|
||||
interruptCameraMotion,
|
||||
moveCamera,
|
||||
} from './cameraVerbs.js';
|
||||
import { createGevActionRunner } from './voice/gevActions.js';
|
||||
|
||||
const radians = Cesium.Math.toRadians;
|
||||
const degrees = Cesium.Math.toDegrees;
|
||||
|
||||
/** Real Cesium camera over London with no renderer: the voice fly-to frame. */
|
||||
function harness(
|
||||
t,
|
||||
{ height = 4000, heading = 0, pitch = -90, pivot = true } = {},
|
||||
) {
|
||||
const realPerformance = globalThis.performance;
|
||||
let nowMs = 0;
|
||||
globalThis.performance = { now: () => nowMs };
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
clientWidth: 1280,
|
||||
clientHeight: 800,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
});
|
||||
const ellipsoid = Cesium.Ellipsoid.WGS84;
|
||||
const scene = {
|
||||
canvas,
|
||||
drawingBufferWidth: 1280,
|
||||
drawingBufferHeight: 800,
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
mapProjection: new Cesium.GeographicProjection(ellipsoid),
|
||||
globe: { ellipsoid },
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
tweens: [],
|
||||
requestRender() {},
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(-0.1276, 51.5072, height),
|
||||
orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 },
|
||||
});
|
||||
const viewer = {
|
||||
camera,
|
||||
scene,
|
||||
trackedEntity: undefined,
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
clock: { onTick: new Cesium.Event() },
|
||||
};
|
||||
// The app's view target: the ground point under the screen centre.
|
||||
const viewTarget = () => {
|
||||
const ray = new Cesium.Ray(camera.positionWC, camera.directionWC);
|
||||
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
|
||||
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
|
||||
};
|
||||
const target = viewTarget();
|
||||
initCameraVerbs(viewer, pivot ? viewTarget : () => undefined);
|
||||
t.after(() => {
|
||||
interruptCameraMotion('test-cleanup');
|
||||
globalThis.performance = realPerformance;
|
||||
});
|
||||
const tick = (seconds = 1 / 30) => {
|
||||
nowMs += seconds * 1000;
|
||||
viewer.clock.onTick.raiseEvent(viewer.clock);
|
||||
};
|
||||
const finish = () => {
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
};
|
||||
return { viewer, camera, target, tick, finish, viewTarget };
|
||||
}
|
||||
|
||||
const pitchDeg = (camera) => degrees(camera.pitch);
|
||||
/** Tick until done() holds; a regression fails here instead of hanging. */
|
||||
const tickUntil = (h, done, frames = 600) => {
|
||||
for (let i = 0; i < frames && !done(); i += 1) h.tick();
|
||||
assert.ok(done(), `not reached in ${frames} frames`);
|
||||
};
|
||||
const headingDeg = (camera) =>
|
||||
degrees(Cesium.Math.negativePiToPi(camera.heading));
|
||||
|
||||
/** Metres between the original centre and the ground now under the crosshair. */
|
||||
function centreDrift(h) {
|
||||
return Cesium.Cartesian3.distance(h.viewTarget(), h.target);
|
||||
}
|
||||
|
||||
test('tilt up leaves a top-down view and keeps the place centred', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up' });
|
||||
assert.equal(result.ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -75) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
|
||||
});
|
||||
|
||||
test('continuous tilt up from top-down keeps moving', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
for (let i = 0; i < 30; i += 1) h.tick();
|
||||
assert.equal(getActiveCameraMotion()?.kind, 'tilt');
|
||||
assert.ok(pitchDeg(h.camera) > -82, `pitch ${pitchDeg(h.camera)}`);
|
||||
});
|
||||
|
||||
test('a requested angle tilts to that pitch', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.direction, 'up');
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -45) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('a bare tilt settles on the default oblique angle', (t) => {
|
||||
const h = harness(t);
|
||||
const result = moveCamera({ motion: 'tilt' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
|
||||
});
|
||||
|
||||
test('a requested angle can tilt back down toward top-down', (t) => {
|
||||
const h = harness(t, { pitch: -30 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -80 });
|
||||
assert.equal(result.direction, 'down');
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
|
||||
});
|
||||
|
||||
test('requested angles clamp to the tilt limits', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -2 }).pitchDeg, -5);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a positive angle is read as the same angle below the horizon', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 45 }).pitchDeg, -45);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
|
||||
});
|
||||
|
||||
for (const pitch of ['', null, false, ' ', 'steep'])
|
||||
test(`a blank or non-numeric angle ${JSON.stringify(pitch)} is ignored`, (t) => {
|
||||
harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
|
||||
// No angle and no direction: the default oblique view, never the horizon.
|
||||
assert.equal(result.pitchDeg, -45);
|
||||
});
|
||||
|
||||
test('a requested angle the camera already holds is a no-op', (t) => {
|
||||
harness(t, { pitch: -45 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.unchanged, true);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
for (const pitch of [-90, -89]) {
|
||||
test(`a top-down view asked for ${pitch}° stays put`, (t) => {
|
||||
// The tool description calls -89 top-down; the clamp must not tilt a
|
||||
// -90° view up 1° and report it as a move up.
|
||||
const h = harness(t);
|
||||
const before = pitchDeg(h.camera);
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.unchanged, true);
|
||||
assert.equal(result.pitchDeg, Math.round(before));
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
assert.equal(pitchDeg(h.camera), before);
|
||||
});
|
||||
}
|
||||
|
||||
test('tilt down from top-down still refuses honestly', (t) => {
|
||||
harness(t);
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'down' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /straight down/);
|
||||
});
|
||||
|
||||
test('a tilt up nudge stops at the horizon limit', (t) => {
|
||||
const h = harness(t, { pitch: -12 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('without a ground pivot a tilt still leaves top-down in place', (t) => {
|
||||
const h = harness(t, { pivot: false });
|
||||
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -75) < 0.5);
|
||||
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-6);
|
||||
});
|
||||
|
||||
for (const heading of [0, 30, -120])
|
||||
test(`tilt from top-down keeps the ${heading}° heading`, (t) => {
|
||||
const h = harness(t, { heading, height: 67000 });
|
||||
// Top-down float noise must not pick a new heading for the pivot.
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(headingDeg(h.camera) - heading) < 1,
|
||||
`heading ${headingDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(centreDrift(h) < 50, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('an oblique tilt keeps its heading toward the pivot', (t) => {
|
||||
const h = harness(t, { heading: 75, pitch: -60 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -30 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(headingDeg(h.camera) - 75) < 0.5);
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('a pivoting tilt yields when tracking takes the camera', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
|
||||
h.tick();
|
||||
const entity = { id: 'tracked' };
|
||||
h.viewer.trackedEntity = entity;
|
||||
h.viewer.trackedEntityChanged.raiseEvent(entity);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
/** Metres between the camera and the point it pivots about. */
|
||||
const pivotRange = (h) =>
|
||||
Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
|
||||
const heightM = (camera) =>
|
||||
Cesium.Cartographic.fromCartesian(camera.positionWC).height;
|
||||
|
||||
test('a zoom during a pivoting tilt changes the range it holds', (t) => {
|
||||
const h = harness(t);
|
||||
assert.equal(
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
|
||||
true,
|
||||
);
|
||||
h.tick();
|
||||
const before = pivotRange(h);
|
||||
assert.deepEqual(
|
||||
adjustOrbitRange(0.5),
|
||||
{ kind: 'tilt', changed: true },
|
||||
'the tilt must take the zoom',
|
||||
);
|
||||
for (let i = 0; i < 5; i += 1) h.tick();
|
||||
// The next frame's lookAt must not put the old range back.
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - before / 2) < 1,
|
||||
`range ${pivotRange(h)} m, wanted ${before / 2} m`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a zoom with no pivot to hold is left to the camera', (t) => {
|
||||
harness(t, { pivot: false });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
assert.equal(adjustOrbitRange(0.5), null);
|
||||
});
|
||||
|
||||
test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => {
|
||||
// A close top-down view: swinging 250 m of range to -5° would end ~22 m up.
|
||||
const h = harness(t, { height: 250 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -5 }).ok, true);
|
||||
let lowest = Infinity;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
lowest = Math.min(lowest, heightM(h.camera));
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(lowest > 149, `camera dropped to ${lowest} m`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
|
||||
});
|
||||
|
||||
test('a continuous tilt up from close in keeps its clearance', (t) => {
|
||||
const h = harness(t, { height: 250 });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
}
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a city-scale pivot tilt keeps the place centred to the horizon limit', (t) => {
|
||||
const h = harness(t);
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
h.finish();
|
||||
// 4 km of range clears the floor at every pitch: all pivot, no drift.
|
||||
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
|
||||
test('pitchDeg against a contradicting direction is refused', (t) => {
|
||||
harness(t, { pitch: -45 });
|
||||
// "tilt down 10 degrees": -10° is an absolute angle, which is up from -45°.
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'down',
|
||||
pitchDeg: 10,
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /absolute angle/);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
test('pitchDeg with a direction that agrees still tilts', (t) => {
|
||||
const h = harness(t, { pitch: -45 });
|
||||
const result = moveCamera({
|
||||
motion: 'tilt',
|
||||
direction: 'down',
|
||||
pitchDeg: -80,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.direction, 'down');
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
|
||||
});
|
||||
|
||||
test('the pitchDeg and direction check uses the camera pitch', (t) => {
|
||||
// At regional range the Earth curves away and the pivot pitch is a degree
|
||||
// or two shallower than the camera's (-30°); -29° is still up from it.
|
||||
harness(t, { height: 150000, pitch: -30 });
|
||||
const down = moveCamera({ motion: 'tilt', direction: 'down', pitchDeg: -29 });
|
||||
assert.equal(down.ok, false);
|
||||
assert.match(down.error, /tilts up, not down/);
|
||||
const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 });
|
||||
assert.equal(up.ok, true);
|
||||
assert.equal(up.direction, 'up');
|
||||
});
|
||||
|
||||
test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => {
|
||||
const h = harness(t, { height: 1000 });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
tickUntil(h, () => pitchDeg(h.camera) >= -30);
|
||||
adjustOrbitRange(1 / 2.4);
|
||||
adjustOrbitRange(1 / 2.4);
|
||||
h.tick();
|
||||
const afterZoom = heightM(h.camera);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(
|
||||
heightM(h.camera) > afterZoom - 1,
|
||||
`camera fell from ${afterZoom} m to ${heightM(h.camera)} m`,
|
||||
);
|
||||
}
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a zoom in during a close pivot tilt never pushes the camera out', (t) => {
|
||||
// 62 m of range, under the 80 m zoom floor.
|
||||
const h = harness(t, { height: 40, pitch: -40 });
|
||||
moveCamera({ motion: 'tilt', direction: 'down', mode: 'continuous' });
|
||||
h.tick();
|
||||
const before = pivotRange(h);
|
||||
assert.ok(before < 80, `range ${before} m`);
|
||||
adjustOrbitRange(1 / 1.6);
|
||||
h.tick();
|
||||
assert.ok(pivotRange(h) <= before + 0.01, `range grew to ${pivotRange(h)} m`);
|
||||
});
|
||||
|
||||
test('a zoom out during a far orbit never pulls the camera in', (t) => {
|
||||
// 15,000 km up, beyond the 5,000 km zoom cap.
|
||||
const h = harness(t, { height: 15_000_000 });
|
||||
assert.equal(moveCamera({ motion: 'orbit', mode: 'continuous' }).ok, true);
|
||||
h.tick();
|
||||
const before = pivotRange(h);
|
||||
assert.ok(before > 5_000_000, `range ${before} m`);
|
||||
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'orbit', changed: false });
|
||||
h.tick();
|
||||
assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`);
|
||||
});
|
||||
|
||||
test('an out-of-range pitchDeg is clamped to the tilt limits', (t) => {
|
||||
// The schema carries no range; 120 reads as 120° below the horizon.
|
||||
const h = harness(t, { pitch: -45 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 120 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -89) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a zoom in during a pivot tilt stops at the clearance', (t) => {
|
||||
// 400 m of range at -40°, then "zoom in a lot" twice: unfloored, that left
|
||||
// the camera about 107 m up, then at the 80 m zoom floor.
|
||||
const h = harness(t, { height: 400 });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
tickUntil(h, () => pitchDeg(h.camera) >= -40);
|
||||
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true });
|
||||
// At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached.
|
||||
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false });
|
||||
// The camera must take that range, not just report it.
|
||||
h.tick();
|
||||
const floor = 150 / Math.sin(radians(40));
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - floor) < floor * 0.02,
|
||||
`range ${pivotRange(h)} m, wanted about ${floor} m`,
|
||||
);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
}
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
|
||||
});
|
||||
|
||||
test('a voice zoom during a pivot tilt says what it changed', async (t) => {
|
||||
const h = harness(t, { height: 400 });
|
||||
const runner = createGevActionRunner({
|
||||
viewer: h.viewer,
|
||||
styleManager: {},
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
});
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
h.tick();
|
||||
const zoom = { direction: 'in', amount: 'lot' };
|
||||
const first = await runner('adjust_camera_zoom', zoom);
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.tiltRangeAdjusted, true);
|
||||
assert.equal('orbitRadiusAdjusted' in first, false);
|
||||
// 400 m → 167 m → the 150 m clearance; a third zoom in moves nothing, and
|
||||
// says so.
|
||||
assert.equal((await runner('adjust_camera_zoom', zoom)).ok, true);
|
||||
const third = await runner('adjust_camera_zoom', zoom);
|
||||
assert.equal(third.ok, false);
|
||||
assert.match(third.error, /tilt is already at its closest range/);
|
||||
});
|
||||
|
||||
test('from globe range a tilt happens in place', (t) => {
|
||||
// A pivot 2,000 km out would swing the camera thousands of km.
|
||||
const h = harness(t, { height: 2_000_000 });
|
||||
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
|
||||
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3);
|
||||
});
|
||||
|
||||
for (const height of [100_000, 400_000])
|
||||
test(`from ${height / 1000} km a tilt to -5° ends with the camera at -5°`, (t) => {
|
||||
// The pivot pitch is shallower than the camera's by up to 4° at this
|
||||
// range; the camera reached only -5.9° and -8.6° when the swing stopped
|
||||
// at a pivot pitch of -5°.
|
||||
const h = harness(t, { height });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(result.pitchDeg, -5);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
|
||||
assert.equal(nudge.ok, false);
|
||||
assert.match(nudge.error, /upper tilt limit/);
|
||||
});
|
||||
|
||||
// From 550 km out the camera's -5° lies beyond the place's own horizon: the
|
||||
// swing ran into its clearance, let go of the pivot and tilted the rest in
|
||||
// place, which put the crosshair on the sky (28 to 63 km up), and a return
|
||||
// to top-down then landed 600 to 900 km from the place.
|
||||
for (const height of [600_000, 700_000, 900_000]) {
|
||||
test(`from ${height / 1000} km a tilt to -5° keeps the place centred`, (t) => {
|
||||
const h = harness(t, { height });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(result.ok, true);
|
||||
h.finish();
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
|
||||
// The reply names the angle the camera reached, not the one asked for.
|
||||
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
|
||||
);
|
||||
assert.match(result.note, /horizon/);
|
||||
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
|
||||
assert.equal(nudge.ok, false);
|
||||
assert.match(nudge.error, /upper tilt limit/);
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -89 }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
// Back over the place at the range it began with (-89° is ~1° off nadir).
|
||||
const range = Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
|
||||
assert.ok(
|
||||
Math.abs(range - height) < height * 0.01,
|
||||
`top-down ${range} m from the place`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`a continuous tilt up from ${height / 1000} km stops with the place centred`, (t) => {
|
||||
const h = harness(t, { height });
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
for (let i = 0; i < 1200 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
assert.ok(h.viewTarget(), `the crosshair is on the sky at frame ${i}`);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must stop at its limit');
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
});
|
||||
}
|
||||
|
||||
test('a pitchDeg tilt toward the horizon limit says it is already there', (t) => {
|
||||
const h = harness(t, { height: 900_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
h.finish();
|
||||
const again = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(again.ok, false);
|
||||
assert.match(again.error, /upper tilt limit/);
|
||||
});
|
||||
|
||||
test('a regional tilt-up nudge goes to the camera horizon limit', (t) => {
|
||||
// 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can
|
||||
// still tilt up 2°.
|
||||
const h = harness(t, { height: 20000, pitch: -7 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a voice tilt from a tracked view judges its limit from the camera pitch', (t) => {
|
||||
const h = harness(t, { height: 20000, pitch: -5.2 });
|
||||
h.viewer.trackedEntity = { id: 'tracked' };
|
||||
// Voice releases the follow camera before the motion starts.
|
||||
const release = (start) => {
|
||||
h.viewer.trackedEntity = undefined;
|
||||
return start();
|
||||
};
|
||||
const result = moveCamera({ motion: 'tilt', direction: 'up' }, release);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /upper tilt limit/);
|
||||
});
|
||||
|
||||
test('a zoom out during a pivoting tilt stops at the pivot range limit', (t) => {
|
||||
// 900 km top-down, "tilt to 10°", then "zoom out a lot" twice: capped at
|
||||
// the orbit's 5,000 km, the swing reached 2,386 km up at a real -44°.
|
||||
const h = harness(t, { height: 900_000 });
|
||||
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -10 }).ok, true);
|
||||
h.tick();
|
||||
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: true });
|
||||
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: false });
|
||||
let farthest = 0;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
farthest = Math.max(farthest, pivotRange(h));
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(farthest <= 1_000_001, `swung ${farthest} m from the pivot`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -10) < 0.5,
|
||||
`pitch ${pitchDeg(h.camera)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unchanged angle still stops a running motion through navigation', (t) => {
|
||||
const h = harness(t);
|
||||
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
|
||||
tickUntil(h, () => pitchDeg(h.camera) >= -45.3);
|
||||
let handoffs = 0;
|
||||
const navigate = (start) => {
|
||||
handoffs += 1;
|
||||
return start();
|
||||
};
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }, navigate);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.unchanged, true);
|
||||
assert.equal(handoffs, 1, 'the request must take navigation');
|
||||
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
|
||||
const held = pitchDeg(h.camera);
|
||||
for (let i = 0; i < 30; i += 1) h.tick();
|
||||
assert.equal(pitchDeg(h.camera), held);
|
||||
});
|
||||
|
||||
for (const direction of ['left', 'right'])
|
||||
test(`a tilt ${direction} is refused`, (t) => {
|
||||
harness(t, { pitch: -45 });
|
||||
const result = moveCamera({ motion: 'tilt', direction });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /tilt needs up or down/);
|
||||
const angled = moveCamera({ motion: 'tilt', direction, pitchDeg: -30 });
|
||||
assert.equal(angled.ok, false);
|
||||
assert.match(angled.error, /pitchDeg alone/);
|
||||
assert.equal(getActiveCameraMotion(), null);
|
||||
});
|
||||
|
||||
// The 1,000 km pivot cutoff includes its boundary: at exactly 1,000 km the
|
||||
// pivot range came back a hair over it, so the tilt ran in place and put the
|
||||
// crosshair on the sky.
|
||||
test('from exactly 1,000 km a tilt to -5° still pivots about the place', (t) => {
|
||||
const h = harness(t, { height: 1_000_000 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
|
||||
assert.equal(result.ok, true);
|
||||
h.finish();
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
|
||||
);
|
||||
assert.match(result.note, /horizon/);
|
||||
});
|
||||
|
||||
// A zoom mid-tilt moves the pivot's horizon limit: the zoom's reply names the
|
||||
// angle the tilt now ends at, and the camera ends there.
|
||||
test('a zoom out during a tilt to an angle reports the angle it now ends at', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
assert.equal(result.pitchDeg, -5);
|
||||
assert.equal(result.note, undefined);
|
||||
for (let i = 0; i < 10; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(2.5);
|
||||
assert.equal(zoom.kind, 'tilt');
|
||||
assert.equal(zoom.changed, true);
|
||||
h.finish();
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
assert.ok(zoom.pitchDeg < -5, `zoom reported ${zoom.pitchDeg}°`);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
test('a zoom in during a horizon-limited tilt reports the angle it now reaches', (t) => {
|
||||
const h = harness(t, { height: 900_000 });
|
||||
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
|
||||
for (let i = 0; i < 10; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(1 / 2.4);
|
||||
assert.equal(zoom.changed, true);
|
||||
h.finish();
|
||||
assert.equal(zoom.pitchDeg, -5);
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a voice zoom during a tilt to an angle passes on the new end angle', async (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
const runner = createGevActionRunner({
|
||||
viewer: h.viewer,
|
||||
styleManager: {},
|
||||
dataManager: { layers: new Map(), getAll: () => [] },
|
||||
});
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 10; i += 1) h.tick();
|
||||
const zoom = await runner('adjust_camera_zoom', {
|
||||
direction: 'out',
|
||||
amount: 'lot',
|
||||
});
|
||||
assert.equal(zoom.ok, true);
|
||||
assert.equal(zoom.tiltRangeAdjusted, true);
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
// Late in the tilt the camera is already past the angle the new range's
|
||||
// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8°): past it
|
||||
// the place is behind the Earth, so the camera steps down to that limit with
|
||||
// the zoom, then holds it with no further jump, and the zoom's reply names
|
||||
// the angle it ends at, not the one it stood at.
|
||||
test('a late zoom out during a tilt steps to the new limit and reports it', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 70; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(2.5);
|
||||
assert.equal(zoom.changed, true);
|
||||
h.tick();
|
||||
assert.equal(zoom.pitchDeg, Math.round(pitchDeg(h.camera)));
|
||||
let last = pitchDeg(h.camera);
|
||||
let maxStep = 0;
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
maxStep = Math.max(maxStep, Math.abs(pitchDeg(h.camera) - last));
|
||||
last = pitchDeg(h.camera);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(maxStep < 0.1, `pitch jumped ${maxStep}° in one frame`);
|
||||
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
|
||||
// Replies give whole degrees: the reported angle is the reached one rounded.
|
||||
assert.equal(
|
||||
zoom.pitchDeg,
|
||||
Math.round(pitchDeg(h.camera)),
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
// Easing from the camera's old pitch at the zoomed-out range put the camera
|
||||
// below the place's horizon: the first frame's crosshair hit the ground
|
||||
// 186 km from London, and London stayed hidden for ~11 frames.
|
||||
test('a late zoom out during a tilt keeps the place under the crosshair', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 70; i += 1) h.tick();
|
||||
const zoom = adjustOrbitRange(2.5);
|
||||
assert.equal(zoom.changed, true);
|
||||
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
|
||||
h.tick();
|
||||
const hit = h.viewTarget();
|
||||
assert.ok(hit, `frame ${i}: the crosshair is on the sky`);
|
||||
const drift = centreDrift(h);
|
||||
assert.ok(
|
||||
drift < 100,
|
||||
`frame ${i}: crosshair ${Math.round(drift)} m off the place`,
|
||||
);
|
||||
}
|
||||
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - 1_000_000) < 1,
|
||||
`range ${pivotRange(h)} m`,
|
||||
);
|
||||
assert.equal(
|
||||
zoom.pitchDeg,
|
||||
Math.round(pitchDeg(h.camera)),
|
||||
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
|
||||
);
|
||||
assert.match(zoom.note, /horizon/);
|
||||
});
|
||||
|
||||
// The zoom out left the pivot pitch level with the place, so the zoom in's
|
||||
// clearance floor came out infinite and it was refused at 1,000 km.
|
||||
test('a zoom in right after a late zoom out brings the tilt back in', (t) => {
|
||||
const h = harness(t, { height: 400_000 });
|
||||
moveCamera({ motion: 'tilt', pitchDeg: -5 });
|
||||
for (let i = 0; i < 70; i += 1) h.tick();
|
||||
const range = pivotRange(h);
|
||||
assert.equal(adjustOrbitRange(2.5).changed, true);
|
||||
const zoomIn = adjustOrbitRange(0.4);
|
||||
assert.equal(zoomIn.changed, true, 'the zoom in must not be refused');
|
||||
h.finish();
|
||||
assert.ok(
|
||||
Math.abs(pivotRange(h) - range) < 1,
|
||||
`range ${pivotRange(h)} m, wanted ${range} m`,
|
||||
);
|
||||
assert.ok(
|
||||
centreDrift(h) < 100,
|
||||
`crosshair ${centreDrift(h)} m off the place`,
|
||||
);
|
||||
});
|
||||
+748
-83
@@ -20,11 +20,19 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { GLOBE_VIEW } from './locations.js';
|
||||
import {
|
||||
globeFrameShare,
|
||||
isFreeGlobeCamera,
|
||||
lookAngleWithinFrame,
|
||||
lookStepLosesGlobe,
|
||||
} from './globeInView.js';
|
||||
import {
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
KEYHOLE_OUTER_RADIUS,
|
||||
} from './celestialRing.js';
|
||||
import { isScopeMaskEnabled } from './scopeMask.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
@@ -48,6 +56,33 @@ const ONCE = {
|
||||
};
|
||||
const PITCH_MIN = Cesium.Math.toRadians(-89);
|
||||
const PITCH_MAX = Cesium.Math.toRadians(-5);
|
||||
/** A bare "tilt the camera" / "oblique view" settles here. */
|
||||
const TILT_DEFAULT_PITCH_DEG = -45;
|
||||
/** °/s for a tilt to a requested angle; short ones keep the once duration. */
|
||||
const TILT_TO_DEG_S = 30;
|
||||
/**
|
||||
* A pivoting tilt keeps the range, so its height above the pivot is
|
||||
* range × sin|pitch|. It swings no lower than this; the rest tilts in place.
|
||||
*/
|
||||
const TILT_MIN_CLEARANCE_M = 150;
|
||||
/**
|
||||
* Beyond this range a pivot would swing the camera thousands of km about the
|
||||
* crosshair point, so the tilt happens in place instead.
|
||||
*/
|
||||
const TILT_PIVOT_MAX_RANGE_M = 1_000_000;
|
||||
/**
|
||||
* A pivot range rebuilt from the camera's position comes back a hair off the
|
||||
* range it was set at: a range this close past the cutoff still counts as on
|
||||
* it.
|
||||
*/
|
||||
const TILT_PIVOT_RANGE_SLACK_M = 1;
|
||||
/**
|
||||
* The shallowest a pivot swing goes when its clearance is not what stops it.
|
||||
* From several hundred km the camera's own horizon limit lies past the
|
||||
* place's horizon; tilting the rest in place there would lift the crosshair
|
||||
* onto the sky, so the swing stops with the place this far above it.
|
||||
*/
|
||||
const TILT_MIN_PIVOT_ELEVATION = Cesium.Math.toRadians(0.5);
|
||||
|
||||
/* ── Route dolly: cinematic tuning ──────────────────────────────────────────
|
||||
* Every knob an owner may want to retune lives in this block. The shaping is
|
||||
@@ -966,6 +1001,17 @@ let _trackingRemover = null;
|
||||
let _inputRemovers = [];
|
||||
/** Monotonic id for the motion currently running; 0 means "no motion". */
|
||||
let _motionSeq = 0;
|
||||
/** Canvas grabs, touches and wheel turns since load. */
|
||||
let _manualInputCount = 0;
|
||||
|
||||
/**
|
||||
* Lets a caller holding a motion back (behind a fly-to) see whether the user
|
||||
* took the camera meanwhile; Cesium does not cancel a flight on input.
|
||||
* @returns {number} Manual camera inputs so far.
|
||||
*/
|
||||
export function manualCameraInputCount() {
|
||||
return _manualInputCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a motion and stamp it with a fresh id. The id is what lets a caller
|
||||
@@ -1008,6 +1054,187 @@ export function interruptCameraMotionIfActive(
|
||||
return interruptCameraMotion(reason);
|
||||
}
|
||||
|
||||
// The frame share that in-place voice turns measure their limit against, with
|
||||
// the pose the last voice turn left the camera in. A turn that starts where
|
||||
// the last one left off keeps that baseline (raised if the planet now fills
|
||||
// more of the frame), so repeating a request cannot take a little more each
|
||||
// time. Once anything else has moved or turned the camera, a turn starts over
|
||||
// from what is on screen.
|
||||
let _lookBaseline = null;
|
||||
|
||||
// The pre-check's proof that a half-degree turn from this pose keeps the planet
|
||||
// in view: which way, from which position and direction. A motion that starts from that
|
||||
// pose turns at least that far, so a request reported as done really moves
|
||||
// even when a smaller first step lands in a dip of the sampled share.
|
||||
const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5);
|
||||
let _provenLook = null;
|
||||
|
||||
/** The part of the frame a viewer can see: the scope keyhole when it is on. */
|
||||
function visibleFrame() {
|
||||
return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 };
|
||||
}
|
||||
|
||||
/** The frame share an in-place turn from `cam`'s pose is limited against. */
|
||||
function lookBaselineShare(cam) {
|
||||
const share = globeFrameShare(cam, visibleFrame());
|
||||
const continues =
|
||||
_lookBaseline &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_lookBaseline.position,
|
||||
cam.positionWC,
|
||||
0,
|
||||
1,
|
||||
) &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_lookBaseline.direction,
|
||||
cam.directionWC,
|
||||
0,
|
||||
1e-9,
|
||||
);
|
||||
_lookBaseline = {
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
share: continues ? Math.max(_lookBaseline.share, share) : share,
|
||||
};
|
||||
return _lookBaseline.share;
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-place look step of motion `m`. From orbit a free globe camera turns
|
||||
* only as far as keeps the planet in frame, measured against the baseline the
|
||||
* turn started from, then the motion ends there.
|
||||
*/
|
||||
function lookWithinFrame(cam, m, look, angle) {
|
||||
let allowed = angle;
|
||||
if (isFreeGlobeCamera(_viewer)) {
|
||||
if (m.globeShareAtStart === undefined) {
|
||||
m.globeShareAtStart = lookBaselineShare(cam);
|
||||
m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
|
||||
m.turnedRad = 0;
|
||||
}
|
||||
_provenLook = null;
|
||||
allowed = lookAngleWithinFrame(cam, look, angle, {
|
||||
...visibleFrame(),
|
||||
startShare: m.globeShareAtStart,
|
||||
});
|
||||
const owed = Math.min(angle, m.provenTurnRad - m.turnedRad);
|
||||
if (allowed < owed) allowed = owed;
|
||||
m.turnedRad += allowed;
|
||||
}
|
||||
if (allowed > 0) cam[look](allowed);
|
||||
// The pose this turn leaves, for a turn that continues from it.
|
||||
if (_lookBaseline)
|
||||
Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction);
|
||||
if (allowed < angle) interruptCameraMotion('globe-out-of-frame');
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-place look a pitch change from `fromPitch` to `goalPitch` makes:
|
||||
* 'lookUp', 'lookDown', or null when it does not move. A tilt is judged by
|
||||
* the way its pitch moves, whatever form or direction word it came in.
|
||||
*/
|
||||
function tiltLook(fromPitch, goalPitch) {
|
||||
if (!Number.isFinite(fromPitch) || !Number.isFinite(goalPitch)) return null;
|
||||
if (goalPitch > fromPitch) return 'lookUp';
|
||||
return goalPitch < fromPitch ? 'lookDown' : null;
|
||||
}
|
||||
|
||||
/** Tilt `m` in place to `goalPitch`, as far as the frame limit allows. */
|
||||
function tiltInPlaceWithinFrame(cam, m, goalPitch) {
|
||||
const look = tiltLook(cam.pitch, goalPitch);
|
||||
if (look) lookWithinFrame(cam, m, look, Math.abs(goalPitch - cam.pitch));
|
||||
}
|
||||
|
||||
/**
|
||||
* The pitch an in-place tilt from `cam`'s pose toward `goalPitch` reaches
|
||||
* before the frame limit stops it, judged as its steps will be: `goalPitch`
|
||||
* itself when the planet stays in frame the whole way, or the camera is not a
|
||||
* free globe camera. The steps run on a copy of the camera, each looking by
|
||||
* the pitch still to go as the tilt's frames do, because a rolled camera's
|
||||
* look turns its pitch by less than the look angle.
|
||||
*/
|
||||
function tiltPitchWithinFrame(cam, goalPitch) {
|
||||
const look = tiltLook(cam.pitch, goalPitch);
|
||||
if (!look || !isFreeGlobeCamera(_viewer)) return goalPitch;
|
||||
const frame = { ...visibleFrame(), startShare: lookBaselineShare(cam) };
|
||||
// A proven half degree is turned whatever the sampled share says.
|
||||
let owed = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
|
||||
const probe = Cesium.Camera.clone(cam);
|
||||
for (let i = 0; i < 32; i += 1) {
|
||||
if (tiltLook(probe.pitch, goalPitch) !== look) return goalPitch;
|
||||
const angle = Math.abs(goalPitch - probe.pitch);
|
||||
if (angle < 1e-6) return goalPitch;
|
||||
const allowed = Math.max(
|
||||
lookAngleWithinFrame(probe, look, angle, frame),
|
||||
Math.min(angle, owed),
|
||||
);
|
||||
owed -= allowed;
|
||||
if (allowed > 0) probe[look](allowed);
|
||||
if (allowed < angle) return probe.pitch;
|
||||
}
|
||||
return probe.pitch;
|
||||
}
|
||||
|
||||
/** The reply's note for a tilt the frame limit stops short of its angle. */
|
||||
function tiltFrameNote(pitch) {
|
||||
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this height a shallower angle would turn the globe out of view. Zoom in to tilt further.`;
|
||||
}
|
||||
|
||||
/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */
|
||||
function lookProven(cam, look) {
|
||||
return (
|
||||
_provenLook?.look === look &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_provenLook.position,
|
||||
cam.positionWC,
|
||||
0,
|
||||
1,
|
||||
) &&
|
||||
Cesium.Cartesian3.equalsEpsilon(
|
||||
_provenLook.direction,
|
||||
cam.directionWC,
|
||||
0,
|
||||
1e-9,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft'
|
||||
* or 'lookRight') must pass from orbit: refused when even half a degree would
|
||||
* cross the frame limit, otherwise proven for that half degree from this pose.
|
||||
* With `released`, the turn runs through a navigation that first releases a
|
||||
* tracked or locked camera and keeps its world pose. The frame measure and the
|
||||
* turn are the same in any reference frame, so such a camera is judged as it
|
||||
* is, before the release, and a refusal leaves tracking as it was. Otherwise a
|
||||
* camera that is not a free globe camera is not checked. Returns the refusal,
|
||||
* or null.
|
||||
*/
|
||||
function checkLookFromOrbit(look, { released = false } = {}) {
|
||||
_provenLook = null;
|
||||
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null;
|
||||
const cam = _viewer.camera;
|
||||
if (!released && !isFreeGlobeCamera(_viewer)) return null;
|
||||
if (
|
||||
lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, {
|
||||
...visibleFrame(),
|
||||
startShare: lookBaselineShare(cam),
|
||||
})
|
||||
)
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error:
|
||||
'From this height that would turn the globe out of view — zoom in first, or turn the other way.',
|
||||
};
|
||||
_provenLook = {
|
||||
look,
|
||||
position: Cesium.Cartesian3.clone(cam.positionWC),
|
||||
direction: Cesium.Cartesian3.clone(cam.directionWC),
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearLookAt() {
|
||||
try {
|
||||
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
@@ -1052,7 +1279,9 @@ export function interruptCameraMotion(reason = 'interrupt') {
|
||||
if (
|
||||
wasActive &&
|
||||
!tracking &&
|
||||
(_active.kind === 'orbit' || _active.kind === 'route')
|
||||
(_active.kind === 'orbit' ||
|
||||
_active.kind === 'route' ||
|
||||
(_active.kind === 'tilt' && _active.hpr))
|
||||
)
|
||||
clearLookAt();
|
||||
// Instant, not eased: the user grabbed the camera, or the flight is over.
|
||||
@@ -1104,7 +1333,7 @@ function onTick() {
|
||||
let scale = 1;
|
||||
if (m.mode === 'once') {
|
||||
m.elapsed = (m.elapsed || 0) + dt;
|
||||
const t = Math.min(1, m.elapsed / ONCE.durationS);
|
||||
const t = Math.min(1, m.elapsed / (m.durationS || ONCE.durationS));
|
||||
scale = 1 - (1 - t) * (1 - t); // ease-out progress share
|
||||
if (t >= 1) {
|
||||
interruptCameraMotion('once-complete');
|
||||
@@ -1173,30 +1402,20 @@ function onTick() {
|
||||
else if (m.direction === 'right') cam.moveRight(step);
|
||||
else if (m.direction === 'up') cam.moveUp(step);
|
||||
else cam.moveDown(step);
|
||||
} else if (
|
||||
m.kind === 'tilt' ||
|
||||
m.kind === 'rotate' ||
|
||||
m.kind === 'rotate_globe'
|
||||
) {
|
||||
} else if (m.kind === 'tilt') {
|
||||
tickTilt(cam, m, scale, dt);
|
||||
} else if (m.kind === 'rotate' || m.kind === 'rotate_globe') {
|
||||
const degS = TILT_DEG_S[m.speed];
|
||||
const stepRad =
|
||||
m.mode === 'once'
|
||||
? Cesium.Math.toRadians(
|
||||
m.kind === 'tilt' ? ONCE.tiltDeg : ONCE.rotateDeg,
|
||||
) *
|
||||
(scale - (m.lastScale || 0))
|
||||
? Cesium.Math.toRadians(ONCE.rotateDeg) * (scale - (m.lastScale || 0))
|
||||
: Cesium.Math.toRadians(degS) * dt;
|
||||
m.lastScale = scale;
|
||||
if (m.kind === 'rotate_globe') {
|
||||
// Tracking can acquire the camera without going through navigation.
|
||||
// Yield without resetting its pose/frame: the world-space arc proof
|
||||
// only describes Cesium's rotation while the frame is identity.
|
||||
if (
|
||||
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
|
||||
_viewer.trackedEntity ||
|
||||
_isCockpitActive?.() ||
|
||||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
|
||||
) {
|
||||
if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) {
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
return;
|
||||
}
|
||||
@@ -1218,18 +1437,14 @@ function onTick() {
|
||||
return;
|
||||
}
|
||||
cam.rotate(m.rotationAxis, angle);
|
||||
} else if (m.kind === 'tilt') {
|
||||
const up = m.direction === 'up';
|
||||
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
|
||||
const next = cam.pitch + (up ? stepRad : -stepRad);
|
||||
if (next > PITCH_MAX || next < PITCH_MIN) {
|
||||
interruptCameraMotion('tilt-clamp');
|
||||
return;
|
||||
}
|
||||
if (up) cam.lookUp(stepRad);
|
||||
else cam.lookDown(stepRad);
|
||||
} else if (m.direction === 'left') cam.lookLeft(stepRad);
|
||||
else cam.lookRight(stepRad);
|
||||
} else {
|
||||
lookWithinFrame(
|
||||
cam,
|
||||
m,
|
||||
m.direction === 'left' ? 'lookLeft' : 'lookRight',
|
||||
stepRad,
|
||||
);
|
||||
}
|
||||
} else if (m.kind === 'route') {
|
||||
// The dolly's shaping lives in advanceRouteFlight(); the tick only
|
||||
// applies the pose and honours the same completion path as before.
|
||||
@@ -1252,6 +1467,186 @@ function onTick() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of tilt. With a ground pivot the camera swings about it (lookAt),
|
||||
* so the place under the crosshair stays centred; without one it looks up or
|
||||
* down in place. Every angle and limit is the camera's own pitch, which at
|
||||
* regional range is several degrees steeper than the pivot's (the Earth
|
||||
* curves away), so the swing solves for the pivot pitch that gives it. Pitch
|
||||
* is clamped to [-89°, -5°] only in the direction of travel: a top-down view
|
||||
* sits at -90°, below the limit, and must still be able to tilt up out of it.
|
||||
* A pivot swing toward the horizon lowers the camera, so it stops at
|
||||
* TILT_MIN_CLEARANCE_M and the rest tilts in place. Farther out it stops with
|
||||
* the camera TILT_MIN_PIVOT_ELEVATION above the place's horizon instead, and
|
||||
* keeps the place centred: tilting in place there would lift the crosshair
|
||||
* onto the sky.
|
||||
*/
|
||||
function tickTilt(cam, m, scale, dt) {
|
||||
const from = cam.pitch;
|
||||
const want =
|
||||
m.mode === 'once'
|
||||
? m.startPitch + (m.goalPitch - m.startPitch) * scale
|
||||
: from +
|
||||
Cesium.Math.toRadians(TILT_DEG_S[m.speed]) *
|
||||
dt *
|
||||
(m.direction === 'up' ? 1 : -1);
|
||||
const up = want > from;
|
||||
const limited = up
|
||||
? Math.min(want, Math.max(from, PITCH_MAX))
|
||||
: Math.max(want, Math.min(from, PITCH_MIN));
|
||||
let atLimit = false;
|
||||
if (m.hpr) {
|
||||
// Apply a range a zoom set since the last frame, even when the pitch
|
||||
// then has nowhere to go (a zoom in to the clearance ends the pivot).
|
||||
cam.lookAt(m.target, m.hpr);
|
||||
// Up toward the horizon the swing lowers the camera: hold the clearance,
|
||||
// and keep the place below the horizon.
|
||||
const cap = up
|
||||
? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range))
|
||||
: Infinity;
|
||||
for (let i = 0; i < 3 && Math.abs(limited - cam.pitch) > 1e-6; i += 1) {
|
||||
const pitch = Math.min(
|
||||
cap,
|
||||
Math.max(-Cesium.Math.PI_OVER_TWO, m.hpr.pitch + limited - cam.pitch),
|
||||
);
|
||||
if (pitch === m.hpr.pitch) break;
|
||||
m.hpr.pitch = pitch;
|
||||
cam.lookAt(m.target, m.hpr);
|
||||
}
|
||||
// A once motion that just completed released the lookAt frame before
|
||||
// this last step re-took it.
|
||||
if (_active !== m) clearLookAt();
|
||||
if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) {
|
||||
if (tiltClearanceBinds(m.hpr.range)) {
|
||||
// Down to its clearance: let go of the pivot and tilt the rest in
|
||||
// place, which keeps the height.
|
||||
if (_active === m) clearLookAt();
|
||||
m.hpr = null;
|
||||
m.target = null;
|
||||
} else {
|
||||
// The place is at its horizon limit: stop here, still centred.
|
||||
atLimit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!m.hpr) tiltInPlaceWithinFrame(cam, m, limited);
|
||||
if (m.mode !== 'once' && (limited !== want || atLimit))
|
||||
interruptCameraMotion('tilt-clamp');
|
||||
}
|
||||
|
||||
/** The refusal for a tilt up the camera has no room left for. */
|
||||
function tiltUpLimitRefusal() {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error:
|
||||
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Shallowest pivot pitch that keeps the clearance at this range. */
|
||||
function tiltPivotPitchMax(range) {
|
||||
return Math.min(
|
||||
-Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)),
|
||||
-TILT_MIN_PIVOT_ELEVATION,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the clearance, not the horizon, stops a swing at this range. Only
|
||||
* then does the rest of the tilt happen in place: that close in (under ~17
|
||||
* km) the camera's -5° limit stays well below its own horizon.
|
||||
*/
|
||||
function tiltClearanceBinds(range) {
|
||||
return TILT_MIN_CLEARANCE_M / range >= Math.sin(TILT_MIN_PIVOT_ELEVATION);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shallowest camera pitch a tilt starting from this frame can reach with
|
||||
* the place under the crosshair still in view: PITCH_MAX, or less where a
|
||||
* far pivot meets its horizon limit first.
|
||||
*/
|
||||
function tiltUpLimit({ target, pivot }) {
|
||||
if (!pivot || tiltClearanceBinds(pivot.range)) return PITCH_MAX;
|
||||
return Math.min(
|
||||
PITCH_MAX,
|
||||
pivotCameraPitch(
|
||||
target,
|
||||
pivot,
|
||||
Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The camera pitch lookAt gives for this pivot at the given pivot pitch. */
|
||||
function pivotCameraPitch(target, { heading, range }, pitch) {
|
||||
const offset = new Cesium.Cartesian3(
|
||||
-range * Math.cos(pitch) * Math.sin(heading),
|
||||
-range * Math.cos(pitch) * Math.cos(heading),
|
||||
-range * Math.sin(pitch),
|
||||
);
|
||||
const eye = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
offset,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const look = Cesium.Cartesian3.subtract(target, eye, new Cesium.Cartesian3());
|
||||
Cesium.Cartesian3.normalize(look, look);
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
const upAtEye = ellipsoid.geodeticSurfaceNormal(eye, new Cesium.Cartesian3());
|
||||
return Math.asin(
|
||||
Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a tilt starts. It swings about the ground under the crosshair so the
|
||||
* place stays centred. It tilts in place with no ground there, from globe
|
||||
* range, or while an entity is tracked (the follow camera owns that frame;
|
||||
* voice releases it before moving, so a voice tilt still pivots).
|
||||
*/
|
||||
function tiltStartFrame(cam) {
|
||||
const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer);
|
||||
let pivot = target ? tiltPivot(cam, target) : null;
|
||||
if (pivot && !tiltPivotInReach(pivot.range)) pivot = null;
|
||||
return { target, pivot, from: cam.pitch };
|
||||
}
|
||||
|
||||
/** Whether a tilt may pivot at this range: up to and including the cutoff. */
|
||||
function tiltPivotInReach(range) {
|
||||
return range <= TILT_PIVOT_MAX_RANGE_M + TILT_PIVOT_RANGE_SLACK_M;
|
||||
}
|
||||
|
||||
/** The reply's note for a tilt the place's horizon stops short of its angle. */
|
||||
function tiltHorizonNote(pitch) {
|
||||
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`;
|
||||
}
|
||||
|
||||
/** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */
|
||||
function tiltPivot(cam, target) {
|
||||
const local = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Matrix4.inverseTransformation(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
new Cesium.Matrix4(),
|
||||
),
|
||||
cam.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const range = Cesium.Cartesian3.magnitude(local);
|
||||
if (!(range > 1) || local.z <= 0) return null;
|
||||
// Near straight overhead the offset's heading is float noise (it flipped a
|
||||
// north-up London view to south); keep the camera's own.
|
||||
const heading =
|
||||
Math.hypot(local.x, local.y) > range * 0.01
|
||||
? Math.atan2(-local.x, -local.y)
|
||||
: cam.heading;
|
||||
return new Cesium.HeadingPitchRange(
|
||||
heading,
|
||||
-Math.asin(Math.min(1, local.z / range)),
|
||||
range,
|
||||
);
|
||||
}
|
||||
|
||||
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
|
||||
function globeRotationHasClearance(position) {
|
||||
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
|
||||
@@ -1413,6 +1808,7 @@ export function initCameraVerbs(
|
||||
_isCockpitActive =
|
||||
typeof isCockpitActive === 'function' ? isCockpitActive : null;
|
||||
_viewer = viewer;
|
||||
_lookBaseline = null;
|
||||
if (_tickRemover) _tickRemover();
|
||||
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
|
||||
if (_trackingRemover) _trackingRemover();
|
||||
@@ -1421,18 +1817,108 @@ export function initCameraVerbs(
|
||||
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
|
||||
if (entity && _active?.kind === 'rotate_globe')
|
||||
interruptCameraMotion('globe-camera-owner-changed');
|
||||
// A pivoting tilt's lookAt would fight the follow camera every frame.
|
||||
else if (entity && _active?.kind === 'tilt' && _active.hpr)
|
||||
interruptCameraMotion('tilt-camera-owner-changed');
|
||||
});
|
||||
for (const rm of _inputRemovers) rm();
|
||||
_inputRemovers = [];
|
||||
const canvas = viewer.scene.canvas;
|
||||
// ANY manual camera input reclaims control — the cancelFlight reflex.
|
||||
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
|
||||
const h = () => interruptCameraMotion('manual-input');
|
||||
const h = () => {
|
||||
_manualInputCount += 1;
|
||||
interruptCameraMotion('manual-input');
|
||||
};
|
||||
canvas.addEventListener(evt, h, { passive: true });
|
||||
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tilt's angle: the one asked for, the default oblique view when a bare
|
||||
* "tilt the camera" names neither angle nor direction, or null for a nudge.
|
||||
*/
|
||||
function requestedTiltPitch(args, direction) {
|
||||
// Only a real number or numeric text: Number('') and Number(null) are 0,
|
||||
// which would swing the camera to the horizon.
|
||||
const raw = args.pitchDeg;
|
||||
const requested =
|
||||
typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')
|
||||
? Number(raw)
|
||||
: NaN;
|
||||
// The camera never looks above the horizon, so a positive angle can only
|
||||
// mean the same angle below it ("tilt 45 degrees" sent as 45).
|
||||
if (Number.isFinite(requested))
|
||||
return Cesium.Math.clamp(
|
||||
Cesium.Math.toRadians(-Math.abs(requested)),
|
||||
PITCH_MIN,
|
||||
PITCH_MAX,
|
||||
);
|
||||
return direction === null
|
||||
? Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Argument errors move_camera refuses whatever the camera is doing, so a
|
||||
* caller holding a motion back behind a flight can refuse it before waiting.
|
||||
* @param {Object} args
|
||||
* @returns {Object|null} The refusal, or null when the arguments are usable.
|
||||
*/
|
||||
export function moveCameraArgsError(args = {}) {
|
||||
const motion = String(args.motion || '').toLowerCase();
|
||||
const direction = args.direction
|
||||
? String(args.direction).toLowerCase()
|
||||
: null;
|
||||
if (motion === 'stop') return null;
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
(motion !== 'tilt' || requestedTiltPitch(args, direction) === null) &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `${motion} needs a direction (${motion === 'tilt' ? 'up/down' : motion === 'rotate' ? 'left/right' : 'left/right/up/down'}).`,
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt' && (direction === 'left' || direction === 'right')) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'tilt needs up or down, or pitchDeg alone for an angle.',
|
||||
};
|
||||
}
|
||||
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate needs left or right.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion === 'rotate_globe' &&
|
||||
direction !== null &&
|
||||
!['left', 'right'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate_globe needs left or right.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* move_camera implementation. Returns the house result shape; rejections are
|
||||
* plain-English `ok:false` errors.
|
||||
@@ -1465,54 +1951,66 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
|
||||
}
|
||||
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion !== 'orbit' &&
|
||||
motion !== 'rotate_globe' &&
|
||||
!['left', 'right', 'up', 'down'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`,
|
||||
};
|
||||
}
|
||||
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate needs left or right.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
motion === 'rotate_globe' &&
|
||||
direction !== null &&
|
||||
!['left', 'right'].includes(direction)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error: 'rotate_globe needs left or right.',
|
||||
};
|
||||
}
|
||||
if (motion === 'tilt') {
|
||||
// Honest limits: at the clamp a tilt is a no-op — say so instead of
|
||||
// reporting success and letting the model gaslight itself (field test).
|
||||
const pitch = _viewer.camera.pitch;
|
||||
if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) {
|
||||
const argsError = moveCameraArgsError(args);
|
||||
if (argsError) return argsError;
|
||||
const tiltToPitch =
|
||||
motion === 'tilt' ? requestedTiltPitch(args, direction) : null;
|
||||
if (motion === 'tilt' && tiltToPitch !== null) {
|
||||
// Angles and limits are the camera's own pitch, whatever the swing pivots
|
||||
// about; releasing the follow camera does not change it.
|
||||
const from = _viewer.camera.pitch;
|
||||
// -89 or -90 means top-down, which a -90° view already is: the clamp
|
||||
// must not tilt it up 1°.
|
||||
const atTopDown = tiltToPitch <= PITCH_MIN && from <= PITCH_MIN;
|
||||
if (
|
||||
atTopDown ||
|
||||
Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)
|
||||
) {
|
||||
// Still a camera request: it stops a running motion and releases the
|
||||
// follow camera, as a tilt that moves would.
|
||||
const hold = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
return {
|
||||
ok: true,
|
||||
action: 'move_camera',
|
||||
motion,
|
||||
pitchDeg: Math.round(
|
||||
Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch),
|
||||
),
|
||||
unchanged: true,
|
||||
};
|
||||
};
|
||||
return typeof runNavigation === 'function' ? runNavigation(hold) : hold();
|
||||
}
|
||||
// pitchDeg is an absolute angle, not a step: "tilt down 10 degrees" sent
|
||||
// as {direction:'down', pitchDeg:10} would tilt up from -45°.
|
||||
const towards = tiltToPitch > from ? 'up' : 'down';
|
||||
if ((direction === 'up' || direction === 'down') && direction !== towards) {
|
||||
const target = Math.round(Cesium.Math.toDegrees(tiltToPitch));
|
||||
const now = Math.round(Cesium.Math.toDegrees(from));
|
||||
return {
|
||||
ok: false,
|
||||
action: 'move_camera',
|
||||
error:
|
||||
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
|
||||
error: `pitchDeg is an absolute angle, not a step: ${target}° from ${now}° tilts ${towards}, not ${direction}. Send direction alone for a nudge, or pitchDeg alone for an angle.`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
towards === 'up' &&
|
||||
from >=
|
||||
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
|
||||
)
|
||||
return tiltUpLimitRefusal();
|
||||
} else if (motion === 'tilt') {
|
||||
// Honest limits: at the clamp a tilt is a no-op — say so instead of
|
||||
// reporting success and letting the model gaslight itself (field test).
|
||||
const pitch = _viewer.camera.pitch;
|
||||
if (
|
||||
direction === 'up' &&
|
||||
pitch >=
|
||||
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
|
||||
) {
|
||||
return tiltUpLimitRefusal();
|
||||
}
|
||||
if (
|
||||
direction === 'down' &&
|
||||
pitch <= PITCH_MIN + Cesium.Math.toRadians(0.5)
|
||||
@@ -1524,6 +2022,34 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
};
|
||||
}
|
||||
}
|
||||
// A tilt is judged by the way its pitch will move: toward its angle, or a
|
||||
// nudge up or down; a rotate turns left only when told to, as it runs.
|
||||
const look =
|
||||
motion === 'tilt'
|
||||
? tiltLook(
|
||||
_viewer.camera.pitch,
|
||||
tiltToPitch ??
|
||||
_viewer.camera.pitch +
|
||||
Cesium.Math.toRadians(ONCE.tiltDeg) *
|
||||
(direction === 'up' ? 1 : -1),
|
||||
)
|
||||
: motion === 'rotate'
|
||||
? direction === 'left'
|
||||
? 'lookLeft'
|
||||
: 'lookRight'
|
||||
: null;
|
||||
// Honest limits from orbit too: a stopped turn rests within 0.05° of its
|
||||
// limit, so one that cannot go half a degree further is refused rather than
|
||||
// run and reported as done, and one that can is then turned at least that
|
||||
// far. Near the limit the sampled share wobbles as the planet's edge crosses
|
||||
// the grid, so a turn that would keep the planet can occasionally be refused
|
||||
// too.
|
||||
// A camera that is tracked or locked now is judged as the navigation that
|
||||
// runs the turn will release it, so a refusal keeps the tracking.
|
||||
const lookRefusal =
|
||||
look &&
|
||||
checkLookFromOrbit(look, { released: typeof runNavigation === 'function' });
|
||||
if (lookRefusal) return lookRefusal;
|
||||
if (
|
||||
(motion === 'rotate_globe' ||
|
||||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
|
||||
@@ -1607,14 +2133,70 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
|
||||
};
|
||||
}
|
||||
// The angle a tilt to an angle stops at short of it, and why.
|
||||
let stoppedAt = null;
|
||||
let stopNote = null;
|
||||
const start = () => {
|
||||
interruptCameraMotion('replaced');
|
||||
// Checked again only if the release left the camera somewhere other than
|
||||
// where the check above expected it.
|
||||
if (look && !lookProven(_viewer.camera, look)) {
|
||||
const refusal = checkLookFromOrbit(look);
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
const state = {
|
||||
kind: motion,
|
||||
direction: direction || 'right',
|
||||
speed,
|
||||
mode,
|
||||
};
|
||||
if (motion === 'tilt') {
|
||||
const frame = tiltStartFrame(_viewer.camera);
|
||||
const { target, pivot, from } = frame;
|
||||
if (pivot) {
|
||||
state.target = target;
|
||||
state.hpr = pivot;
|
||||
}
|
||||
state.startPitch = from;
|
||||
const upLimit = tiltUpLimit(frame);
|
||||
if (tiltToPitch !== null) {
|
||||
// From far out the place reaches its horizon before the camera
|
||||
// reaches the asked angle: stop there, and say so.
|
||||
let goal =
|
||||
tiltToPitch > from
|
||||
? Math.min(tiltToPitch, Math.max(from, upLimit))
|
||||
: tiltToPitch;
|
||||
if (goal !== tiltToPitch) stopNote = tiltHorizonNote(goal);
|
||||
// From orbit an in-place tilt stops where the planet would leave the
|
||||
// frame: aim it there, and say so.
|
||||
if (!pivot) {
|
||||
const framed = tiltPitchWithinFrame(_viewer.camera, goal);
|
||||
if (framed !== goal) {
|
||||
goal = framed;
|
||||
stopNote = tiltFrameNote(goal);
|
||||
}
|
||||
}
|
||||
state.mode = 'once';
|
||||
state.direction = goal > from ? 'up' : 'down';
|
||||
state.goalPitch = goal;
|
||||
// A zoom mid-tilt moves the horizon limit; it re-judges the goal
|
||||
// against the angle asked for.
|
||||
state.askedPitch = tiltToPitch;
|
||||
state.durationS = Math.max(
|
||||
ONCE.durationS,
|
||||
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
|
||||
);
|
||||
if (goal !== tiltToPitch) stoppedAt = goal;
|
||||
} else {
|
||||
// A nudge stops at the limit it travels toward.
|
||||
const step =
|
||||
Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1);
|
||||
state.goalPitch =
|
||||
step > 0
|
||||
? Math.min(from + step, Math.max(from, upLimit))
|
||||
: Math.max(from + step, Math.min(from, PITCH_MIN));
|
||||
}
|
||||
}
|
||||
if (motion === 'rotate_globe') {
|
||||
// Convert an inherited ground-target frame without changing the world
|
||||
// pose. Rotation must pivot about Earth, never the previous local target.
|
||||
@@ -1633,9 +2215,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
}
|
||||
if (motion === 'orbit') {
|
||||
if (_viewer.scene.tweens.length > 0) {
|
||||
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
|
||||
// and capture at the destination; a target existing right now is
|
||||
// meaningless (the globe is always under the crosshair).
|
||||
// A camera flight is in progress — ALWAYS arm and capture at the
|
||||
// destination; a target existing right now is meaningless (the globe
|
||||
// is always under the crosshair). The voice runner already holds an
|
||||
// orbit until its own fly-to lands; this covers any other
|
||||
// flight.
|
||||
setActiveMotion({
|
||||
kind: 'orbit',
|
||||
direction: direction || 'right',
|
||||
@@ -1699,7 +2283,11 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
motion,
|
||||
direction: state.direction,
|
||||
speed,
|
||||
mode,
|
||||
mode: state.mode,
|
||||
...(tiltToPitch !== null && {
|
||||
pitchDeg: Math.round(Cesium.Math.toDegrees(stoppedAt ?? tiltToPitch)),
|
||||
}),
|
||||
...(stopNote !== null && { note: stopNote }),
|
||||
};
|
||||
};
|
||||
const preserveCameraFlight =
|
||||
@@ -1714,16 +2302,93 @@ export function moveCamera(args = {}, runNavigation = null) {
|
||||
/**
|
||||
* Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom
|
||||
* fight the per-frame lookAt (field test: "zoom out did literally nothing").
|
||||
* A tilt swinging about its pivot holds a range the same way.
|
||||
* @param {number} factor >1 zooms out (radius grows), <1 zooms in.
|
||||
* @returns {boolean} true when an orbit consumed the zoom.
|
||||
* @returns {{kind: string, changed: boolean, pitchDeg?: number,
|
||||
* note?: string}|null} the motion that consumed the zoom, and whether its
|
||||
* range moved; null when none did. A tilt to an angle whose end the new
|
||||
* range moves also names the angle it now ends at (pitchDeg), with a note.
|
||||
*/
|
||||
export function adjustOrbitRange(factor) {
|
||||
if (!_active || _active.kind !== 'orbit' || !_active.hpr) return false;
|
||||
_active.hpr.range = Math.max(
|
||||
80,
|
||||
Math.min(5_000_000, _active.hpr.range * factor),
|
||||
if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr)
|
||||
return null;
|
||||
// A tilt's zoom in stops at the range that keeps its clearance. The limits
|
||||
// never move a view the wrong way: a zoom in leaves a closer view in place,
|
||||
// and a zoom out leaves a farther one.
|
||||
const { kind, hpr } = _active;
|
||||
const floor =
|
||||
kind === 'tilt'
|
||||
? Math.max(80, TILT_MIN_CLEARANCE_M / Math.abs(Math.sin(hpr.pitch)))
|
||||
: 80;
|
||||
// A tilt pivots only within TILT_PIVOT_MAX_RANGE_M; farther out the swing
|
||||
// would carry the camera thousands of km.
|
||||
const ceiling = kind === 'tilt' ? TILT_PIVOT_MAX_RANGE_M : 5_000_000;
|
||||
const range = hpr.range;
|
||||
hpr.range = Math.max(
|
||||
Math.min(floor, range),
|
||||
Math.min(Math.max(ceiling, range), range * factor),
|
||||
);
|
||||
return true;
|
||||
// Under half a metre counts as not moved, as for a plain zoom.
|
||||
const changed = Math.abs(hpr.range - range) >= 0.5;
|
||||
return { kind, changed, ...(changed && retargetTiltAfterZoom(_active)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A zoom changed the range a tilt to an angle holds, and with it the place's
|
||||
* horizon limit: aim the tilt at the angle asked for or the new limit,
|
||||
* whichever comes first, from where the camera is now. The pivot pitch is
|
||||
* re-solved so the new range keeps the camera's pitch, and a limit the camera
|
||||
* is already past is eased down to rather than jumped to. Returns the end
|
||||
* angle and a note for the reply when that end moved, otherwise nothing.
|
||||
*/
|
||||
function retargetTiltAfterZoom(m) {
|
||||
if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined)
|
||||
return {};
|
||||
let from = _viewer.camera.pitch;
|
||||
if (!(m.askedPitch > from)) return {};
|
||||
if (m.hpr) {
|
||||
// At the same pivot pitch a longer range looks down more steeply (the
|
||||
// Earth curves away): find the pivot pitch that holds this camera pitch.
|
||||
// It stays below the place's horizon limit (or where the pivot already
|
||||
// was): past it the place drops behind the Earth, so a camera the zoom
|
||||
// leaves past that limit steps down to it with the zoom.
|
||||
const cap = Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range));
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const error = from - pivotCameraPitch(m.target, m.hpr, m.hpr.pitch);
|
||||
if (Math.abs(error) < 1e-7) break;
|
||||
const pitch = Cesium.Math.clamp(
|
||||
m.hpr.pitch + error,
|
||||
-Cesium.Math.PI_OVER_TWO,
|
||||
cap,
|
||||
);
|
||||
if (pitch === m.hpr.pitch) break;
|
||||
m.hpr.pitch = pitch;
|
||||
}
|
||||
from = Math.min(from, pivotCameraPitch(m.target, m.hpr, m.hpr.pitch));
|
||||
}
|
||||
const limit =
|
||||
m.hpr && !tiltClearanceBinds(m.hpr.range)
|
||||
? Math.min(
|
||||
PITCH_MAX,
|
||||
pivotCameraPitch(m.target, m.hpr, tiltPivotPitchMax(m.hpr.range)),
|
||||
)
|
||||
: PITCH_MAX;
|
||||
const goal = Math.min(m.askedPitch, limit);
|
||||
if (Math.abs(goal - m.goalPitch) < 1e-6) return {};
|
||||
m.goalPitch = goal;
|
||||
m.startPitch = from;
|
||||
m.elapsed = 0;
|
||||
m.durationS = Math.max(
|
||||
ONCE.durationS,
|
||||
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
|
||||
);
|
||||
const limited = goal !== m.askedPitch;
|
||||
return {
|
||||
pitchDeg: Math.round(Cesium.Math.toDegrees(goal)),
|
||||
note: limited
|
||||
? tiltHorizonNote(goal)
|
||||
: `The tilt now reaches ${Math.round(Cesium.Math.toDegrees(goal))}°.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
|
||||
ui.indexOf("if (normalized === 'exit') {"),
|
||||
);
|
||||
assert.ok(branch, 'cockpit enter branch is missing');
|
||||
const gateIndex = branch.indexOf('isEntryAllowed?.()');
|
||||
const gateIndex = branch.indexOf('this._cockpitEntryRefusal()');
|
||||
const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
|
||||
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
|
||||
assert.ok(
|
||||
gateIndex >= 0,
|
||||
'entry consults the same gate the manual chip uses',
|
||||
'entry refuses on the gate it can check before tracking',
|
||||
);
|
||||
assert.ok(
|
||||
retargetIndex > gateIndex,
|
||||
@@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
|
||||
ui.indexOf("if (normalized === 'enter') {"),
|
||||
ui.indexOf("if (normalized === 'exit') {"),
|
||||
);
|
||||
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/);
|
||||
assert.match(branch, /Contacts must be active to enter Cockpit/);
|
||||
assert.match(branch, /Contacts is still starting up/);
|
||||
// Contacts is not required: only a Context change in flight or no aircraft
|
||||
// layer to ride shuts the gate before tracking, and the entry itself then
|
||||
// applies the manual chip's gate to the aircraft it rides.
|
||||
assert.match(
|
||||
branch,
|
||||
/const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/,
|
||||
);
|
||||
assert.doesNotMatch(branch, /Contacts must be active/);
|
||||
const refusal = ui.slice(
|
||||
ui.indexOf(' _cockpitEntryRefusal() {'),
|
||||
ui.indexOf(' getCockpitState() {'),
|
||||
);
|
||||
assert.match(refusal, /Context is still changing/);
|
||||
assert.match(refusal, /No aircraft layer is on/);
|
||||
assert.doesNotMatch(refusal, /[Cc]ontacts/);
|
||||
});
|
||||
|
||||
test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
@@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
);
|
||||
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
|
||||
assert.match(state, /'contacts-starting'/);
|
||||
assert.match(state, /'contacts-inactive'/);
|
||||
assert.match(state, /'subject-layer-off'/);
|
||||
assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required');
|
||||
assert.match(state, /'no-tracked-aircraft'/);
|
||||
});
|
||||
|
||||
|
||||
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
|
||||
],
|
||||
});
|
||||
|
||||
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
|
||||
const f = fixture(t);
|
||||
const crowded = {
|
||||
...snapshot(),
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
count: 6,
|
||||
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
|
||||
id: `n${n}`,
|
||||
label: `N${n}`,
|
||||
distanceM: n * 1000,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
updateCockpitSignals.call(f.owner, crowded, 0, {
|
||||
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
|
||||
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(f.owner.signalItems.length, 5);
|
||||
assert.equal(f.owner.signalItems[0].key, 'feed-status');
|
||||
assert.equal(f.owner.signalItems[0].tone, 'warning');
|
||||
assert.equal(f.owner.signalItems[0].target, null);
|
||||
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
|
||||
updateCockpitSignals.call(f.owner, crowded, 0);
|
||||
assert.equal(
|
||||
f.owner.signalItems.some((item) => item.key === 'feed-status'),
|
||||
false,
|
||||
'with the feed back, the row goes',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows render the signals the controller presents', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [
|
||||
|
||||
+23
-16
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
|
||||
return restored;
|
||||
}
|
||||
|
||||
/** The layers an aircraft Cockpit subject can come from. */
|
||||
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
|
||||
'flights',
|
||||
'military',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Cockpit entry belongs to the operational Contacts context bundle. A tracked
|
||||
* aircraft alone is not sufficient: both observed-flight feeds must still be
|
||||
* active so the cockpit's surrounding-contact picture is not presented as a
|
||||
* complete context view when one source is disabled.
|
||||
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
|
||||
* (Live Flights for a civilian, Military Flights for a military aircraft) must
|
||||
* be on. Contacts and the other feeds are not required. When a source the
|
||||
* surrounding picture uses is off, the Cockpit's Contact card and Live
|
||||
* Signals say the picture is partial instead of blocking entry, so a partial
|
||||
* picture is never presented as complete. A Context mode change in flight
|
||||
* still owns the camera, so entry waits it out.
|
||||
*
|
||||
* @param {object} input Current context and dependency visibility.
|
||||
* @param {string|null} input.contextMode Active Context mode.
|
||||
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
|
||||
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
|
||||
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled.
|
||||
* @param {object} input Current subject and Context state.
|
||||
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
|
||||
* ('flights' or 'military'), or null with none tracked.
|
||||
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
|
||||
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
|
||||
* @returns {boolean} Whether cockpit entry may be offered or activated.
|
||||
*/
|
||||
export function cockpitEntryAllowed({
|
||||
contextMode,
|
||||
subjectLayerId,
|
||||
subjectLayerEnabled,
|
||||
contextModeChanging,
|
||||
flightsEnabled,
|
||||
militaryEnabled,
|
||||
}) {
|
||||
return (
|
||||
contextMode === 'flights' &&
|
||||
!contextModeChanging &&
|
||||
Boolean(flightsEnabled) &&
|
||||
Boolean(militaryEnabled)
|
||||
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
|
||||
Boolean(subjectLayerEnabled) &&
|
||||
!contextModeChanging
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued
|
||||
}
|
||||
});
|
||||
|
||||
test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => {
|
||||
test("cockpit entry needs only the tracked aircraft's own layer", () => {
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: 'flights',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), true);
|
||||
}), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: 'military',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
}), true, 'a military aircraft needs Military Flights alone');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
subjectLayerId: 'flights',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: true,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), false, 'Cockpit remains unavailable until Contacts activation settles');
|
||||
}), false, 'Cockpit remains unavailable until a Context mode change settles');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: null,
|
||||
subjectLayerId: 'military',
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), false, 'ordinary layer selection is not an operational Contacts context');
|
||||
}), false, "the ridden aircraft's own layer is required");
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: null,
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: false,
|
||||
}), false, 'the military contact feed is required');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: false,
|
||||
militaryEnabled: true,
|
||||
}), false, 'the civilian contact feed is required');
|
||||
}), false, 'there is no aircraft to ride');
|
||||
});
|
||||
|
||||
test('context teardown guard restores coordination state after success and failure', async () => {
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -312,6 +312,23 @@ 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',
|
||||
@@ -733,6 +750,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'weather-satellite',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'wildlife-sightings',
|
||||
token: '9',
|
||||
disposition: 'enabled+options',
|
||||
optionOwner: 'wildlife-sightings',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'wind',
|
||||
token: 'k',
|
||||
|
||||
@@ -203,8 +203,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 35);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 36);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 36);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -243,7 +243,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '9');
|
||||
assert.equal(nextLayerStateToken(), '00');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
@@ -306,8 +306,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '9',
|
||||
next: '00',
|
||||
future: '00',
|
||||
next: '01',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -315,12 +315,13 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
() =>
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
future: '01',
|
||||
}),
|
||||
/next free token 9/,
|
||||
/next free token 00/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
delete beforeLastDigit['wildlife-sightings'];
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(beforeLastDigit, {
|
||||
...beforeLastDigit,
|
||||
@@ -2695,6 +2696,32 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
|
||||
);
|
||||
});
|
||||
|
||||
test('wildlife confirmed and every taxa preset round-trip through the full share codec', () => {
|
||||
assert.equal(
|
||||
LAYER_STATE_REGISTRY.find(({ id }) => id === 'wildlife-sightings').token,
|
||||
'9',
|
||||
);
|
||||
for (const taxa of [
|
||||
'all',
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'whales',
|
||||
'Insecta',
|
||||
'Plantae',
|
||||
]) {
|
||||
const state = normalizeLayerState({
|
||||
enabledLayerIds: ['wildlife-sightings'],
|
||||
options: { 'wildlife-sightings': { confirmed: true, taxa } },
|
||||
});
|
||||
const encoded = encode(state);
|
||||
assert.doesNotMatch(encoded, /undefined/);
|
||||
assert.deepEqual(
|
||||
decodeLayerStateParams(new URLSearchParams(encoded)),
|
||||
state,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('drone restrictions uses digit 8 without colliding with fire perimeters', () => {
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=8.2'));
|
||||
assert.deepEqual(decoded.enabledLayerIds, [
|
||||
|
||||
@@ -97,6 +97,10 @@ function withPublishedBase(
|
||||
}
|
||||
}
|
||||
|
||||
const preWildlifeRows = reservationRows.filter(
|
||||
([id]) => id !== 'wildlife-sightings',
|
||||
);
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
|
||||
mkdirSync(path.dirname(providerCodec), { recursive: true });
|
||||
@@ -104,7 +108,12 @@ function writeCodecFixture(cwd, rows, entries) {
|
||||
providerCodec,
|
||||
readFileSync(new URL('./idSwitches.js', import.meta.url)),
|
||||
);
|
||||
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
let source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
if (!rows.some(([id]) => id === 'wildlife-sightings'))
|
||||
source = source.replace(
|
||||
/ Object\.freeze\(\{\s*id: 'wildlife-sightings',[\s\S]*?\}\),\n/,
|
||||
'',
|
||||
);
|
||||
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
|
||||
assert.equal(source.split(registryEnd).length, 2);
|
||||
const additions = entries
|
||||
@@ -128,7 +137,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 36);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -140,6 +149,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
'sea-marks': '6',
|
||||
'ski-pistes': '7',
|
||||
'drone-airspace': '8',
|
||||
'wildlife-sightings': '9',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -165,9 +175,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '9']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '00']])
|
||||
.future,
|
||||
'9',
|
||||
'00',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -185,7 +195,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
|
||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||
withPublishedBase(
|
||||
{ rows: [...reservationRows, ['retired-layer', '9']] },
|
||||
{ rows: [...preWildlifeRows, ['retired-layer', '9']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
@@ -217,7 +227,7 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => {
|
||||
withPublishedBase({ rows: [...preWildlifeRows, ['pr-a', '9']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -238,7 +248,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '9']],
|
||||
[...preWildlifeRows, ['pr-b', '9']],
|
||||
[{ id: 'pr-b', token: '9' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
@@ -247,7 +257,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '9']],
|
||||
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '9']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '9' },
|
||||
@@ -259,7 +269,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '9']];
|
||||
const validBaselineRows = [...preWildlifeRows, ['pr-a', '9']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
|
||||
const helper = spawnSync(
|
||||
process.execPath,
|
||||
@@ -275,7 +285,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '00']],
|
||||
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '00']],
|
||||
[
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '00' },
|
||||
@@ -290,7 +300,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...preWildlifeRows,
|
||||
...digitRows,
|
||||
]);
|
||||
const beforeNine = { ...allDigits };
|
||||
@@ -349,7 +359,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
token,
|
||||
}));
|
||||
const fixtureRows = [
|
||||
...reservationRows,
|
||||
...preWildlifeRows,
|
||||
...priorDigits,
|
||||
...fixtureLayers.map(({ id, token }) => [id, token]),
|
||||
];
|
||||
@@ -358,13 +368,13 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
assert.equal(codec.validateLayerStateRegistry(), true);
|
||||
assert.equal(
|
||||
nextLayerStateToken(
|
||||
parseLayerStateTokenReservations([...reservationRows, ...priorDigits]),
|
||||
parseLayerStateTokenReservations([...preWildlifeRows, ...priorDigits]),
|
||||
),
|
||||
'00',
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
parseLayerStateTokenReservations(preWildlifeRows),
|
||||
codec.LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
),
|
||||
true,
|
||||
@@ -395,7 +405,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 36);
|
||||
assert.equal(Object.keys(published).length, 37);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -33,5 +33,6 @@
|
||||
["weather-lightning", "l"],
|
||||
["weather-radar", "v"],
|
||||
["weather-satellite", "o"],
|
||||
["wildlife-sightings", "9"],
|
||||
["wind", "k"]
|
||||
]
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export const SPRITE_LAYER_ORDER = Object.freeze([
|
||||
'cctv',
|
||||
'firms',
|
||||
'wildlife-sightings',
|
||||
'bikeshare',
|
||||
'transit',
|
||||
'transit-motion',
|
||||
|
||||
@@ -30,6 +30,28 @@ test('a superseded flow fetch is not an outage', () => {
|
||||
assert.equal(deriveTrafficFlowError(undefined), null);
|
||||
});
|
||||
|
||||
test('hosted account limits and service failures name their own reason', () => {
|
||||
const failure = (status, code) =>
|
||||
deriveTrafficFlowError(
|
||||
Object.assign(new Error(`TomTom flow unavailable (HTTP ${status})`), {
|
||||
status,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
failure(429, 'USAGE_LIMIT_REACHED'),
|
||||
'Monthly traffic allowance used',
|
||||
);
|
||||
assert.equal(failure(429, 'RATE_LIMITED'), 'Traffic rate limit reached');
|
||||
assert.equal(failure(429, 'budget'), 'TomTom daily budget reached');
|
||||
assert.equal(failure(503, 'no_key'), 'TomTom key unavailable');
|
||||
assert.equal(failure(503, null), 'TomTom key unavailable');
|
||||
assert.equal(
|
||||
failure(503, 'USAGE_UNAVAILABLE'),
|
||||
'Traffic service unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('flow failures map onto short, specific reasons', () => {
|
||||
const reason = (message) => deriveTrafficFlowError(new Error(message));
|
||||
assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable');
|
||||
|
||||
@@ -1020,6 +1020,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
'noir',
|
||||
]);
|
||||
visualStyle.enum = [...visualStyle.enum, 'snow'];
|
||||
// Tilt to an angle adds pitchDeg and rewords direction; restore
|
||||
// the legacy camera fields to keep the pin.
|
||||
const camera = legacyTools.find((tool) => tool.name === 'move_camera')
|
||||
.parameters.properties;
|
||||
assert.equal(camera.pitchDeg.type, 'number');
|
||||
delete camera.pitchDeg;
|
||||
camera.direction.description =
|
||||
'Required for pan/tilt/rotate; orbit/rotate_globe default right.';
|
||||
const block = JSON.stringify(legacyTools);
|
||||
// Re-derived for the voice layer manifest (generated layer enums, alias and
|
||||
// field hints), point-and-ask's pointer/referent arguments and the prompt
|
||||
@@ -1039,10 +1047,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
|
||||
// Pistes extend the generated layer enums and alias hint; voice geometry
|
||||
// adds the analyst area scopes and the osm-places layer.
|
||||
assert.equal(block.length, 31318, 'serialized tool schema length drifted');
|
||||
// control_cockpit states the own-layer entry rule (Contacts not required).
|
||||
assert.equal(block.length, 31372, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc',
|
||||
'7f593e78c232db972b04441f830b6a60f93916a6e5bc5a68eca7422cd1035508',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
// Cesium's free look (Shift+drag) turns the camera in place. Near the ground
|
||||
// that surveys a city; from orbit a few drags turn the whole planet out of
|
||||
// frame and leave only stars.
|
||||
export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000;
|
||||
|
||||
// A voice turn from orbit stops when about this share of the frame still shows
|
||||
// the planet's surface.
|
||||
export const MIN_GLOBE_FRAME_SHARE = 0.1;
|
||||
|
||||
/**
|
||||
* A free globe camera: the viewer is in 3D, follows no tracked entity, and its
|
||||
* camera is not locked to a reference frame.
|
||||
*/
|
||||
export function isFreeGlobeCamera(viewer) {
|
||||
return (
|
||||
viewer.scene.mode === Cesium.SceneMode.SCENE3D &&
|
||||
!viewer.trackedEntity &&
|
||||
Cesium.Matrix4.equals(viewer.camera.transform, Cesium.Matrix4.IDENTITY)
|
||||
);
|
||||
}
|
||||
|
||||
const GRID_COLUMNS = 32;
|
||||
const GRID_ROWS = 18;
|
||||
// A turn across a sliver of planet moves its edge through sample points, so
|
||||
// the share wobbles by a cell or two without the planet leaving.
|
||||
const GRID_NOISE_CELLS = 2;
|
||||
// Whatever the baseline, a voice turn never takes the planet below this share
|
||||
// of the frame; a view that already shows less may only keep what it has.
|
||||
const LOOK_ABSOLUTE_FLOOR_SHARE = 0.01;
|
||||
const sampleRay = new Cesium.Ray();
|
||||
const sampleOffset = new Cesium.Cartesian3();
|
||||
|
||||
/**
|
||||
* Cast a 32×18 grid of view rays, one through the centre of each cell, at the
|
||||
* WGS84 ellipsoid. With `keyholeRadius` (a radius around the frame centre, in
|
||||
* units of half the frame height) only the cells inside that circle count: the
|
||||
* scope mask hides the rest. Returns the surface hits and the cells counted, or
|
||||
* null for a frustum other than a perspective one.
|
||||
*/
|
||||
function sampleFrame(camera, keyholeRadius) {
|
||||
const frustum = camera.frustum;
|
||||
if (!(frustum instanceof Cesium.PerspectiveFrustum)) return null;
|
||||
const aspect = frustum.aspectRatio;
|
||||
const tanY = Math.tan(frustum.fovy / 2);
|
||||
const tanX = tanY * aspect;
|
||||
const keyholeSquared = keyholeRadius > 0 ? keyholeRadius * keyholeRadius : 0;
|
||||
Cesium.Cartesian3.clone(camera.positionWC, sampleRay.origin);
|
||||
let hits = 0;
|
||||
let cells = 0;
|
||||
for (let row = 0; row < GRID_ROWS; row += 1) {
|
||||
const rowY = (2 * (row + 0.5)) / GRID_ROWS - 1;
|
||||
for (let column = 0; column < GRID_COLUMNS; column += 1) {
|
||||
const columnX = (2 * (column + 0.5)) / GRID_COLUMNS - 1;
|
||||
if (
|
||||
keyholeSquared &&
|
||||
(columnX * aspect) ** 2 + rowY * rowY > keyholeSquared
|
||||
)
|
||||
continue;
|
||||
cells += 1;
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.rightWC,
|
||||
columnX * tanX,
|
||||
sampleOffset,
|
||||
);
|
||||
Cesium.Cartesian3.add(
|
||||
camera.directionWC,
|
||||
sampleOffset,
|
||||
sampleRay.direction,
|
||||
);
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
camera.upWC,
|
||||
rowY * tanY,
|
||||
sampleOffset,
|
||||
);
|
||||
Cesium.Cartesian3.add(
|
||||
sampleRay.direction,
|
||||
sampleOffset,
|
||||
sampleRay.direction,
|
||||
);
|
||||
Cesium.Cartesian3.normalize(sampleRay.direction, sampleRay.direction);
|
||||
if (
|
||||
Cesium.IntersectionTests.rayEllipsoid(sampleRay, Cesium.Ellipsoid.WGS84)
|
||||
)
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
return { hits, cells };
|
||||
}
|
||||
|
||||
/**
|
||||
* Share of the frame, from 0 to 1, that shows the planet's surface: the
|
||||
* fraction of a 32×18 grid of view rays, one through the centre of each cell,
|
||||
* that hit the WGS84 ellipsoid. With `keyholeRadius` (see `sampleFrame`) it is
|
||||
* the share of the visible circle instead. A frustum other than a perspective
|
||||
* one counts as fully covered.
|
||||
*/
|
||||
export function globeFrameShare(camera, { keyholeRadius = 0 } = {}) {
|
||||
const sample = sampleFrame(camera, keyholeRadius);
|
||||
if (!sample || sample.cells === 0) return 1;
|
||||
return sample.hits / sample.cells;
|
||||
}
|
||||
|
||||
/** True when none of the planet's surface is in `camera`'s visible frame. */
|
||||
export function globeOutOfFrame(camera, options) {
|
||||
return globeFrameShare(camera, options) === 0;
|
||||
}
|
||||
|
||||
const lookProbes = new WeakMap();
|
||||
const LOOK_EDGE_TOLERANCE = Cesium.Math.toRadians(0.05);
|
||||
|
||||
/**
|
||||
* How much of one in-place look step (`lookUp`, `lookDown`, `lookLeft` or
|
||||
* `lookRight` by a non-negative `angle` in radians) keeps the planet in frame
|
||||
* from orbit. Above the free-look height the step may not leave less than
|
||||
* `MIN_GLOBE_FRAME_SHARE` of the visible frame on the planet, or less than
|
||||
* `startShare` when that is lower, give or take two grid cells of sampling
|
||||
* noise. However the baseline was taken, it never leaves less than 1% of the
|
||||
* frame, and a view that already shows less may only keep or gain what it has.
|
||||
* `startShare` is the baseline the turn is measured against (the current share
|
||||
* by default): callers pass one value to every step of a motion, and to every
|
||||
* turn that continues from where the last one left off, so the limit cannot
|
||||
* slide down a step or a command at a time. `keyholeRadius` limits the measure
|
||||
* to the scope's visible circle, as in `globeFrameShare`.
|
||||
* With nothing on screen the limit is nothing either, so the step is left to
|
||||
* the move-end recovery. Returns `angle` when the whole step fits, otherwise a
|
||||
* smaller angle that fits, found by bisection to within 0.05°, so a stopped
|
||||
* motion rests at the limit. Steps are tried on a copy; `camera` is not moved.
|
||||
*/
|
||||
export function lookAngleWithinFrame(
|
||||
camera,
|
||||
look,
|
||||
angle,
|
||||
{ startShare, keyholeRadius = 0 } = {},
|
||||
) {
|
||||
if (
|
||||
!(angle > 0) ||
|
||||
!(camera.positionCartographic?.height > FREE_LOOK_MAX_HEIGHT_METERS)
|
||||
)
|
||||
return angle;
|
||||
const sample = sampleFrame(camera, keyholeRadius);
|
||||
if (!sample || sample.cells === 0) return angle;
|
||||
const share = sample.hits / sample.cells;
|
||||
const baseline = startShare ?? share;
|
||||
// Capped at what is on screen now, so a step that keeps or gains surface is
|
||||
// never refused, even when the frame has shrunk since the baseline was taken
|
||||
// (a resized window or a turned phone).
|
||||
const floor = Math.min(
|
||||
share,
|
||||
Math.max(
|
||||
Math.min(MIN_GLOBE_FRAME_SHARE, baseline) -
|
||||
GRID_NOISE_CELLS / sample.cells,
|
||||
Math.min(LOOK_ABSOLUTE_FLOOR_SHARE, baseline),
|
||||
),
|
||||
);
|
||||
const fits = (step) => {
|
||||
const probe = Cesium.Camera.clone(camera, lookProbes.get(camera));
|
||||
lookProbes.set(camera, probe);
|
||||
probe[look](step);
|
||||
// Shares are whole grid cells; the epsilon keeps rounding in `floor` from
|
||||
// costing a cell.
|
||||
return globeFrameShare(probe, { keyholeRadius }) >= floor - 1e-9;
|
||||
};
|
||||
if (fits(angle)) return angle;
|
||||
let inside = 0;
|
||||
let outside = angle;
|
||||
while (outside - inside > LOOK_EDGE_TOLERANCE) {
|
||||
const middle = (inside + outside) / 2;
|
||||
if (fits(middle)) inside = middle;
|
||||
else outside = middle;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** True when an in-place look step of `angle` would lose part of its turn. */
|
||||
export function lookStepLosesGlobe(camera, look, angle, options) {
|
||||
return lookAngleWithinFrame(camera, look, angle, options) < angle;
|
||||
}
|
||||
@@ -1749,6 +1749,9 @@
|
||||
"Hybrid": "هجين",
|
||||
"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 +2190,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}",
|
||||
@@ -3404,5 +3410,61 @@
|
||||
"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} (بحر ساليش) · التراخيص لكل سجل"
|
||||
}
|
||||
@@ -1667,6 +1667,9 @@
|
||||
"Hybrid": "Hybrid",
|
||||
"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 +2090,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}",
|
||||
@@ -3104,5 +3110,61 @@
|
||||
"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"
|
||||
}
|
||||
@@ -1920,6 +1920,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 +2274,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",
|
||||
@@ -3179,5 +3185,61 @@
|
||||
"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"
|
||||
}
|
||||
@@ -1105,6 +1105,9 @@
|
||||
"Hybrid": "Hybride",
|
||||
"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 +1663,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",
|
||||
@@ -3179,5 +3185,61 @@
|
||||
"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"
|
||||
}
|
||||
@@ -1105,6 +1105,9 @@
|
||||
"Hybrid": "Ibrido",
|
||||
"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 +1663,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",
|
||||
@@ -3179,5 +3185,61 @@
|
||||
"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"
|
||||
}
|
||||
@@ -1073,6 +1073,9 @@
|
||||
"Hybrid": "ハイブリッド",
|
||||
"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 +1615,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 +1690,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": {
|
||||
@@ -3029,5 +3035,61 @@
|
||||
"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}(セイリッシュ海) · ライセンスは記録ごと"
|
||||
}
|
||||
@@ -1103,6 +1103,9 @@
|
||||
"Hybrid": "Híbrido",
|
||||
"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 +1663,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 +1742,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": {
|
||||
@@ -3179,5 +3185,61 @@
|
||||
"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"
|
||||
}
|
||||
@@ -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": "Нет выбранных слоёв данных",
|
||||
@@ -1953,6 +1953,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 +2313,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",
|
||||
@@ -3254,5 +3260,61 @@
|
||||
"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} (море Сэлиш) · лицензия у каждой записи"
|
||||
}
|
||||
@@ -1120,6 +1120,9 @@
|
||||
"Hybrid": "Гібрид",
|
||||
"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 +1686,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 +1767,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": {
|
||||
@@ -3254,5 +3260,61 @@
|
||||
"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} (море Селіш) · ліцензія в кожного запису"
|
||||
}
|
||||
@@ -1049,6 +1049,9 @@
|
||||
"Hybrid": "混合",
|
||||
"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 +1591,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 +1666,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": {
|
||||
@@ -3029,5 +3035,61 @@
|
||||
"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}(萨利希海) · 各记录单独授权"
|
||||
}
|
||||
@@ -1044,6 +1044,9 @@
|
||||
"Hybrid": "混合",
|
||||
"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 +1581,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 +1656,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": {
|
||||
@@ -3029,5 +3035,61 @@
|
||||
"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}(薩利希海) · 各紀錄個別授權"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+1
@@ -23,6 +23,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
attachDataManager(dataManager) {
|
||||
layerState.dataManager = dataManager;
|
||||
parts.dependencies.watchAdoptions(dataManager);
|
||||
},
|
||||
|
||||
setParams(params = {}) {
|
||||
|
||||
@@ -79,6 +79,25 @@ export function createDependencies({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Give up a dependency the user adopts. A direct selection, or the Cockpit
|
||||
* riding one of its aircraft, promotes a layer Contacts turned on into the
|
||||
* user's own (`DataLayerManager.adoptLayerVisibility`). Releasing it when
|
||||
* Contacts goes off would tear down that track or Cockpit before the
|
||||
* Context restore could put the layer back.
|
||||
*/
|
||||
function relinquishAdoptedDependency(change) {
|
||||
if (change?.type === 'visibility' && change.adopted && change.enabled)
|
||||
layerState.ownedDependencies.delete(change.layerId);
|
||||
}
|
||||
|
||||
/** Follow one manager's adoptions; null stops following. */
|
||||
function watchAdoptions(dataManager) {
|
||||
layerState.unwatchAdoptions?.();
|
||||
layerState.unwatchAdoptions =
|
||||
dataManager?.subscribe?.(relinquishAdoptedDependency) || null;
|
||||
}
|
||||
|
||||
function activateOperationalContext() {
|
||||
const activationId = ++layerState.activationId;
|
||||
layerState.autoFocusRetryPending = false;
|
||||
@@ -113,6 +132,7 @@ export function createDependencies({
|
||||
refreshAfterDeferredDependency,
|
||||
enableDependencies,
|
||||
releaseOwnedDependencies,
|
||||
watchAdoptions,
|
||||
activateOperationalContext,
|
||||
};
|
||||
}
|
||||
@@ -340,6 +340,7 @@ export function createLifecycle({
|
||||
layerState.clearListener = null;
|
||||
layerState.subjectClearListener = null;
|
||||
layerState.viewer = null;
|
||||
parts.dependencies.watchAdoptions(null);
|
||||
layerState.dataManager = null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ export function createState({ services }) {
|
||||
// layers activate only after Contacts is explicitly selected.
|
||||
passive: true,
|
||||
ownedDependencies: new Set(),
|
||||
/** Stops following the manager's adoptions (see watchAdoptions). */
|
||||
unwatchAdoptions: null,
|
||||
subject: null,
|
||||
results: null,
|
||||
visual: null,
|
||||
|
||||
@@ -38,7 +38,15 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
const status = Number.isFinite(error.status)
|
||||
? error.status
|
||||
: Number(message.match(/HTTP (\d{3})/)?.[1]);
|
||||
const code = typeof error.code === 'string' ? error.code : null;
|
||||
// English in state; trafficFeedPresentation translates it for the panel.
|
||||
// A hosted deployment limits each account (code from the server); the
|
||||
// local proxy has one daily budget (`budget`) and answers `no_key`.
|
||||
if (code === 'USAGE_LIMIT_REACHED')
|
||||
return msg('Monthly traffic allowance used');
|
||||
if (code === 'RATE_LIMITED') return msg('Traffic rate limit reached');
|
||||
if (status === 503 && code && code !== 'no_key')
|
||||
return msg('Traffic service unavailable');
|
||||
if (status === 503) return msg('TomTom key unavailable');
|
||||
if (status === 429) return msg('TomTom daily budget reached');
|
||||
if (status === 502 || status === 504)
|
||||
|
||||
@@ -11,6 +11,26 @@ const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`;
|
||||
* (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom
|
||||
* fails), and when the proxy fetched it.
|
||||
*/
|
||||
/**
|
||||
* The machine-readable reason a failed tile carries: `code` (hosted usage,
|
||||
* e.g. USAGE_LIMIT_REACHED or RATE_LIMITED) or the local proxy's `error`
|
||||
* (`no_key`, `budget`). Only short identifiers are kept; prose is ignored.
|
||||
*/
|
||||
async function failureCode(response) {
|
||||
if (!/json/i.test(response.headers?.get?.('content-type') || '')) return null;
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text.length > 4096) return null;
|
||||
const body = JSON.parse(text);
|
||||
const code = body?.code ?? body?.error;
|
||||
return typeof code === 'string' && /^[A-Za-z_]{1,40}$/.test(code)
|
||||
? code
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function tileFreshness(response) {
|
||||
const status = response.headers?.get?.('x-tomtom-cache') || '';
|
||||
const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at'));
|
||||
@@ -60,7 +80,16 @@ export function createFlowTileSource({
|
||||
},
|
||||
fetchImpl: async (url, init) => {
|
||||
const response = await fetchImpl(url, init);
|
||||
if (response.ok && !init.signal.aborted) {
|
||||
if (!response.ok) {
|
||||
const code = await failureCode(response);
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw Object.assign(
|
||||
// Classified for display by deriveTrafficFlowError.
|
||||
new Error(`TomTom flow unavailable (HTTP ${response.status})`),
|
||||
{ status: response.status, code },
|
||||
);
|
||||
}
|
||||
if (!init.signal.aborted) {
|
||||
const path = String(url);
|
||||
const entry = {
|
||||
mark: tileFreshness(response),
|
||||
|
||||
@@ -1050,3 +1050,29 @@ test('cancelling a flow body releases its freshness before a new request', async
|
||||
assert.equal(getEventListeners(requestSignal, 'abort').length, 0);
|
||||
assert.equal(source.getFlowSessionStats().cacheEntries, 0);
|
||||
});
|
||||
|
||||
test('a failed flow tile carries the server reason for its label', async () => {
|
||||
for (const [status, body, code] of [
|
||||
[
|
||||
429,
|
||||
{ error: 'Monthly usage limit reached', code: 'USAGE_LIMIT_REACHED' },
|
||||
'USAGE_LIMIT_REACHED',
|
||||
],
|
||||
[
|
||||
429,
|
||||
{ error: 'Request rate limit reached', code: 'RATE_LIMITED' },
|
||||
'RATE_LIMITED',
|
||||
],
|
||||
[503, { error: 'no_key' }, 'no_key'],
|
||||
[503, { error: 'Usage enforcement is temporarily unavailable' }, null],
|
||||
]) {
|
||||
const source = createFlowTileSource({
|
||||
fetchImpl: async () => Response.json(body, { status }),
|
||||
});
|
||||
await assert.rejects(source.fetchFlowForBounds(bounds), (error) => {
|
||||
assert.equal(error.status, status);
|
||||
assert.equal(error.code, code);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// The regional feed includes sightings outside the layer's Salish Sea coverage.
|
||||
export const ACARTIA_GUARD_BOX = Object.freeze({
|
||||
south: 45.5,
|
||||
west: -126,
|
||||
north: 50.5,
|
||||
east: -121,
|
||||
});
|
||||
|
||||
// Frozen, conservative cetacean species/alias list for this regional feed.
|
||||
// Exact normalized names only: an unfamiliar name must never become a whale
|
||||
// merely because it contains "whale", "dolphin" or a known species name.
|
||||
export const ACARTIA_CETACEAN_NAMES = Object.freeze([
|
||||
'Orcinus orca',
|
||||
'Orca',
|
||||
'Killer whale',
|
||||
'Southern resident killer whale',
|
||||
'Northern resident killer whale',
|
||||
'Resident killer whale',
|
||||
"Bigg's killer whale",
|
||||
'Transient killer whale',
|
||||
'Offshore killer whale',
|
||||
'Megaptera novaeangliae',
|
||||
'Humpback whale',
|
||||
'Humpback',
|
||||
'Eschrichtius robustus',
|
||||
'Gray whale',
|
||||
'Grey whale',
|
||||
'Balaenoptera acutorostrata',
|
||||
'Common minke whale',
|
||||
'Minke whale',
|
||||
'Balaenoptera physalus',
|
||||
'Fin whale',
|
||||
'Finback whale',
|
||||
'Balaenoptera musculus',
|
||||
'Blue whale',
|
||||
'Balaenoptera borealis',
|
||||
'Sei whale',
|
||||
'Eubalaena japonica',
|
||||
'North Pacific right whale',
|
||||
'Physeter macrocephalus',
|
||||
'Sperm whale',
|
||||
'Kogia breviceps',
|
||||
'Pygmy sperm whale',
|
||||
'Kogia sima',
|
||||
'Dwarf sperm whale',
|
||||
'Berardius bairdii',
|
||||
"Baird's beaked whale",
|
||||
'Ziphius cavirostris',
|
||||
"Cuvier's beaked whale",
|
||||
'Mesoplodon carlhubbsi',
|
||||
"Hubbs' beaked whale",
|
||||
'Mesoplodon stejnegeri',
|
||||
"Stejneger's beaked whale",
|
||||
'Mesoplodon ginkgodens',
|
||||
'Ginkgo-toothed beaked whale',
|
||||
'Phocoena phocoena',
|
||||
'Harbor porpoise',
|
||||
'Harbour porpoise',
|
||||
'Phocoenoides dalli',
|
||||
"Dall's porpoise",
|
||||
'Lagenorhynchus obliquidens',
|
||||
'Pacific white-sided dolphin',
|
||||
'Lissodelphis borealis',
|
||||
'Northern right whale dolphin',
|
||||
'Grampus griseus',
|
||||
"Risso's dolphin",
|
||||
'Delphinus delphis',
|
||||
'Short-beaked common dolphin',
|
||||
'Common dolphin',
|
||||
'Delphinus capensis',
|
||||
'Long-beaked common dolphin',
|
||||
'Tursiops truncatus',
|
||||
'Common bottlenose dolphin',
|
||||
'Bottlenose dolphin',
|
||||
'Delphinapterus leucas',
|
||||
'Beluga whale',
|
||||
'Beluga',
|
||||
'Pseudorca crassidens',
|
||||
'False killer whale',
|
||||
'Globicephala macrorhynchus',
|
||||
'Short-finned pilot whale',
|
||||
]);
|
||||
|
||||
// These names clearly identify a marine mammal but do not establish a species
|
||||
// in the cetacean list. Retain them for ALL/MAMMALS, never for WHALES.
|
||||
const OTHER_MARINE_MAMMAL_NAMES = Object.freeze([
|
||||
'Phoca vitulina',
|
||||
'Harbor seal',
|
||||
'Harbour seal',
|
||||
'Mirounga angustirostris',
|
||||
'Northern elephant seal',
|
||||
'Zalophus californianus',
|
||||
'California sea lion',
|
||||
'Eumetopias jubatus',
|
||||
'Steller sea lion',
|
||||
"Steller's sea lion",
|
||||
'Callorhinus ursinus',
|
||||
'Northern fur seal',
|
||||
'Enhydra lutris',
|
||||
'Sea otter',
|
||||
'Seal',
|
||||
'Unidentified seal',
|
||||
'Sea lion',
|
||||
'Unidentified sea lion',
|
||||
'Pinniped',
|
||||
'Unidentified pinniped',
|
||||
'Marine mammal',
|
||||
'Unidentified marine mammal',
|
||||
'Unknown marine mammal',
|
||||
'Cetacean',
|
||||
'Unidentified cetacean',
|
||||
'Unknown cetacean',
|
||||
'Whale',
|
||||
'Unidentified whale',
|
||||
'Unknown whale',
|
||||
'Dolphin',
|
||||
'Unidentified dolphin',
|
||||
'Unknown dolphin',
|
||||
'Porpoise',
|
||||
'Unidentified porpoise',
|
||||
'Unknown porpoise',
|
||||
]);
|
||||
|
||||
function speciesKey(value) {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\u2018\u2019]/g, "'")
|
||||
.replace(/[\u2010-\u2015]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
: '';
|
||||
}
|
||||
|
||||
const CETACEANS = new Set(ACARTIA_CETACEAN_NAMES.map(speciesKey));
|
||||
const OTHER_MARINE_MAMMALS = new Set(OTHER_MARINE_MAMMAL_NAMES.map(speciesKey));
|
||||
|
||||
/** Classify exact normalized species names; ambiguous sightings stay noncetacean. */
|
||||
export function acartiaSpeciesGroup(value) {
|
||||
const key = speciesKey(value);
|
||||
if (CETACEANS.has(key)) return 'cetacean';
|
||||
return OTHER_MARINE_MAMMALS.has(key) ? 'marine-mammal' : null;
|
||||
}
|
||||
|
||||
/** Accept only positions inside the supported Salish Sea coverage. */
|
||||
export function inAcartiaCoverage(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
lat >= ACARTIA_GUARD_BOX.south &&
|
||||
lat <= ACARTIA_GUARD_BOX.north &&
|
||||
lon >= ACARTIA_GUARD_BOX.west &&
|
||||
lon <= ACARTIA_GUARD_BOX.east
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ACARTIA_CETACEAN_NAMES, acartiaSpeciesGroup } from './acartiaTaxa.js';
|
||||
|
||||
test('frozen cetacean names accept only normalized exact species or aliases', () => {
|
||||
assert.equal(Object.isFrozen(ACARTIA_CETACEAN_NAMES), true);
|
||||
assert.throws(() => ACARTIA_CETACEAN_NAMES.push('Whale shark'), TypeError);
|
||||
for (const name of ACARTIA_CETACEAN_NAMES) {
|
||||
assert.equal(acartiaSpeciesGroup(name), 'cetacean');
|
||||
assert.equal(acartiaSpeciesGroup(` ${name.toUpperCase()} `), 'cetacean');
|
||||
assert.equal(acartiaSpeciesGroup(`${name} or another animal`), null);
|
||||
}
|
||||
assert.equal(acartiaSpeciesGroup('Dall’s porpoise'), 'cetacean');
|
||||
assert.equal(acartiaSpeciesGroup('Pacific white–sided dolphin'), 'cetacean');
|
||||
assert.equal(acartiaSpeciesGroup(' HARBOR PORPOISE '), 'cetacean');
|
||||
});
|
||||
|
||||
test('clearly marine mammal names remain distinct from validated cetacean species', () => {
|
||||
for (const name of [
|
||||
'Harbor seal',
|
||||
'Phoca vitulina',
|
||||
'Sea otter',
|
||||
'Unidentified whale',
|
||||
]) {
|
||||
assert.equal(acartiaSpeciesGroup(name), 'marine-mammal');
|
||||
}
|
||||
for (const name of [
|
||||
'Unknown',
|
||||
'Whale shark',
|
||||
'Elephant',
|
||||
'',
|
||||
null,
|
||||
{ species: 'Orca' },
|
||||
]) {
|
||||
assert.equal(acartiaSpeciesGroup(name), null);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
import { drainWildlifeWork, WILDLIFE_WORK_UNIT } from './performance.js';
|
||||
import { wildlifeIconicGroup } from './model.js';
|
||||
|
||||
/** Incremental billboard reconciliation, independent of Cesium and the DOM.
|
||||
* A refresh changes only added/removed contacts and properties that differ.
|
||||
* Position sampling and appearance construction run only for changed rows. */
|
||||
export function createWildlifeBillboardModel({
|
||||
collection,
|
||||
pointById,
|
||||
positions,
|
||||
positionForRow,
|
||||
appearanceForGroup,
|
||||
}) {
|
||||
const previous = new Map();
|
||||
|
||||
function update(rows, options = {}) {
|
||||
return drainWildlifeWork(updateWork(rows, options));
|
||||
}
|
||||
|
||||
function* updateWork(rows, { onOperations = null } = {}) {
|
||||
const nextIds = new Set();
|
||||
const result = { created: 0, removed: 0, updated: 0, writes: 0 };
|
||||
let visits = 0;
|
||||
for (const row of rows) {
|
||||
if (visits++ % WILDLIFE_WORK_UNIT === 0) yield 'billboard-update';
|
||||
const id = row.stableId;
|
||||
if (nextIds.has(id)) continue;
|
||||
nextIds.add(id);
|
||||
const group = wildlifeIconicGroup(row.iconic);
|
||||
const old = previous.get(id);
|
||||
const point = pointById.get(id);
|
||||
if (
|
||||
old &&
|
||||
point &&
|
||||
old.lat === row.lat &&
|
||||
old.lon === row.lon &&
|
||||
old.group === group
|
||||
)
|
||||
continue;
|
||||
const appearance =
|
||||
old && old.group === group ? old.appearance : appearanceForGroup(group);
|
||||
const positionChanged =
|
||||
!old || old.lat !== row.lat || old.lon !== row.lon;
|
||||
const position = positionChanged
|
||||
? positionForRow(row)
|
||||
: positions.get(id);
|
||||
if (!point) {
|
||||
const options = { id: `wildlife:${id}`, position, ...appearance };
|
||||
pointById.set(id, collection.add(options));
|
||||
positions.set(id, position);
|
||||
result.created += 1;
|
||||
result.writes += Object.keys(options).length;
|
||||
} else {
|
||||
let writes = 0;
|
||||
if (positionChanged) {
|
||||
point.position = position;
|
||||
positions.set(id, position);
|
||||
writes += 1;
|
||||
}
|
||||
for (const key of Object.keys(appearance)) {
|
||||
// Cesium clones Color and NearFarScalar values on assignment. Our
|
||||
// last descriptor, rather than getter reference identity, records
|
||||
// whether the appearance has actually changed.
|
||||
if (old?.appearance?.[key] === appearance[key]) continue;
|
||||
point[key] = appearance[key];
|
||||
writes += 1;
|
||||
}
|
||||
result.writes += writes;
|
||||
if (writes) result.updated += 1;
|
||||
}
|
||||
previous.set(id, {
|
||||
lat: row.lat,
|
||||
lon: row.lon,
|
||||
group,
|
||||
appearance,
|
||||
});
|
||||
}
|
||||
for (const [id, point] of pointById) {
|
||||
if (visits++ % WILDLIFE_WORK_UNIT === 0) yield 'billboard-update';
|
||||
if (nextIds.has(id)) continue;
|
||||
collection.remove(point);
|
||||
pointById.delete(id);
|
||||
positions.delete(id);
|
||||
previous.delete(id);
|
||||
result.removed += 1;
|
||||
}
|
||||
onOperations?.({ billboardVisits: visits, billboardWrites: result.writes });
|
||||
return result;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
collection.removeAll();
|
||||
pointById.clear();
|
||||
positions.clear();
|
||||
previous.clear();
|
||||
}
|
||||
|
||||
// Font readiness replaces just the cached texture. Never resample anchors,
|
||||
// clear the collection or allocate replacement billboards for this swap.
|
||||
function refreshImages(imageForGroup) {
|
||||
let writes = 0;
|
||||
for (const [id, old] of previous) {
|
||||
const point = pointById.get(id);
|
||||
const image = imageForGroup(old.group);
|
||||
if (!point || old.appearance.image === image) continue;
|
||||
point.image = image;
|
||||
old.appearance = { ...old.appearance, image };
|
||||
writes += 1;
|
||||
}
|
||||
return writes;
|
||||
}
|
||||
|
||||
return { update, updateWork, clear, refreshImages };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Shared by browser viewport queries and the provider's cache-key validation.
|
||||
export const WILDLIFE_MAX_VIEW_DEGREES = 40;
|
||||
|
||||
const cleanDegree = (value) => Math.round(value * 1e8) / 1e8;
|
||||
const spanOf = (box) => Math.max(box.north - box.south, box.east - box.west);
|
||||
|
||||
/** Snap non-crossing boxes outward to one canonical grid. Validate the final
|
||||
* span too: a legal raw box may expand past the supported query size. */
|
||||
export function snapWildlifeBox(box) {
|
||||
if (
|
||||
!box ||
|
||||
!['south', 'west', 'north', 'east'].every((key) =>
|
||||
Number.isFinite(box[key]),
|
||||
) ||
|
||||
box.south < -90 ||
|
||||
box.north > 90 ||
|
||||
box.west < -180 ||
|
||||
box.east > 180 ||
|
||||
box.north <= box.south ||
|
||||
box.east <= box.west ||
|
||||
spanOf(box) > WILDLIFE_MAX_VIEW_DEGREES + 1e-8
|
||||
)
|
||||
return null;
|
||||
let grid = spanOf(box) > 5 ? 1 : 0.1;
|
||||
const snap = () => ({
|
||||
south: Math.max(
|
||||
-90,
|
||||
cleanDegree(Math.floor((box.south + 1e-10) / grid) * grid),
|
||||
),
|
||||
west: Math.max(
|
||||
-180,
|
||||
cleanDegree(Math.floor((box.west + 1e-10) / grid) * grid),
|
||||
),
|
||||
north: Math.min(
|
||||
90,
|
||||
cleanDegree(Math.ceil((box.north - 1e-10) / grid) * grid),
|
||||
),
|
||||
east: Math.min(
|
||||
180,
|
||||
cleanDegree(Math.ceil((box.east - 1e-10) / grid) * grid),
|
||||
),
|
||||
});
|
||||
let snapped = snap();
|
||||
// Crossing the five-degree boundary while snapping must pick the same grid
|
||||
// on the browser and proxy, and on every subsequent snap of this box.
|
||||
if (grid === 0.1 && spanOf(snapped) > 5 + 1e-8) {
|
||||
grid = 1;
|
||||
snapped = snap();
|
||||
}
|
||||
return spanOf(snapped) <= WILDLIFE_MAX_VIEW_DEGREES ? snapped : null;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { inaturalistProxy } from '../../../server/providers/inaturalist/observations.js';
|
||||
import { syntheticGrid } from '../../../server/providers/inaturalist/grid.fixture.mjs';
|
||||
import {
|
||||
createWildlifeSightingsSource,
|
||||
WILDLIFE_GRID_TTL_MS,
|
||||
} from './source.js';
|
||||
|
||||
// Exercise both real cache owners through their projected response contract.
|
||||
// Only the HTTP transport and upstream data are synthetic; no socket is needed.
|
||||
test('client and proxy share stale revalidation and publish its fresh result in the same view', async () => {
|
||||
let now = Date.parse('2026-11-04T18:05:00Z');
|
||||
let upstreamCalls = 0;
|
||||
let proxyCalls = 0;
|
||||
let finishRefresh;
|
||||
let handler;
|
||||
inaturalistProxy({
|
||||
now: () => now,
|
||||
cacheDir: null,
|
||||
queueOptions: { intervalMs: 0, windowMs: 0 },
|
||||
warn() {},
|
||||
fetchImpl() {
|
||||
upstreamCalls++;
|
||||
return upstreamCalls === 1
|
||||
? Response.json(syntheticGrid())
|
||||
: new Promise((resolve) => {
|
||||
finishRefresh = resolve;
|
||||
});
|
||||
},
|
||||
}).configureServer({
|
||||
middlewares: {
|
||||
use(_mount, middleware) {
|
||||
handler = middleware;
|
||||
},
|
||||
},
|
||||
});
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
async fetchImpl(url) {
|
||||
proxyCalls++;
|
||||
let status;
|
||||
let headers;
|
||||
let response;
|
||||
await handler(
|
||||
{
|
||||
method: 'GET',
|
||||
url: url.slice('/api/inaturalist'.length),
|
||||
headers: {},
|
||||
socket: { remoteAddress: 'synthetic' },
|
||||
},
|
||||
{
|
||||
writeHead(code, values) {
|
||||
status = code;
|
||||
headers = values;
|
||||
},
|
||||
end(body) {
|
||||
response = new Response(body, { status, headers });
|
||||
},
|
||||
},
|
||||
);
|
||||
return response;
|
||||
},
|
||||
});
|
||||
const options = { tiles: [{ z: 2, x: 1, y: 1 }], taxa: 'Aves' };
|
||||
const first = await source.getGridSnapshot(options);
|
||||
assert.equal(first.cells.length, 2);
|
||||
await source.getGridSnapshot(options);
|
||||
assert.equal(upstreamCalls, 1);
|
||||
assert.equal(proxyCalls, 1, 'client hits do not even call the proxy');
|
||||
now += WILDLIFE_GRID_TTL_MS;
|
||||
const publications = [];
|
||||
let settled = false;
|
||||
const refreshing = source
|
||||
.getGridSnapshot({
|
||||
...options,
|
||||
onTile: (value) => publications.push(value),
|
||||
})
|
||||
.then((value) => {
|
||||
settled = true;
|
||||
return value;
|
||||
});
|
||||
try {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false, 'revalidation is still waiting for upstream');
|
||||
assert.equal(
|
||||
publications[0]?.stale,
|
||||
true,
|
||||
'old cells already reached the renderer',
|
||||
);
|
||||
assert.deepEqual(publications[0].cells, first.cells);
|
||||
assert.equal(
|
||||
upstreamCalls,
|
||||
2,
|
||||
'normal stale GET and refresh=wait share one upstream attempt',
|
||||
);
|
||||
finishRefresh(Response.json(syntheticGrid()));
|
||||
const fresh = await refreshing;
|
||||
assert.equal(fresh.stale, false);
|
||||
assert.equal(fresh.fetchedAt, now);
|
||||
assert.equal(publications.at(-1).fetchedAt, now);
|
||||
assert.notEqual(publications.at(-1).stale, true);
|
||||
const before = proxyCalls;
|
||||
await source.getGridSnapshot(options);
|
||||
assert.equal(proxyCalls, before);
|
||||
assert.equal(upstreamCalls, 2);
|
||||
} finally {
|
||||
finishRefresh?.(Response.json(syntheticGrid()));
|
||||
await refreshing;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { photoCreator } from './photoText.js';
|
||||
import { getLocale, t, translateKnown } from '../../i18n/index.js';
|
||||
import { CCTV_THUMBNAIL_STYLE } from '../../overlays/worldOverlayTokens.js';
|
||||
import { truncateOverlayTitle } from '../../overlays/worldOverlayDraw.js';
|
||||
import { isOpenPhoto } from './records.js';
|
||||
import { compareWildlifeIds, wildlifeIconicGroup } from './model.js';
|
||||
import {
|
||||
ICONIC_TINTS,
|
||||
WILDLIFE_CARD_LIMIT,
|
||||
WILDLIFE_CARD_MIN_SEP_PX,
|
||||
WILDLIFE_CARD_REGION_COLUMNS,
|
||||
WILDLIFE_CARD_REGION_ROWS,
|
||||
WILDLIFE_CARD_ALTITUDE_SCALE,
|
||||
WILDLIFE_CARD_FADE_LOW_METERS,
|
||||
WILDLIFE_CARD_FADE_HIGH_METERS,
|
||||
WILDLIFE_SELECTED_LABEL_FADE_LOW_METERS,
|
||||
WILDLIFE_SELECTED_LABEL_FADE_HIGH_METERS,
|
||||
WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
WILDLIFE_CARD_MAX_DISTANCE_METERS,
|
||||
WILDLIFE_LABEL_LIMIT,
|
||||
} from './policy.js';
|
||||
|
||||
export const WILDLIFE_OVERLAY_SOURCE_ID = 'wildlife-sightings';
|
||||
export const WILDLIFE_LABEL_SOURCE_ID = 'wildlife-sightings-labels';
|
||||
export const WILDLIFE_SELECTED_SOURCE_ID = 'wildlife-sightings-selected';
|
||||
export const WILDLIFE_LABEL_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: WILDLIFE_LABEL_LIMIT,
|
||||
collisionCapacity: WILDLIFE_LABEL_LIMIT,
|
||||
moving: false,
|
||||
});
|
||||
export const WILDLIFE_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: WILDLIFE_CARD_LIMIT,
|
||||
collisionCapacity: WILDLIFE_CARD_LIMIT,
|
||||
moving: false,
|
||||
});
|
||||
export const WILDLIFE_SELECTED_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: 1,
|
||||
collisionCapacity: 1,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
/** Match cards and labels to the sighting sprite's taxon tint. */
|
||||
export function wildlifeAccent(iconic) {
|
||||
return ICONIC_TINTS[wildlifeIconicGroup(iconic)];
|
||||
}
|
||||
|
||||
/** Importance score for open, non-sensitive iNaturalist photos. Other rows
|
||||
* cannot consume a slot, even if an accidental photo field reaches a caller. */
|
||||
export function ambientScore(row, nowMs) {
|
||||
if (row?.obscured || !isOpenPhoto(row?.photo) || !photoCreator(row.photo))
|
||||
return -Infinity;
|
||||
const nonnegative = (n) => (Number.isFinite(n) ? Math.max(0, n) : 0);
|
||||
const time = Number.isFinite(row.observedAt)
|
||||
? row.observedAt
|
||||
: Date.parse(row.observedOn);
|
||||
const ageH =
|
||||
Number.isFinite(time) && Number.isFinite(nowMs)
|
||||
? Math.max(0, (nowMs - time) / 3_600_000)
|
||||
: Infinity;
|
||||
return (
|
||||
(row.quality === 'research' ? 2 : 0) +
|
||||
(row.threatened ? 1 : 0) +
|
||||
Math.log1p(nonnegative(row.favesCount)) +
|
||||
0.5 * Math.log1p(nonnegative(row.identificationAgreements)) +
|
||||
2 * 0.5 ** (ageH / 12)
|
||||
);
|
||||
}
|
||||
|
||||
/** Named rows without eligible photos rank below eligible photo rows. */
|
||||
export function wildlifeLabelScore(row, nowMs) {
|
||||
const score = ambientScore(row, nowMs);
|
||||
return Number.isFinite(score) ? score : -1;
|
||||
}
|
||||
|
||||
/** Visible incumbents own their slots until they leave the view/window. A new
|
||||
* random sample or a higher-scoring challenger can only fill a free slot. */
|
||||
function rankWildlifeAmbientRows(rows, currentIds, nowMs) {
|
||||
const incumbent = new Map(currentIds.map((id, index) => [id, index]));
|
||||
const scored = rows
|
||||
.map((row) => ({
|
||||
row,
|
||||
score: ambientScore(row, nowMs),
|
||||
current: incumbent.get(row.stableId),
|
||||
}))
|
||||
.filter((item) => Number.isFinite(item.score));
|
||||
scored.sort((a, b) => {
|
||||
if (a.current !== undefined || b.current !== undefined) {
|
||||
if (a.current === undefined) return 1;
|
||||
if (b.current === undefined) return -1;
|
||||
return a.current - b.current;
|
||||
}
|
||||
return (
|
||||
b.score - a.score || compareWildlifeIds(a.row.stableId, b.row.stableId)
|
||||
);
|
||||
});
|
||||
return scored.map((item) => item.row);
|
||||
}
|
||||
|
||||
/** Spread newly admitted cards over screen regions. Once shown, a card stays
|
||||
* even when a pan moves it into another incumbent's region; region quotas only
|
||||
* govern free slots, so a camera movement never replaces a visible photo. */
|
||||
export function selectWildlifeAmbientRows(
|
||||
rows,
|
||||
currentIds = [],
|
||||
nowMs,
|
||||
limit = WILDLIFE_CARD_LIMIT,
|
||||
{ screens = null, width = 0, height = 0 } = {},
|
||||
) {
|
||||
const incumbent = new Set(currentIds);
|
||||
const accepted = [];
|
||||
const regions = new Set();
|
||||
const regionFor = (screen) =>
|
||||
`${Math.min(WILDLIFE_CARD_REGION_COLUMNS - 1, Math.floor((screen.x / width) * WILDLIFE_CARD_REGION_COLUMNS))}:${Math.min(WILDLIFE_CARD_REGION_ROWS - 1, Math.floor((screen.y / height) * WILDLIFE_CARD_REGION_ROWS))}`;
|
||||
for (const row of rankWildlifeAmbientRows(rows, currentIds, nowMs)) {
|
||||
if (accepted.length >= Math.max(0, limit)) break;
|
||||
const screen = screens?.get(row.stableId);
|
||||
const region = screen && width > 0 && height > 0 ? regionFor(screen) : null;
|
||||
if (!incumbent.has(row.stableId)) {
|
||||
if (region !== null && regions.has(region)) continue;
|
||||
if (
|
||||
screen &&
|
||||
accepted.some((other) => {
|
||||
const prior = screens.get(other.stableId);
|
||||
return (
|
||||
prior &&
|
||||
Math.hypot(prior.x - screen.x, prior.y - screen.y) <
|
||||
WILDLIFE_CARD_MIN_SEP_PX
|
||||
);
|
||||
})
|
||||
)
|
||||
continue;
|
||||
}
|
||||
accepted.push(row);
|
||||
if (region !== null) regions.add(region);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function nameFor(row) {
|
||||
return translateKnown(row.commonName || row.scientificName) || t('SIGHTING');
|
||||
}
|
||||
|
||||
/** Return a trimmed common name, or null for unnamed observations. */
|
||||
export function wildlifeCommonName(row) {
|
||||
const name = row.commonName;
|
||||
return typeof name === 'string' && name.trim() ? name.trim() : null;
|
||||
}
|
||||
|
||||
/** Full translated display name, shared by titles and screen-space deduping. */
|
||||
export function wildlifeDisplayName(row) {
|
||||
const commonName = wildlifeCommonName(row);
|
||||
const name = commonName ? translateKnown(commonName).trim() : '';
|
||||
return name.replace(/\s+/g, ' ').trim().toLocaleUpperCase(getLocale());
|
||||
}
|
||||
|
||||
function compactName(row) {
|
||||
return truncateOverlayTitle(
|
||||
wildlifeDisplayName(row),
|
||||
WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
);
|
||||
}
|
||||
|
||||
/** One-line common names for identified observations. The caller adds the
|
||||
* Cartesian anchor after ranking and front-side/screen-space filtering. */
|
||||
export function buildWildlifeLabelEntry(
|
||||
row,
|
||||
{ select, hasRow, nowMs = Date.now() } = {},
|
||||
) {
|
||||
if (!wildlifeCommonName(row)) return null;
|
||||
return {
|
||||
id: `wildlife-sightings-label:${row.stableId}`,
|
||||
variant: 'label',
|
||||
title: compactName(row),
|
||||
accent: wildlifeAccent(row.iconic),
|
||||
priority: wildlifeLabelScore(row, nowMs),
|
||||
collisionGroup: 'ambient-label',
|
||||
// The host checks actual card placements, including sticky/fading cards,
|
||||
// before spending the remaining space on labels in score order.
|
||||
yieldToCollisionGroup: 'ambient-card',
|
||||
stateless: true,
|
||||
paintLane: 'ambient-label',
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
horizonCull: true,
|
||||
edgeFade: 'keyhole',
|
||||
interactive: false,
|
||||
accessibilityLabel: nameFor(row),
|
||||
...selectionAction(row, { select, hasRow }),
|
||||
};
|
||||
}
|
||||
|
||||
function selectionAction(row, { select, hasRow } = {}) {
|
||||
if (typeof select !== 'function') return {};
|
||||
return {
|
||||
interactive: true,
|
||||
accessibilityLabel: t('Show {name} sighting', { name: nameFor(row) }),
|
||||
activate() {
|
||||
if (typeof hasRow === 'function' && !hasRow(row.stableId)) return false;
|
||||
return select(row.stableId) !== false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an attributed ambient photo; the runtime supplies the decoded image. */
|
||||
export function buildWildlifeThumbnailEntry(
|
||||
row,
|
||||
nowMs,
|
||||
{ image = null, select, hasRow, selected = false } = {},
|
||||
) {
|
||||
const score = ambientScore(row, nowMs);
|
||||
if (!Number.isFinite(score)) return null;
|
||||
return {
|
||||
id: `sighting:${row.stableId}`,
|
||||
variant: 'thumbnail',
|
||||
title: nameFor(row),
|
||||
image,
|
||||
requireImage: true,
|
||||
accent: wildlifeAccent(row.iconic),
|
||||
interactive: false,
|
||||
accessibilityLabel: t('Show {name} sighting', { name: nameFor(row) }),
|
||||
collisionGroup: 'ambient-card',
|
||||
horizonCull: true,
|
||||
edgeFade: 'keyhole',
|
||||
verticalOnly: true,
|
||||
gapPx: 15,
|
||||
thumbnailPadX: CCTV_THUMBNAIL_STYLE.padding,
|
||||
thumbnailPadTop: CCTV_THUMBNAIL_STYLE.padding,
|
||||
thumbnailPadBottom: CCTV_THUMBNAIL_STYLE.padding,
|
||||
thumbnailTitleGap: 2,
|
||||
thumbnailTitleHeight: CCTV_THUMBNAIL_STYLE.titleHeight,
|
||||
thumbnailTitleChars: CCTV_THUMBNAIL_STYLE.titleChars,
|
||||
thumbnailTitleWordBoundary: true,
|
||||
thumbnailBackground: CCTV_THUMBNAIL_STYLE.background,
|
||||
thumbnailTitleColor: CCTV_THUMBNAIL_STYLE.titleColor,
|
||||
thumbnailTitleFont: CCTV_THUMBNAIL_STYLE.titleFont,
|
||||
thumbnailLeaderColor: CCTV_THUMBNAIL_STYLE.leader,
|
||||
thumbnailRuleColor: CCTV_THUMBNAIL_STYLE.rule,
|
||||
thumbnailRuleHeight: CCTV_THUMBNAIL_STYLE.ruleHeight,
|
||||
thumbnailRadius: CCTV_THUMBNAIL_STYLE.radius,
|
||||
...selectionAction(row, { select, hasRow }),
|
||||
// i18n-ignore: the creator is verbatim feed attribution with a copyright mark
|
||||
details: [`© ${photoCreator(row.photo)}`],
|
||||
thumbnailFixedWidth: true,
|
||||
thumbnailFit: 'contain',
|
||||
thumbnailWidth: 96,
|
||||
thumbnailHeight: 72,
|
||||
priority: selected ? Number.MAX_SAFE_INTEGER : score,
|
||||
minAnchorSeparationPx: WILDLIFE_CARD_MIN_SEP_PX,
|
||||
maxDistance: WILDLIFE_CARD_MAX_DISTANCE_METERS,
|
||||
altitudeScale: WILDLIFE_CARD_ALTITUDE_SCALE,
|
||||
altitudeFadeStart: WILDLIFE_CARD_FADE_LOW_METERS,
|
||||
altitudeFadeEnd: WILDLIFE_CARD_FADE_HIGH_METERS,
|
||||
...(selected ? { selected: true, paintLane: 'selected' } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A bounded selected name uses the host's shared selected card treatment. */
|
||||
export function buildWildlifeSelectedLabel(row) {
|
||||
if (!wildlifeCommonName(row)) return null;
|
||||
return {
|
||||
id: `wildlife-sightings-label:${row.stableId}`,
|
||||
variant: 'label',
|
||||
selected: true,
|
||||
protected: true,
|
||||
yieldToCollisionGroup: 'ambient-card',
|
||||
paintLane: 'selected',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title: compactName(row),
|
||||
accent: wildlifeAccent(row.iconic),
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
horizonCull: true,
|
||||
edgeFade: 'keyhole',
|
||||
interactive: false,
|
||||
accessibilityLabel: nameFor(row),
|
||||
altitudeFadeStart: WILDLIFE_SELECTED_LABEL_FADE_LOW_METERS,
|
||||
altitudeFadeEnd: WILDLIFE_SELECTED_LABEL_FADE_HIGH_METERS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ambientScore,
|
||||
selectWildlifeAmbientRows,
|
||||
buildWildlifeThumbnailEntry,
|
||||
buildWildlifeLabelEntry,
|
||||
buildWildlifeSelectedLabel,
|
||||
wildlifeAccent,
|
||||
WILDLIFE_OVERLAY_SOURCE_OPTIONS,
|
||||
WILDLIFE_LABEL_SOURCE_ID,
|
||||
WILDLIFE_LABEL_SOURCE_OPTIONS,
|
||||
WILDLIFE_SELECTED_SOURCE_OPTIONS,
|
||||
} from './cards.js';
|
||||
import { CCTV_THUMBNAIL_STYLE } from '../../overlays/worldOverlayTokens.js';
|
||||
import {
|
||||
WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
WILDLIFE_CARD_SHOW_MAX_METERS,
|
||||
WILDLIFE_CARD_HIDE_MAX_METERS,
|
||||
WILDLIFE_LABEL_LIMIT,
|
||||
} from './policy.js';
|
||||
import { formatAge } from './photoText.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import {
|
||||
altitudeFade,
|
||||
altitudeScale,
|
||||
measureOverlayEntry,
|
||||
} from '../../overlays/worldOverlayDraw.js';
|
||||
|
||||
const now = Date.UTC(2026, 10, 4, 18, 40);
|
||||
const hour = 3_600_000;
|
||||
const photo = {
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpeg',
|
||||
medium:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/123/medium.jpeg',
|
||||
licence: 'cc-by-nc',
|
||||
attribution: '(c) Synthetic Photographer, some rights reserved',
|
||||
};
|
||||
const row = (overrides = {}) => ({
|
||||
stableId: 'inat:123',
|
||||
origin: 'inaturalist',
|
||||
lat: 47.6,
|
||||
lon: -122.3,
|
||||
observedAt: now - 2 * hour,
|
||||
observedOn: '2026-11-04',
|
||||
obscured: false,
|
||||
cell: null,
|
||||
quality: 'research',
|
||||
accuracyM: 12,
|
||||
commonName: "Anna's Hummingbird",
|
||||
scientificName: 'Calypte anna',
|
||||
iconic: 'Aves',
|
||||
threatened: false,
|
||||
conservation: null,
|
||||
count: null,
|
||||
provider: null,
|
||||
obsLicence: 'cc-by-nc',
|
||||
link: 'https://www.inaturalist.org/observations/123',
|
||||
photo,
|
||||
favesCount: 0,
|
||||
identificationAgreements: 0,
|
||||
...overrides,
|
||||
});
|
||||
const acartia = (overrides = {}) =>
|
||||
row({
|
||||
stableId: 'acartia:abc',
|
||||
origin: 'acartia',
|
||||
iconic: 'Mammalia',
|
||||
commonName: 'Orca',
|
||||
scientificName: null,
|
||||
accuracyM: null,
|
||||
count: 5,
|
||||
provider: { name: 'Synthetic Network', entity: 'Whale Alert' },
|
||||
obsLicence: null,
|
||||
photo: null,
|
||||
link: 'https://acartia.io',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('ambient photo score prioritizes grade, threat, appreciation and recency exactly', () => {
|
||||
const base = row({ quality: 'needs_id' });
|
||||
assert.ok(
|
||||
Math.abs(ambientScore(row(), now) - ambientScore(base, now) - 2) < 1e-12,
|
||||
);
|
||||
assert.equal(
|
||||
ambientScore(row({ threatened: true, observedAt: now }), now) -
|
||||
ambientScore(row({ observedAt: now }), now),
|
||||
1,
|
||||
);
|
||||
assert.ok(
|
||||
ambientScore(row({ favesCount: 8 }), now) >
|
||||
ambientScore(row({ favesCount: 1 }), now),
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
ambientScore(row({ identificationAgreements: 3 }), now) -
|
||||
ambientScore(row(), now) -
|
||||
0.5 * Math.log1p(3),
|
||||
) < 1e-12,
|
||||
);
|
||||
assert.ok(
|
||||
ambientScore(row({ quality: 'needs_id', observedAt: now - hour }), now) >
|
||||
ambientScore(
|
||||
row({ quality: 'needs_id', observedAt: now - 48 * hour }),
|
||||
now,
|
||||
),
|
||||
);
|
||||
for (const input of [
|
||||
row({ obscured: true }),
|
||||
row({ photo: null }),
|
||||
row({ photo: { ...photo, licence: 'cc-by-nd' } }),
|
||||
])
|
||||
assert.equal(ambientScore(input, now), -Infinity);
|
||||
});
|
||||
|
||||
test('ambient ranking ties by stable id and never replaces a visible incumbent', () => {
|
||||
const a = row({ stableId: 'inat:a' });
|
||||
const b = row({ stableId: 'inat:b' });
|
||||
assert.deepEqual(
|
||||
selectWildlifeAmbientRows([b, a], [], now).map((item) => item.stableId),
|
||||
['inat:a', 'inat:b'],
|
||||
);
|
||||
const slightlyBetter = row({ stableId: 'inat:b', observedAt: now - hour });
|
||||
assert.ok(ambientScore(slightlyBetter, now) - ambientScore(a, now) < 0.5);
|
||||
assert.equal(
|
||||
selectWildlifeAmbientRows([a, slightlyBetter], ['inat:a'], now, 1)[0]
|
||||
.stableId,
|
||||
'inat:a',
|
||||
);
|
||||
assert.equal(
|
||||
selectWildlifeAmbientRows(
|
||||
[a, row({ stableId: 'inat:b', threatened: true })],
|
||||
['inat:a'],
|
||||
now,
|
||||
1,
|
||||
)[0].stableId,
|
||||
'inat:a',
|
||||
);
|
||||
assert.equal(
|
||||
selectWildlifeAmbientRows([a], ['gone'], now, 24)[0].stableId,
|
||||
a.stableId,
|
||||
);
|
||||
});
|
||||
|
||||
test('ambient entries are stable, accessible and select in place until their row is gone', () => {
|
||||
const selected = [];
|
||||
let exists = true;
|
||||
const entry = buildWildlifeThumbnailEntry(row(), now, {
|
||||
select(id) {
|
||||
selected.push(id);
|
||||
},
|
||||
hasRow: () => exists,
|
||||
});
|
||||
assert.equal(entry.id, 'sighting:inat:123');
|
||||
assert.equal(entry.interactive, true);
|
||||
assert.equal(entry.accessibilityLabel, "Show Anna's Hummingbird sighting");
|
||||
assert.equal(entry.activate(), true);
|
||||
assert.deepEqual(selected, ['inat:123']);
|
||||
exists = false;
|
||||
assert.equal(entry.activate(), false);
|
||||
assert.equal(selected.length, 1);
|
||||
assert.equal(buildWildlifeThumbnailEntry(row({ obscured: true }), now), null);
|
||||
});
|
||||
|
||||
test('all wildlife name labels prefer a complete word before their ellipsis and retain the full accessible name', () => {
|
||||
const commonName =
|
||||
'Great Blue Heron With An Unusually Lengthy Synthetic Common Name';
|
||||
for (const build of [buildWildlifeLabelEntry, buildWildlifeSelectedLabel]) {
|
||||
const entry = build(row({ commonName }));
|
||||
assert.equal(entry.title, 'GREAT BLUE HERON WITH AN UNUSUALLY…');
|
||||
assert.ok(
|
||||
Array.from(entry.title).length <= WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
);
|
||||
assert.ok(entry.accessibilityLabel.includes(commonName));
|
||||
const unbroken = build(row({ commonName: '𓅃'.repeat(60) }));
|
||||
assert.equal(
|
||||
Array.from(unbroken.title).length,
|
||||
WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
);
|
||||
assert.ok(unbroken.title.endsWith('…'));
|
||||
assert.ok(!unbroken.title.includes('\ufffd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('thumbnail entries use every shared thumbnail token and wildlife altitude curves', () => {
|
||||
const entry = buildWildlifeThumbnailEntry(row(), now);
|
||||
assert.deepEqual([entry.thumbnailWidth, entry.thumbnailHeight], [96, 72]);
|
||||
for (const key of ['thumbnailPadX', 'thumbnailPadTop', 'thumbnailPadBottom'])
|
||||
assert.equal(entry[key], CCTV_THUMBNAIL_STYLE.padding);
|
||||
const mapping = {
|
||||
thumbnailTitleHeight: 'titleHeight',
|
||||
thumbnailTitleChars: 'titleChars',
|
||||
thumbnailBackground: 'background',
|
||||
thumbnailTitleColor: 'titleColor',
|
||||
thumbnailTitleFont: 'titleFont',
|
||||
thumbnailLeaderColor: 'leader',
|
||||
thumbnailRuleColor: 'rule',
|
||||
thumbnailRuleHeight: 'ruleHeight',
|
||||
thumbnailRadius: 'radius',
|
||||
};
|
||||
for (const [field, token] of Object.entries(mapping))
|
||||
assert.equal(entry[field], CCTV_THUMBNAIL_STYLE[token]);
|
||||
assert.equal(entry.altitudeScale.fullEnd, 20_000);
|
||||
assert.equal(entry.altitudeScale.midEnd, 120_000);
|
||||
assert.equal(entry.altitudeFadeStart, 70_000);
|
||||
assert.equal(entry.altitudeFadeEnd, 125_000);
|
||||
assert.equal(entry.maxDistance, 600_000);
|
||||
assert.equal(entry.minAnchorSeparationPx, 112);
|
||||
assert.equal(entry.accent, wildlifeAccent('Aves'));
|
||||
assert.equal(entry.horizonCull, true);
|
||||
assert.equal(entry.requireImage, true);
|
||||
assert.deepEqual(entry.details, ['© Synthetic Photographer']);
|
||||
assert.equal(entry.thumbnailFixedWidth, true);
|
||||
assert.equal(entry.thumbnailFit, 'contain');
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(entry)), entry);
|
||||
assert.equal(entry.interactive, false);
|
||||
assert.equal(Object.isFrozen(WILDLIFE_OVERLAY_SOURCE_OPTIONS), true);
|
||||
assert.equal(WILDLIFE_OVERLAY_SOURCE_OPTIONS.cohortLimit, 24);
|
||||
assert.equal(WILDLIFE_SELECTED_SOURCE_OPTIONS.cohortLimit, 1);
|
||||
assert.equal(WILDLIFE_SELECTED_SOURCE_OPTIONS.collisionCapacity, 1);
|
||||
});
|
||||
|
||||
test('ambient cards retain only the credited creator and leave names for host truncation', () => {
|
||||
const commonName = 'An extremely long synthetic organism name';
|
||||
for (const [attribution, creator] of [
|
||||
[
|
||||
'(c) Synthetic Photographer, some rights reserved (CC BY-NC)',
|
||||
'Synthetic Photographer',
|
||||
],
|
||||
['© Doe, Jane, some rights reserved (CC BY)', 'Doe, Jane'],
|
||||
['(c) Synthetic Observer, no rights reserved (CC0)', 'Synthetic Observer'],
|
||||
['Doe, Jane, CC BY', 'Doe, Jane'],
|
||||
['Synthetic Photographer, CC BY-NC', 'Synthetic Photographer'],
|
||||
['© Synthetic Photographer (CC BY-SA 4.0)', 'Synthetic Photographer'],
|
||||
['Synthetic Photographer, CC0', 'Synthetic Photographer'],
|
||||
['Synthetic Photographer', 'Synthetic Photographer'],
|
||||
]) {
|
||||
const input = row({ commonName, photo: { ...photo, attribution } });
|
||||
const entry = buildWildlifeThumbnailEntry(input, now);
|
||||
assert.equal(
|
||||
entry.title,
|
||||
commonName,
|
||||
'the overlay host truncates the title',
|
||||
);
|
||||
assert.equal(entry.thumbnailFixedWidth, true);
|
||||
assert.deepEqual(entry.details, [`© ${creator}`]);
|
||||
assert.doesNotMatch(
|
||||
entry.details.join(' '),
|
||||
/Photo:|reserved|CC BY|Not a census/,
|
||||
);
|
||||
}
|
||||
for (const attribution of [
|
||||
'some rights reserved (CC BY)',
|
||||
'(c) ',
|
||||
'CC BY',
|
||||
'CC-BY-NC',
|
||||
'© (CC BY)',
|
||||
]) {
|
||||
assert.equal(
|
||||
buildWildlifeThumbnailEntry(
|
||||
row({ photo: { ...photo, attribution } }),
|
||||
now,
|
||||
),
|
||||
null,
|
||||
'a missing creator never becomes a made-up ambient credit',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ambient cards fade and scale gradually through the 120 km height band', () => {
|
||||
const entry = buildWildlifeThumbnailEntry(row(), now);
|
||||
const options = {
|
||||
fadeStart: entry.altitudeFadeStart,
|
||||
fadeEnd: entry.altitudeFadeEnd,
|
||||
};
|
||||
assert.equal(altitudeFade(70_000, options), 1);
|
||||
assert.ok(altitudeFade(100_000, options) > altitudeFade(120_000, options));
|
||||
assert.ok(altitudeFade(120_000, options) > 0);
|
||||
assert.equal(altitudeFade(125_000, options), 0);
|
||||
assert.equal(altitudeScale(20_000, entry.altitudeScale), 1);
|
||||
assert.equal(altitudeScale(120_000, entry.altitudeScale), 0.9);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
altitudeScale(119_999, entry.altitudeScale) -
|
||||
altitudeScale(120_001, entry.altitudeScale),
|
||||
) < 0.001,
|
||||
);
|
||||
});
|
||||
|
||||
test('selected rows use the shared selected treatment without duplicating photo content', () => {
|
||||
for (const input of [
|
||||
row(),
|
||||
row({ photo: null }),
|
||||
row({ obscured: true, observedAt: null, accuracyM: null }),
|
||||
acartia(),
|
||||
]) {
|
||||
const label = buildWildlifeSelectedLabel(input);
|
||||
assert.equal(label.id, `wildlife-sightings-label:${input.stableId}`);
|
||||
assert.equal(label.title, input.commonName.toLocaleUpperCase('en'));
|
||||
assert.equal(label.variant, 'label');
|
||||
assert.equal(label.placement, 'above');
|
||||
assert.equal(label.verticalOnly, true);
|
||||
assert.equal(label.protected, true);
|
||||
assert.equal(label.paintLane, 'selected');
|
||||
assert.equal(label.priority, Number.MAX_SAFE_INTEGER);
|
||||
assert.equal(label.interactive, false);
|
||||
assert.equal(label.selected, true);
|
||||
assert.equal(label.details, undefined, 'no link line on canvas cards');
|
||||
assert.equal(label.horizonCull, true);
|
||||
assert.equal(label.edgeFade, 'keyhole');
|
||||
assert.equal(label.accent, wildlifeAccent(input.iconic));
|
||||
for (const field of ['image', 'thumbnailWidth', 'pinned', 'activate'])
|
||||
assert.equal(
|
||||
Object.hasOwn(label, field),
|
||||
false,
|
||||
`${field} is absent from the selected name label`,
|
||||
);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(label)), label);
|
||||
}
|
||||
const ctx = { measureText: (value) => ({ width: String(value).length * 6 }) };
|
||||
const measured = measureOverlayEntry(ctx, buildWildlifeSelectedLabel(row()));
|
||||
assert.equal(
|
||||
measured.titleH,
|
||||
15,
|
||||
'selected names use the shared selected title metrics',
|
||||
);
|
||||
assert.equal(measured.padX, 12);
|
||||
assert.equal(measured.padY, 8);
|
||||
});
|
||||
|
||||
test('selected labels normalize one bounded common-name line and stay absent when unnamed', () => {
|
||||
const label = buildWildlifeSelectedLabel(
|
||||
row({
|
||||
commonName: `Synthetic\n ${'organism '.repeat(10)}`,
|
||||
}),
|
||||
);
|
||||
assert.doesNotMatch(label.title, /\n| /);
|
||||
assert.ok(
|
||||
Array.from(label.title).length <= WILDLIFE_SELECTED_LABEL_MAX_CHARS,
|
||||
);
|
||||
assert.equal(label.title, 'SYNTHETIC ORGANISM ORGANISM ORGANISM…');
|
||||
assert.ok(label.title.endsWith('…'));
|
||||
assert.equal(buildWildlifeSelectedLabel(row({ commonName: null })), null);
|
||||
assert.equal(
|
||||
buildWildlifeSelectedLabel(row({ commonName: null, scientificName: null })),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('detail labels show only common names, with no age, grade, accuracy or photo details', () => {
|
||||
const label = buildWildlifeLabelEntry(row());
|
||||
assert.equal(label.id, 'wildlife-sightings-label:inat:123');
|
||||
assert.equal(label.title, "ANNA'S HUMMINGBIRD");
|
||||
assert.equal(label.variant, 'label');
|
||||
assert.equal(label.collisionGroup, 'ambient-label');
|
||||
assert.equal(label.yieldToCollisionGroup, 'ambient-card');
|
||||
assert.equal(
|
||||
label.stateless,
|
||||
true,
|
||||
'labels never retain overlapping incumbent or exit placements',
|
||||
);
|
||||
assert.equal(label.paintLane, 'ambient-label');
|
||||
assert.equal(label.horizonCull, true);
|
||||
assert.equal(label.edgeFade, 'keyhole');
|
||||
assert.equal(label.interactive, false);
|
||||
assert.equal(label.accent, wildlifeAccent('Aves'));
|
||||
for (const field of [
|
||||
'details',
|
||||
'image',
|
||||
'accuracyM',
|
||||
'quality',
|
||||
'observedAt',
|
||||
'maxDistance',
|
||||
'altitudeFadeStart',
|
||||
'altitudeFadeEnd',
|
||||
'selected',
|
||||
])
|
||||
assert.equal(Object.hasOwn(label, field), false, field);
|
||||
assert.equal(WILDLIFE_LABEL_SOURCE_ID, 'wildlife-sightings-labels');
|
||||
assert.equal(WILDLIFE_LABEL_LIMIT, 150);
|
||||
assert.equal(Object.isFrozen(WILDLIFE_LABEL_SOURCE_OPTIONS), true);
|
||||
assert.equal(WILDLIFE_LABEL_SOURCE_OPTIONS.cohortLimit, WILDLIFE_LABEL_LIMIT);
|
||||
assert.equal(
|
||||
WILDLIFE_LABEL_SOURCE_OPTIONS.collisionCapacity,
|
||||
WILDLIFE_LABEL_LIMIT,
|
||||
);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(label)), label);
|
||||
});
|
||||
|
||||
test('unnamed observations have no ambient label and names come only from the row', () => {
|
||||
for (const commonName of [null, undefined, '', ' \n\t '])
|
||||
assert.equal(buildWildlifeLabelEntry({ ...row(), commonName }), null);
|
||||
assert.equal(
|
||||
buildWildlifeLabelEntry({ ...row(), commonName: 'Red-tailed hawk' }).title,
|
||||
'RED-TAILED HAWK',
|
||||
);
|
||||
});
|
||||
|
||||
test('ambient names select in place and reject activation after their row disappears', () => {
|
||||
let selectedId = null;
|
||||
let exists = true;
|
||||
const label = buildWildlifeLabelEntry(row(), {
|
||||
select(id) {
|
||||
selectedId = id;
|
||||
},
|
||||
hasRow: () => exists,
|
||||
});
|
||||
assert.equal(label.interactive, true);
|
||||
assert.equal(label.accessibilityLabel, "Show Anna's Hummingbird sighting");
|
||||
assert.equal(label.activate(), true);
|
||||
assert.equal(selectedId, 'inat:123');
|
||||
exists = false;
|
||||
assert.equal(label.activate(), false);
|
||||
});
|
||||
|
||||
test('selected name labels retain the existing altitude fade through 400 km', () => {
|
||||
for (const input of [row(), row({ photo: null }), acartia()]) {
|
||||
const entry = buildWildlifeSelectedLabel(input);
|
||||
const options = {
|
||||
fadeStart: entry.altitudeFadeStart,
|
||||
fadeEnd: entry.altitudeFadeEnd,
|
||||
};
|
||||
assert.equal(altitudeFade(30_000, options), 1);
|
||||
assert.ok(altitudeFade(300_000, options) > 0);
|
||||
assert.equal(altitudeFade(400_000, options), 0);
|
||||
assert.equal(altitudeFade(5_000_000, options), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('compact ages follow shared translated minute, hour and day forms', () => {
|
||||
assert.equal(formatAge(59_999), '1m');
|
||||
assert.equal(formatAge(60 * 60_000), '1h');
|
||||
assert.equal(formatAge(2 * 24 * hour), '2d');
|
||||
assert.equal(formatAge(-1), null);
|
||||
assert.equal(formatAge(NaN), null);
|
||||
assert.equal(wildlifeAccent('Protozoa'), '#9aa7b3');
|
||||
});
|
||||
|
||||
function ambientFixture(rows, screens) {
|
||||
const state = {
|
||||
enabled: true,
|
||||
renderedRows: rows,
|
||||
ambientRows: [],
|
||||
ambientIds: [],
|
||||
labelRows: [],
|
||||
selectedId: null,
|
||||
positions: new Map(
|
||||
rows.map((row) => [row.stableId, screens.get(row.stableId)]),
|
||||
),
|
||||
pointById: new Map(),
|
||||
rowById: new Map(rows.map((row) => [row.stableId, row])),
|
||||
viewer: {
|
||||
scene: { canvas: { clientWidth: 1200, clientHeight: 900 } },
|
||||
camera: { positionCartographic: { height: 30_000 } },
|
||||
},
|
||||
};
|
||||
const photos = [];
|
||||
const painted = [];
|
||||
const entries = new Map();
|
||||
let cardSourceOptions;
|
||||
const selection = createSelection({
|
||||
state,
|
||||
services: {
|
||||
now: () => now,
|
||||
project: (_scene, position) => position,
|
||||
overlayHost: {
|
||||
setEntries(id, _entries, options) {
|
||||
entries.set(id, _entries);
|
||||
if (id === 'wildlife-sightings') cardSourceOptions = options;
|
||||
},
|
||||
},
|
||||
},
|
||||
parts: {
|
||||
rendering: {
|
||||
occluder: () => null,
|
||||
requestRender() {},
|
||||
setPhotoCardVisibility: (ids) => painted.push([...ids]),
|
||||
},
|
||||
photos: { get: () => null, setAmbient: (rows) => photos.push(rows) },
|
||||
},
|
||||
});
|
||||
return {
|
||||
state,
|
||||
selection,
|
||||
photos,
|
||||
painted,
|
||||
entries,
|
||||
get cardSourceOptions() {
|
||||
return cardSourceOptions;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('label candidates and host priorities use ambientScore before the candidate cap', () => {
|
||||
const rows = Array.from({ length: WILDLIFE_LABEL_LIMIT + 20 }, (_, i) =>
|
||||
row({
|
||||
stableId: `inat:${String(i).padStart(3, '0')}`,
|
||||
commonName: `Synthetic bird ${i}`,
|
||||
favesCount: i,
|
||||
}),
|
||||
);
|
||||
const f = ambientFixture(
|
||||
rows,
|
||||
new Map(
|
||||
rows.map((r, i) => [
|
||||
r.stableId,
|
||||
{ x: 10 + (i % 40) * 28, y: 200 + Math.floor(i / 40) * 28 },
|
||||
]),
|
||||
),
|
||||
);
|
||||
f.selection.refreshAmbient();
|
||||
const entries = f.entries.get('wildlife-sightings-labels');
|
||||
assert.equal(entries.length, WILDLIFE_LABEL_LIMIT);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.id),
|
||||
rows
|
||||
.slice(20)
|
||||
.reverse()
|
||||
.map((r) => `wildlife-sightings-label:${r.stableId}`),
|
||||
);
|
||||
for (const r of f.state.labelRows)
|
||||
assert.equal(
|
||||
buildWildlifeLabelEntry(r, { nowMs: now }).priority,
|
||||
ambientScore(r, now),
|
||||
);
|
||||
});
|
||||
|
||||
test('ambient source forwards actual painted membership to badge visibility', () => {
|
||||
const r = row();
|
||||
const f = ambientFixture([r], new Map([[r.stableId, { x: 150, y: 150 }]]));
|
||||
f.selection.refreshAmbient();
|
||||
assert.deepEqual(f.painted, [], 'a queued photo is not a painted card');
|
||||
f.cardSourceOptions.onVisibilityChange(new Set([`sighting:${r.stableId}`]));
|
||||
assert.deepEqual(f.painted, [[`sighting:${r.stableId}`]]);
|
||||
f.cardSourceOptions.onVisibilityChange(new Set());
|
||||
assert.deepEqual(f.painted.at(-1), []);
|
||||
});
|
||||
|
||||
test('new ambient cards admit at most one highest-score photo per screen region', () => {
|
||||
const rows = Array.from({ length: 24 }, (_, i) =>
|
||||
row({ stableId: `inat:${i}`, favesCount: i }),
|
||||
);
|
||||
const screens = new Map(
|
||||
rows.map((row, i) => [
|
||||
row.stableId,
|
||||
{
|
||||
x: 150 + (Math.floor(i / 2) % 4) * 300,
|
||||
y: 150 + Math.floor(i / 8) * 300,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const f = ambientFixture(rows, screens);
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 12);
|
||||
assert.ok(
|
||||
f.state.ambientRows.every(
|
||||
(row) => Number(row.stableId.split(':')[1]) % 2 === 1,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
new Set(
|
||||
f.state.ambientRows.map((row) => {
|
||||
const p = screens.get(row.stableId);
|
||||
return `${Math.floor(p.x / 300)}:${Math.floor(p.y / 300)}`;
|
||||
}),
|
||||
).size,
|
||||
12,
|
||||
);
|
||||
});
|
||||
|
||||
test('pans retain every on-screen photo and refreshes only fill free slots', () => {
|
||||
const a = row({ stableId: 'inat:a' });
|
||||
const b = row({ stableId: 'inat:b' });
|
||||
const c = row({ stableId: 'inat:c', favesCount: 1000 });
|
||||
const screens = new Map([
|
||||
[a.stableId, { x: 150, y: 150 }],
|
||||
[b.stableId, { x: 450, y: 150 }],
|
||||
[c.stableId, { x: 170, y: 150 }],
|
||||
]);
|
||||
const f = ambientFixture([a, b], screens);
|
||||
f.selection.refreshAmbient();
|
||||
const initial = f.state.ambientIds.slice();
|
||||
// This pan moves the two incumbents into one region. Neither may be evicted.
|
||||
f.state.positions.set(b.stableId, { x: 200, y: 160 });
|
||||
f.selection.refreshAmbient();
|
||||
assert.deepEqual(f.state.ambientIds, initial);
|
||||
f.state.rowById.set(c.stableId, c);
|
||||
f.state.positions.set(c.stableId, screens.get(c.stableId));
|
||||
// A new random sample can omit both incumbent rows from the rendered batch.
|
||||
f.state.renderedRows = [c];
|
||||
f.selection.refreshAmbient();
|
||||
assert.deepEqual(f.state.ambientIds, initial);
|
||||
assert.deepEqual(
|
||||
new Set(f.selection.retainedAmbientRows().map((row) => row.stableId)),
|
||||
new Set(initial),
|
||||
);
|
||||
f.state.positions.set(a.stableId, { x: -1, y: 150 });
|
||||
assert.deepEqual(
|
||||
f.selection.retainedAmbientRows().map((row) => row.stableId),
|
||||
[b.stableId],
|
||||
);
|
||||
});
|
||||
|
||||
test('ambient show/hide thresholds have hysteresis without small-zoom toggles', () => {
|
||||
const a = row();
|
||||
const f = ambientFixture([a], new Map([[a.stableId, { x: 150, y: 150 }]]));
|
||||
const height = f.state.viewer.camera.positionCartographic;
|
||||
height.height = WILDLIFE_CARD_SHOW_MAX_METERS - 1;
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 1);
|
||||
for (const altitude of [110_001, 119_999, 120_001, 124_999, 110_001]) {
|
||||
height.height = altitude;
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 1);
|
||||
}
|
||||
height.height = WILDLIFE_CARD_HIDE_MAX_METERS;
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 0);
|
||||
height.height = WILDLIFE_CARD_HIDE_MAX_METERS - 1;
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 0);
|
||||
height.height = WILDLIFE_CARD_SHOW_MAX_METERS;
|
||||
f.selection.refreshAmbient();
|
||||
assert.equal(f.state.ambientRows.length, 1);
|
||||
});
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import { t, formatNumber, translateKnown } from '../../i18n/index.js';
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import { translateFeedError } from '../../ui/statusText.js';
|
||||
import { wildlifeIconicGroup } from './model.js';
|
||||
import { formatAge } from './photoText.js';
|
||||
import {
|
||||
ICONIC_TINTS,
|
||||
ICONIC_LEGEND_ORDER,
|
||||
TAXA_PRESETS,
|
||||
WILDLIFE_CONFIRMED_CHIP_ID,
|
||||
WILDLIFE_GRID_HEIGHT_METERS,
|
||||
WILDLIFE_UPDATE_INTERVAL_MS,
|
||||
} from './policy.js';
|
||||
|
||||
const LEGEND_LABELS = Object.freeze({
|
||||
Aves: msg('Birds'),
|
||||
Mammalia: msg('Mammals'),
|
||||
Reptilia: msg('Reptiles'),
|
||||
Amphibia: msg('Amphibians'),
|
||||
Actinopterygii: msg('Ray-finned fishes'),
|
||||
Insecta: msg('Insects'),
|
||||
Arachnida: msg('Arachnids'),
|
||||
Plantae: msg('Plants'),
|
||||
Fungi: msg('Fungi'),
|
||||
Animalia: msg('Other animals'),
|
||||
Unknown: msg('Other or unknown'),
|
||||
});
|
||||
|
||||
/** Own layer-row descriptors and serializable filter options. */
|
||||
export function createControls({ state, services, parts }) {
|
||||
function notify() {
|
||||
try {
|
||||
state.controlsListener?.();
|
||||
} catch {
|
||||
/* repaint listeners are optional */
|
||||
}
|
||||
}
|
||||
|
||||
function chip({ id, label, title, params, key, value }) {
|
||||
const pending =
|
||||
state.pendingParams?.keys.includes(key) &&
|
||||
(key === 'confirmed' || state.pendingParams[key] === value);
|
||||
const failedKey = state.failedParams?.keys.includes(key);
|
||||
const failed =
|
||||
failedKey && (key === 'confirmed' || state.failedParams[key] === value);
|
||||
const active =
|
||||
!pending &&
|
||||
(failedKey
|
||||
? state[key] === value
|
||||
: !!state.drawnParams && state.drawnParams[key] === value);
|
||||
return {
|
||||
id,
|
||||
label: `${label}${pending ? ' ···' : failed ? ' ✕' : ''}`,
|
||||
title: failed
|
||||
? t('{label} {detail} — click to retry', {
|
||||
label,
|
||||
detail: translateFeedError(
|
||||
state.error || msg('Wildlife sightings source unavailable'),
|
||||
),
|
||||
})
|
||||
: title,
|
||||
active,
|
||||
busy: !!pending,
|
||||
disabled: !!pending,
|
||||
state: pending
|
||||
? 'loading'
|
||||
: failed
|
||||
? 'error'
|
||||
: active
|
||||
? 'active'
|
||||
: 'idle',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
const methods = {
|
||||
id: 'wildlife-sightings',
|
||||
name: 'Wildlife Sightings',
|
||||
icon: '🦉',
|
||||
source: 'iNaturalist',
|
||||
updateInterval: WILDLIFE_UPDATE_INTERVAL_MS,
|
||||
|
||||
/** Route camera refreshes through the application's lifecycle manager. */
|
||||
attachDataManager(manager) {
|
||||
state.manager = manager;
|
||||
},
|
||||
/** Connect the layer row's repaint listener. */
|
||||
setRowControlsListener(listener) {
|
||||
state.controlsListener = typeof listener === 'function' ? listener : null;
|
||||
},
|
||||
/** Snapshot the requested filters for share links and restoration. */
|
||||
getParams() {
|
||||
return { confirmed: state.confirmed, taxa: state.taxa };
|
||||
},
|
||||
/** Apply filters while retaining the last accepted display until success. */
|
||||
setParams(params = {}) {
|
||||
const next = {
|
||||
confirmed:
|
||||
typeof params.confirmed === 'boolean'
|
||||
? params.confirmed
|
||||
: state.confirmed,
|
||||
taxa: TAXA_PRESETS.some((preset) => preset.value === params.taxa)
|
||||
? params.taxa
|
||||
: state.taxa,
|
||||
};
|
||||
const changed = ['confirmed', 'taxa'].filter(
|
||||
(key) => next[key] !== state[key],
|
||||
);
|
||||
if (!changed.length && !state.failedParams) return true;
|
||||
// Compare with the displayed filters so disjoint edits stay pending.
|
||||
// Initially taxa awaits data while the default-off toggle remains idle.
|
||||
const drawn = state.drawnParams || { confirmed: false, taxa: null };
|
||||
const keys = ['confirmed', 'taxa'].filter(
|
||||
(key) => next[key] !== drawn[key],
|
||||
);
|
||||
if (!changed.length && state.failedParams) {
|
||||
for (const key of state.failedParams.keys) {
|
||||
if (!keys.includes(key)) keys.push(key);
|
||||
}
|
||||
}
|
||||
state.confirmed = next.confirmed;
|
||||
state.taxa = next.taxa;
|
||||
state.pendingParams = keys.length ? { ...next, keys } : null;
|
||||
state.failedParams = null;
|
||||
// Replace the LRU with the accepted result so taxa never get mixed.
|
||||
state.pendingClear = true;
|
||||
state.lastQueryKey = null;
|
||||
state.request?.abort();
|
||||
state.background?.abort();
|
||||
parts.lifecycle.scheduleRefresh();
|
||||
notify();
|
||||
return true;
|
||||
},
|
||||
/** Describe filter chips, nonempty legend groups and provider attribution. */
|
||||
getRowControls() {
|
||||
const confirmedFailed = state.failedParams?.keys.includes('confirmed');
|
||||
const chips = [
|
||||
chip({
|
||||
id: WILDLIFE_CONFIRMED_CHIP_ID,
|
||||
label: t('CONFIRMED'),
|
||||
title: t('Show only community-confirmed (research-grade) sightings'),
|
||||
params: {
|
||||
confirmed: confirmedFailed ? state.confirmed : !state.confirmed,
|
||||
},
|
||||
key: 'confirmed',
|
||||
value: true,
|
||||
}),
|
||||
...TAXA_PRESETS.map((preset) =>
|
||||
chip({
|
||||
id: preset.id,
|
||||
label: translateKnown(preset.label),
|
||||
title: translateKnown(preset.title),
|
||||
params: { taxa: preset.value },
|
||||
key: 'taxa',
|
||||
value: preset.value,
|
||||
}),
|
||||
),
|
||||
];
|
||||
const counts = new Map();
|
||||
for (const row of state.renderedRows) {
|
||||
const group = wildlifeIconicGroup(row.iconic);
|
||||
counts.set(group, (counts.get(group) || 0) + 1);
|
||||
}
|
||||
const legend = ICONIC_LEGEND_ORDER.filter((key) => counts.has(key)).map(
|
||||
(key, index) => ({
|
||||
label: translateKnown(LEGEND_LABELS[key]),
|
||||
color: ICONIC_TINTS[key],
|
||||
count: counts.get(key),
|
||||
...(index === 0
|
||||
? {
|
||||
blurb: t(
|
||||
'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.',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
if (!state.lastUpdate && !legend.length) return { chips, legend: [] };
|
||||
const acartiaCount = state.renderedRows.filter(
|
||||
(row) => row.origin === 'acartia',
|
||||
).length;
|
||||
const info = acartiaCount
|
||||
? t(
|
||||
'iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record',
|
||||
{ count: formatNumber(acartiaCount) },
|
||||
)
|
||||
: t('iNaturalist contributors · licences per record');
|
||||
return { chips, legend, info };
|
||||
},
|
||||
/** Report the visible count, feed state and retry guidance. */
|
||||
getStats() {
|
||||
const grid = state.gridAvailable === true;
|
||||
const count =
|
||||
state.sightingCount ??
|
||||
(grid ? (state.gridTotal ?? 0) : state.renderedRows.length);
|
||||
const loading = state.loading && !state.lastUpdate;
|
||||
const refreshing = state.loading && !!state.lastUpdate;
|
||||
const age = state.lastUpdate
|
||||
? formatAge(services.now() - state.lastUpdate)
|
||||
: '';
|
||||
const minutes = state.lastUpdate
|
||||
? Math.max(0, (services.now() - state.lastUpdate) / 60_000)
|
||||
: 0;
|
||||
const liveAge =
|
||||
minutes < 1
|
||||
? t('<1m ago')
|
||||
: minutes < 90
|
||||
? t('{count}m ago', { count: Math.floor(minutes) })
|
||||
: t('{count}h ago', { count: Math.round(minutes / 60) });
|
||||
const stats = {
|
||||
count,
|
||||
lastUpdate: state.lastUpdate,
|
||||
error: state.error,
|
||||
stale: state.stale,
|
||||
loading,
|
||||
refreshing,
|
||||
countLabel: t('{count} sightings in view', {
|
||||
count: formatNumber(count),
|
||||
}),
|
||||
loadingLabel: loading
|
||||
? t('loading...')
|
||||
: refreshing
|
||||
? t('refreshing...')
|
||||
: state.stale
|
||||
? t('STALE · cached {age}', { age })
|
||||
: state.lastUpdate
|
||||
? t('LIVE · updated {age}', { age: liveAge })
|
||||
: '',
|
||||
};
|
||||
if (
|
||||
!state.error &&
|
||||
!state.query &&
|
||||
state.viewer?.camera?.positionCartographic?.height >
|
||||
WILDLIFE_GRID_HEIGHT_METERS
|
||||
) {
|
||||
// i18n-ignore: feed-state protocol value
|
||||
stats.status = 'zoom-in';
|
||||
stats.statusMessage = msg('Zoom in for local sightings');
|
||||
} else if (!state.error && state.lastUpdate && !count) {
|
||||
// i18n-ignore: feed-state protocol value
|
||||
stats.status = 'empty';
|
||||
stats.statusMessage = msg('No recent sightings in view');
|
||||
}
|
||||
if (state.retryAt > services.now())
|
||||
stats.retryInSec = Math.ceil((state.retryAt - services.now()) / 1000);
|
||||
return stats;
|
||||
},
|
||||
};
|
||||
return { methods, notify };
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import { wildlifeGridCellBounds } from './gridViewport.js';
|
||||
import {
|
||||
createWildlifeWorkQueue,
|
||||
drainWildlifeWork,
|
||||
WILDLIFE_WORK_UNIT,
|
||||
} from './performance.js';
|
||||
|
||||
export const WILDLIFE_DENSITY_OVERLAP = 4;
|
||||
const KERNEL_SIGMA = 0.7;
|
||||
export const WILDLIFE_DENSITY_MAX_ALPHA = 0.34;
|
||||
const DENSITY_MIN_ALPHA = 0.012;
|
||||
const DENSITY_RAMP_GAMMA = 3;
|
||||
const METERS_PER_DEGREE = (2 * Math.PI * 6378137) / 360;
|
||||
const FADE_STEPS = 3;
|
||||
const FADE_STEP_MS = 60;
|
||||
const SPRITE_SIZE = 128;
|
||||
// Fade coarse splats before they read as haze across the whole Earth disc.
|
||||
const ALTITUDE_FADE_START = 3_000_000;
|
||||
const ALTITUDE_FADE_END = 8_000_000;
|
||||
// Sparse cells read cool teal; hotspots warm toward amber. Sensor styles keep
|
||||
// one neutral or phosphor tint.
|
||||
const NORMAL_TINT = { low: [0.3, 0.8, 0.78], high: [1, 0.78, 0.38] };
|
||||
const MONO_TINT = { low: [0.96, 0.96, 0.96], high: [0.96, 0.96, 0.96] };
|
||||
const CRT_TINT = { low: [0.48, 1, 0.72], high: [0.48, 1, 0.72] };
|
||||
|
||||
/** View-relative alpha keeps hotspots distinct as cell counts grow on zoom-out. */
|
||||
export function wildlifeDensityIntensity(rank) {
|
||||
if (!(rank > 0) || !Number.isFinite(rank)) return 0;
|
||||
return (
|
||||
DENSITY_MIN_ALPHA +
|
||||
(WILDLIFE_DENSITY_MAX_ALPHA - DENSITY_MIN_ALPHA) *
|
||||
Math.min(1, rank) ** DENSITY_RAMP_GAMMA
|
||||
);
|
||||
}
|
||||
|
||||
/** Rank function over the snapshot's positive counts: the share of cells with
|
||||
* a count at or below the given one. */
|
||||
export function wildlifeDensityRanks(cells = []) {
|
||||
const counts = [];
|
||||
for (const cell of cells)
|
||||
if (Number.isSafeInteger(cell?.cellCount) && cell.cellCount > 0)
|
||||
counts.push(cell.cellCount);
|
||||
counts.sort((a, b) => a - b);
|
||||
return (count) => {
|
||||
if (!counts.length || !(count > 0)) return 0;
|
||||
let low = 0;
|
||||
let high = counts.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (counts[mid] <= count) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low / counts.length;
|
||||
};
|
||||
}
|
||||
|
||||
/** Truncated Gaussian in source-cell widths; overlapping splats hide the grid. */
|
||||
export function wildlifeDensityKernel(distance) {
|
||||
const radius = WILDLIFE_DENSITY_OVERLAP / 2;
|
||||
if (!Number.isFinite(distance) || distance < 0 || distance >= radius)
|
||||
return 0;
|
||||
const edge = Math.exp(-(radius ** 2) / (2 * KERNEL_SIGMA ** 2));
|
||||
return (
|
||||
(Math.exp(-(distance ** 2) / (2 * KERNEL_SIGMA ** 2)) - edge) / (1 - edge)
|
||||
);
|
||||
}
|
||||
|
||||
const DENSITY_IMAGE = `data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><defs><radialGradient id="g">${Array.from(
|
||||
{ length: 33 },
|
||||
(_, i) =>
|
||||
`<stop offset="${i / 32}" stop-color="white" stop-opacity="${wildlifeDensityKernel(((i / 32) * WILDLIFE_DENSITY_OVERLAP) / 2).toFixed(5)}"/>`,
|
||||
).join(
|
||||
'',
|
||||
)}</radialGradient></defs><circle cx="64" cy="64" r="64" fill="url(#g)"/></svg>`,
|
||||
)}`;
|
||||
|
||||
function tintForStyle(style) {
|
||||
if (['mono', 'surveillance', 'thermal', 'noir', 'nvg'].includes(style))
|
||||
return MONO_TINT;
|
||||
return ['crt', 'retro'].includes(style) ? CRT_TINT : NORMAL_TINT;
|
||||
}
|
||||
|
||||
function intersects(bounds, boxes, padding = 0) {
|
||||
if (!boxes?.length) return true;
|
||||
const latitude = (bounds.north - bounds.south) * padding;
|
||||
const longitude = (bounds.east - bounds.west) * padding;
|
||||
for (const box of boxes) {
|
||||
if (
|
||||
bounds.north + latitude < box.south ||
|
||||
bounds.south - latitude > box.north
|
||||
)
|
||||
continue;
|
||||
// Both a padded splat and the viewport can straddle the antimeridian.
|
||||
const east = box.east < box.west ? box.east + 360 : box.east;
|
||||
const left = bounds.west - longitude;
|
||||
const right = bounds.east + longitude;
|
||||
if (
|
||||
(right >= box.west && left <= east) ||
|
||||
(right + 360 >= box.west && left + 360 <= east) ||
|
||||
(right - 360 >= box.west && left - 360 <= east)
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fit the entire kernel inside the globe's tangent cone, plus 5% of its radius.
|
||||
// A visible centre alone lets a depth-free billboard bleed across the limb.
|
||||
// The inscribed sphere is conservative for WGS84 and needs no projections.
|
||||
function horizonTest(cameraPosition, radius) {
|
||||
const { x, y, z } = cameraPosition || {};
|
||||
const distance = Math.hypot(x, y, z);
|
||||
if (!Number.isFinite(distance) || distance <= radius) return null;
|
||||
const nx = x / distance;
|
||||
const ny = y / distance;
|
||||
const nz = z / distance;
|
||||
const sinAngle = radius / distance;
|
||||
const cosAngleSquared = 1 - sinAngle ** 2;
|
||||
const horizonPlane = radius * sinAngle;
|
||||
return (position, kernelRadius) => {
|
||||
const along = position.x * nx + position.y * ny + position.z * nz;
|
||||
if (along <= horizonPlane) return false;
|
||||
const clearance = sinAngle * (distance - along) - kernelRadius * 1.05;
|
||||
const acrossSquared = Math.max(
|
||||
0,
|
||||
position.x ** 2 + position.y ** 2 + position.z ** 2 - along ** 2,
|
||||
);
|
||||
return clearance > 0 && acrossSquared * cosAngleSquared < clearance ** 2;
|
||||
};
|
||||
}
|
||||
|
||||
/** Own noninteractive, world-sized glow splats, arrival fades and style changes. */
|
||||
/** A world-sized kernel fills the screen once the camera is inside it, e.g.
|
||||
* while flying in before the closer view's tiles arrive. Cesium fades it on the
|
||||
* GPU: gone within half its width, full beyond twice its width. */
|
||||
export const WILDLIFE_DENSITY_NEAR_FADE = Object.freeze({ near: 0.5, far: 2 });
|
||||
|
||||
export function createWildlifeDensity({
|
||||
cesium: C,
|
||||
viewer,
|
||||
positionForCell = (cell) => C.Cartesian3.fromDegrees(cell.lon, cell.lat),
|
||||
requestRender = () => {},
|
||||
setTimer = (fn, delay) => setTimeout(fn, delay),
|
||||
clearTimer = (timer) => clearTimeout(timer),
|
||||
requestIdle,
|
||||
cancelIdle,
|
||||
eventTarget = globalThis.window,
|
||||
documentRef = globalThis.document,
|
||||
} = {}) {
|
||||
let collection = null;
|
||||
const nearFade = (width) =>
|
||||
new C.NearFarScalar(
|
||||
width * WILDLIFE_DENSITY_NEAR_FADE.near,
|
||||
0,
|
||||
width * WILDLIFE_DENSITY_NEAR_FADE.far,
|
||||
1,
|
||||
);
|
||||
let metadata = new WeakMap();
|
||||
const entries = new Map();
|
||||
const fading = new Set();
|
||||
let fadeTimer = null;
|
||||
let destroyed = false;
|
||||
let generation = 0;
|
||||
let cockpitVision = false;
|
||||
let tint = tintForStyle(documentRef?.documentElement?.dataset?.gevStyle);
|
||||
let listening = false;
|
||||
const fadeQueue = createWildlifeWorkQueue({ requestIdle, cancelIdle });
|
||||
const styleQueue = createWildlifeWorkQueue({ requestIdle, cancelIdle });
|
||||
|
||||
// BillboardCollection does not implement Primitive's allowPicking option.
|
||||
// Skipping its public pick update prevents its translucent quads from
|
||||
// intercepting observations, including pixels with a faint glow alpha.
|
||||
class DensityCollection extends C.BillboardCollection {
|
||||
constructor() {
|
||||
// Cesium's TRANSLUCENT-only option writes depth and occludes badges.
|
||||
super();
|
||||
this.allowPicking = false;
|
||||
}
|
||||
update(frameState) {
|
||||
if (frameState.passes?.pick) return;
|
||||
return super.update(frameState);
|
||||
}
|
||||
}
|
||||
|
||||
function color(entry) {
|
||||
const mix = (i) => tint.low[i] + (tint.high[i] - tint.low[i]) * entry.rank;
|
||||
return new C.Color(
|
||||
mix(0),
|
||||
mix(1),
|
||||
mix(2),
|
||||
(wildlifeDensityIntensity(entry.rank) * entry.opacity * entry.fade) /
|
||||
FADE_STEPS,
|
||||
);
|
||||
}
|
||||
|
||||
function* styleWork() {
|
||||
let visits = 0;
|
||||
for (const entry of entries.values()) {
|
||||
if (visits++ % WILDLIFE_WORK_UNIT === 0) yield 'density-style';
|
||||
entry.point.color = color(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function setStyle(style) {
|
||||
const next = tintForStyle(style);
|
||||
if (next === tint) return;
|
||||
tint = next;
|
||||
if (entries.size)
|
||||
styleQueue.start(styleWork(), () => requestRender('density-style'));
|
||||
}
|
||||
|
||||
function onStyle(event) {
|
||||
if (!cockpitVision) setStyle(event?.detail?.style);
|
||||
}
|
||||
|
||||
function onVision(event) {
|
||||
cockpitVision = event?.detail?.cockpit === true;
|
||||
setStyle(event?.detail?.style);
|
||||
}
|
||||
|
||||
function ensureCollection() {
|
||||
if (collection) return;
|
||||
collection = new DensityCollection();
|
||||
viewer.scene.primitives.add(collection);
|
||||
if (!listening) {
|
||||
tint = tintForStyle(documentRef?.documentElement?.dataset?.gevStyle);
|
||||
eventTarget?.addEventListener?.('gev:style-change', onStyle);
|
||||
eventTarget?.addEventListener?.('gev:vision-change', onVision);
|
||||
listening = true;
|
||||
}
|
||||
}
|
||||
|
||||
function* fadeWork() {
|
||||
let visits = 0;
|
||||
for (const entry of fading) {
|
||||
if (visits++ % WILDLIFE_WORK_UNIT === 0) yield 'density-fade';
|
||||
entry.fade++;
|
||||
entry.point.color = color(entry);
|
||||
if (entry.fade >= FADE_STEPS) fading.delete(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFade() {
|
||||
if (fadeTimer !== null || fadeQueue.pending || !fading.size || destroyed)
|
||||
return;
|
||||
fadeTimer = setTimer(() => {
|
||||
fadeTimer = null;
|
||||
fadeQueue.start(fadeWork(), () => {
|
||||
requestRender('density-fade');
|
||||
scheduleFade();
|
||||
});
|
||||
}, FADE_STEP_MS);
|
||||
}
|
||||
|
||||
function dataForCell(cell, operations) {
|
||||
if (
|
||||
!cell ||
|
||||
!Number.isFinite(cell.lat) ||
|
||||
Math.abs(cell.lat) > 90 ||
|
||||
!Number.isFinite(cell.lon) ||
|
||||
Math.abs(cell.lon) > 180 ||
|
||||
!Number.isSafeInteger(cell.cellCount) ||
|
||||
cell.cellCount <= 0
|
||||
)
|
||||
return null;
|
||||
const old = metadata.get(cell);
|
||||
if (
|
||||
old?.id === cell.id &&
|
||||
old.lat === cell.lat &&
|
||||
old.lon === cell.lon &&
|
||||
old.count === cell.cellCount
|
||||
)
|
||||
return old;
|
||||
const bounds =
|
||||
old?.id === cell.id ? old.bounds : wildlifeGridCellBounds(cell.id);
|
||||
if (!bounds) return null;
|
||||
const samePosition = old?.lat === cell.lat && old?.lon === cell.lon;
|
||||
const data = {
|
||||
id: cell.id,
|
||||
lat: cell.lat,
|
||||
lon: cell.lon,
|
||||
count: cell.cellCount,
|
||||
bounds,
|
||||
width:
|
||||
WILDLIFE_DENSITY_OVERLAP *
|
||||
METERS_PER_DEGREE *
|
||||
Math.max(
|
||||
(bounds.east - bounds.west) * Math.cos((cell.lat * Math.PI) / 180),
|
||||
bounds.north - bounds.south,
|
||||
),
|
||||
position: samePosition ? old.position : positionForCell(cell),
|
||||
};
|
||||
operations.densityMetadataBuilds++;
|
||||
if (!samePosition) operations.densityPositionBuilds++;
|
||||
metadata.set(cell, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Reconcile the authoritative snapshot, including successful empty tiles. */
|
||||
function* updateWork(
|
||||
cells = [],
|
||||
{ boxes, isPointVisible, onOperations = () => {} } = {},
|
||||
) {
|
||||
const token = ++generation;
|
||||
const operations = {
|
||||
densityVisits: 0,
|
||||
densityRetirementVisits: 0,
|
||||
densityMetadataBuilds: 0,
|
||||
densityPositionBuilds: 0,
|
||||
densityWrites: 0,
|
||||
densityCreated: 0,
|
||||
densityRemoved: 0,
|
||||
};
|
||||
let sightingCount = 0;
|
||||
let changed = false;
|
||||
const height = viewer.camera?.positionCartographic?.height ?? 0;
|
||||
const opacity = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(ALTITUDE_FADE_END - height) /
|
||||
(ALTITUDE_FADE_END - ALTITUDE_FADE_START),
|
||||
),
|
||||
);
|
||||
const insideHorizon = horizonTest(
|
||||
viewer.camera?.positionWC,
|
||||
C.Ellipsoid?.WGS84?.minimumRadius ?? 6_356_752.314245,
|
||||
);
|
||||
const rankOf = wildlifeDensityRanks(cells);
|
||||
for (const cell of cells) {
|
||||
if (operations.densityVisits++ % WILDLIFE_WORK_UNIT === 0) {
|
||||
yield 'density';
|
||||
if (destroyed || token !== generation)
|
||||
return { sightingCount: 0, changed: false, size: entries.size };
|
||||
}
|
||||
const data = dataForCell(cell, operations);
|
||||
if (
|
||||
!data ||
|
||||
!intersects(data.bounds, boxes, (WILDLIFE_DENSITY_OVERLAP - 1) / 2) ||
|
||||
isPointVisible?.(data.position) === false
|
||||
)
|
||||
continue;
|
||||
if (intersects(data.bounds, boxes))
|
||||
sightingCount = Math.min(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
sightingCount + data.count,
|
||||
);
|
||||
// These are visual decisions only; counts retain every visible cell.
|
||||
if (
|
||||
opacity === 0 ||
|
||||
insideHorizon?.(data.position, data.width / 2) === false
|
||||
)
|
||||
continue;
|
||||
const rank = rankOf(data.count);
|
||||
let entry = entries.get(data.id);
|
||||
if (!entry) {
|
||||
ensureCollection();
|
||||
entry = { data, rank, opacity, fade: 1, seen: token, point: null };
|
||||
entry.point = collection.add({
|
||||
position: data.position,
|
||||
image: DENSITY_IMAGE,
|
||||
// Cesium packs width in 16 bits even for meter-sized billboards.
|
||||
// Put the world diameter in its unrestricted scale attribute so
|
||||
// continental kernels never clamp into thin vertical ellipses.
|
||||
width: SPRITE_SIZE,
|
||||
height: SPRITE_SIZE,
|
||||
scale: data.width / SPRITE_SIZE,
|
||||
sizeInMeters: true,
|
||||
color: color(entry),
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
translucencyByDistance: nearFade(data.width),
|
||||
});
|
||||
entries.set(data.id, entry);
|
||||
fading.add(entry);
|
||||
operations.densityCreated++;
|
||||
changed = true;
|
||||
} else {
|
||||
entry.seen = token;
|
||||
if (entry.data.position !== data.position) {
|
||||
entry.point.position = data.position;
|
||||
operations.densityWrites++;
|
||||
changed = true;
|
||||
}
|
||||
if (entry.data.width !== data.width) {
|
||||
entry.point.scale = data.width / SPRITE_SIZE;
|
||||
entry.point.translucencyByDistance = nearFade(data.width);
|
||||
operations.densityWrites++;
|
||||
changed = true;
|
||||
}
|
||||
if (entry.rank !== rank || entry.opacity !== opacity) {
|
||||
entry.rank = rank;
|
||||
entry.opacity = opacity;
|
||||
entry.point.color = color(entry);
|
||||
operations.densityWrites++;
|
||||
changed = true;
|
||||
}
|
||||
entry.data = data;
|
||||
}
|
||||
}
|
||||
for (const [id, entry] of entries) {
|
||||
if (operations.densityRetirementVisits++ % WILDLIFE_WORK_UNIT === 0) {
|
||||
yield 'density';
|
||||
if (destroyed || token !== generation)
|
||||
return { sightingCount: 0, changed: false, size: entries.size };
|
||||
}
|
||||
if (entry.seen === token) continue;
|
||||
collection.remove(entry.point);
|
||||
fading.delete(entry);
|
||||
entries.delete(id);
|
||||
operations.densityRemoved++;
|
||||
changed = true;
|
||||
}
|
||||
if (!entries.size && collection) clear(true);
|
||||
else scheduleFade();
|
||||
if (changed) requestRender('density');
|
||||
onOperations(operations);
|
||||
return { sightingCount, changed, size: entries.size };
|
||||
}
|
||||
|
||||
/** Release the texture atlas, billboards, cached coordinates and all work. */
|
||||
function clear(keepMetadata = false) {
|
||||
generation++;
|
||||
fadeQueue.cancel();
|
||||
styleQueue.cancel();
|
||||
if (fadeTimer !== null) clearTimer(fadeTimer);
|
||||
fadeTimer = null;
|
||||
entries.clear();
|
||||
fading.clear();
|
||||
if (!keepMetadata) metadata = new WeakMap();
|
||||
if (collection) {
|
||||
if (
|
||||
viewer.scene.primitives.remove(collection) !== true &&
|
||||
!collection.isDestroyed?.()
|
||||
)
|
||||
collection.destroy();
|
||||
collection = null;
|
||||
}
|
||||
if (listening) {
|
||||
eventTarget?.removeEventListener?.('gev:style-change', onStyle);
|
||||
eventTarget?.removeEventListener?.('gev:vision-change', onVision);
|
||||
listening = false;
|
||||
}
|
||||
cockpitVision = false;
|
||||
}
|
||||
|
||||
return {
|
||||
updateWork,
|
||||
update: (cells, options) => drainWildlifeWork(updateWork(cells, options)),
|
||||
clear,
|
||||
destroy() {
|
||||
clear();
|
||||
destroyed = true;
|
||||
},
|
||||
get collection() {
|
||||
return collection;
|
||||
},
|
||||
get size() {
|
||||
return entries.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { wildlifeGridCellBounds } from './gridViewport.js';
|
||||
import {
|
||||
createWildlifeDensity,
|
||||
wildlifeDensityIntensity,
|
||||
wildlifeDensityRanks,
|
||||
wildlifeDensityKernel,
|
||||
WILDLIFE_DENSITY_MAX_ALPHA,
|
||||
WILDLIFE_DENSITY_NEAR_FADE,
|
||||
WILDLIFE_DENSITY_OVERLAP,
|
||||
} from './density.js';
|
||||
import { WILDLIFE_WORK_UNIT } from './performance.js';
|
||||
|
||||
function cell(column, count = 1, options = {}) {
|
||||
const id = `grid:3/4/2:31:${column * 2 + 1}`;
|
||||
const bounds = wildlifeGridCellBounds(id);
|
||||
return {
|
||||
id,
|
||||
lat: (bounds.south + bounds.north) / 2,
|
||||
lon: (bounds.west + bounds.east) / 2,
|
||||
cellCount: count,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function harness({ documentStyle = 'normal', camera = {} } = {}) {
|
||||
const counts = { positions: 0, writes: 0, created: 0, updates: 0 };
|
||||
class BillboardCollection extends Cesium.BillboardCollection {
|
||||
add(options) {
|
||||
counts.created++;
|
||||
const point = super.add(options);
|
||||
return new Proxy(point, {
|
||||
get: (target, key) => Reflect.get(target, key, target),
|
||||
set(target, key, value) {
|
||||
counts.writes++;
|
||||
return Reflect.set(target, key, value, target);
|
||||
},
|
||||
});
|
||||
}
|
||||
update() {
|
||||
counts.updates++;
|
||||
}
|
||||
}
|
||||
const primitives = new Cesium.PrimitiveCollection();
|
||||
let handle = 0;
|
||||
const timers = new Map();
|
||||
const idle = new Map();
|
||||
const renders = [];
|
||||
const listeners = new Map();
|
||||
const eventTarget = {
|
||||
addEventListener(name, listener) {
|
||||
if (!listeners.has(name)) listeners.set(name, new Set());
|
||||
listeners.get(name).add(listener);
|
||||
},
|
||||
removeEventListener(name, listener) {
|
||||
listeners.get(name)?.delete(listener);
|
||||
},
|
||||
emit(name, detail) {
|
||||
for (const listener of listeners.get(name) || []) listener({ detail });
|
||||
},
|
||||
};
|
||||
const density = createWildlifeDensity({
|
||||
cesium: { ...Cesium, BillboardCollection },
|
||||
viewer: { scene: { primitives }, camera },
|
||||
positionForCell(row) {
|
||||
counts.positions++;
|
||||
return Cesium.Cartesian3.fromDegrees(row.lon, row.lat);
|
||||
},
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
setTimer(fn) {
|
||||
timers.set(++handle, fn);
|
||||
return handle;
|
||||
},
|
||||
clearTimer: (id) => timers.delete(id),
|
||||
requestIdle(fn) {
|
||||
idle.set(++handle, fn);
|
||||
return handle;
|
||||
},
|
||||
cancelIdle: (id) => idle.delete(id),
|
||||
eventTarget,
|
||||
documentRef: { documentElement: { dataset: { gevStyle: documentStyle } } },
|
||||
});
|
||||
function flushIdle() {
|
||||
while (idle.size) {
|
||||
const [id, fn] = idle.entries().next().value;
|
||||
idle.delete(id);
|
||||
fn();
|
||||
}
|
||||
}
|
||||
function fadeStep() {
|
||||
for (const [id, fn] of [...timers]) {
|
||||
timers.delete(id);
|
||||
fn();
|
||||
}
|
||||
flushIdle();
|
||||
}
|
||||
return {
|
||||
density,
|
||||
primitives,
|
||||
counts,
|
||||
renders,
|
||||
timers,
|
||||
idle,
|
||||
listeners,
|
||||
eventTarget,
|
||||
flushIdle,
|
||||
fadeStep,
|
||||
settle() {
|
||||
while (timers.size || idle.size) {
|
||||
fadeStep();
|
||||
flushIdle();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('density intensity rises with view rank, is bounded, and leaves no background for zero data', () => {
|
||||
for (const empty of [0, -1, null, undefined, NaN, Infinity])
|
||||
assert.equal(wildlifeDensityIntensity(empty), 0);
|
||||
const ranks = [0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 1];
|
||||
const values = ranks.map(wildlifeDensityIntensity);
|
||||
assert.ok(values[0] > 0, 'the sparsest cell still leaves a trace');
|
||||
assert.ok(values[0] < 0.02, 'sparse cells stay a trace, not a haze');
|
||||
for (let i = 1; i < values.length; i++) assert.ok(values[i] > values[i - 1]);
|
||||
assert.equal(values.at(-1), WILDLIFE_DENSITY_MAX_ALPHA);
|
||||
assert.ok(
|
||||
values[3] < WILDLIFE_DENSITY_MAX_ALPHA / 3,
|
||||
'a median cell is well below a hotspot',
|
||||
);
|
||||
});
|
||||
|
||||
test('density ranks are relative to the view, so hotspots stay distinct at any zoom', () => {
|
||||
const cells = (counts) => counts.map((cellCount) => ({ cellCount }));
|
||||
const near = wildlifeDensityRanks(cells([1, 2, 3, 40]));
|
||||
const far = wildlifeDensityRanks(cells([100, 200, 300, 4000]));
|
||||
assert.equal(near(40), 1);
|
||||
assert.equal(far(4000), 1, 'the top cell is brightest whatever its count');
|
||||
assert.equal(near(1), far(100), 'the same position ranks the same');
|
||||
assert.equal(wildlifeDensityRanks(cells([0, -1]))(5), 0);
|
||||
assert.equal(wildlifeDensityRanks([])(5), 0);
|
||||
});
|
||||
|
||||
test('an empty density snapshot creates no glow', () => {
|
||||
const h = harness();
|
||||
assert.deepEqual(h.density.update([]), {
|
||||
sightingCount: 0,
|
||||
changed: false,
|
||||
size: 0,
|
||||
});
|
||||
h.density.update([
|
||||
cell(0, 0),
|
||||
cell(1, -1),
|
||||
cell(2, 0.5),
|
||||
cell(3, 1, { id: 'invalid' }),
|
||||
]);
|
||||
assert.equal(h.density.collection, null);
|
||||
assert.equal(h.primitives.length, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('uniform occupied cells make a continuous field with no visible lattice', () => {
|
||||
assert.ok(WILDLIFE_DENSITY_OVERLAP >= 1.5);
|
||||
assert.equal(wildlifeDensityKernel(0), 1);
|
||||
assert.equal(wildlifeDensityKernel(WILDLIFE_DENSITY_OVERLAP / 2), 0);
|
||||
const samples = [];
|
||||
for (let x = 0; x < 1; x += 0.1)
|
||||
for (let y = 0; y < 1; y += 0.1) {
|
||||
let value = 0;
|
||||
for (let dx = -3; dx <= 3; dx++)
|
||||
for (let dy = -3; dy <= 3; dy++)
|
||||
value += wildlifeDensityKernel(Math.hypot(x - dx, y - dy));
|
||||
samples.push(value);
|
||||
}
|
||||
const modulation =
|
||||
(Math.max(...samples) - Math.min(...samples)) / Math.max(...samples);
|
||||
assert.ok(modulation < 0.007, `${(100 * modulation).toFixed(3)}% modulation`);
|
||||
});
|
||||
|
||||
test('each glow kernel fades on the GPU as the camera comes inside it, scaled to its own width', () => {
|
||||
const h = harness();
|
||||
h.density.update([cell(0, 4), cell(1, 9)]);
|
||||
const collection = h.density.collection;
|
||||
assert.equal(collection.length, 2);
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const point = collection.get(i);
|
||||
const width = point.scale * point.width;
|
||||
const fade = point.translucencyByDistance;
|
||||
assert.ok(fade, 'no screen-filling kernel when zoomed inside it');
|
||||
assert.ok(
|
||||
Math.abs(fade.near - width * WILDLIFE_DENSITY_NEAR_FADE.near) < 1e-6,
|
||||
);
|
||||
assert.equal(fade.nearValue, 0);
|
||||
assert.ok(
|
||||
Math.abs(fade.far - width * WILDLIFE_DENSITY_NEAR_FADE.far) < 1e-6,
|
||||
);
|
||||
assert.equal(fade.farValue, 1);
|
||||
}
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('occupied grid counts produce unlabelled world-sized glow billboards that never enter Cesium picking', () => {
|
||||
const h = harness();
|
||||
const rows = [cell(0, 1), cell(1, 100), cell(2, 1000000)];
|
||||
const result = h.density.update(rows);
|
||||
assert.equal(result.sightingCount, 1000101);
|
||||
assert.equal(h.primitives.length, 1);
|
||||
const collection = h.density.collection;
|
||||
assert.equal(collection.allowPicking, false);
|
||||
assert.equal(collection.length, 3);
|
||||
const first = collection.get(0);
|
||||
assert.equal(
|
||||
first.id,
|
||||
undefined,
|
||||
'density has no sighting identity or pick payload',
|
||||
);
|
||||
assert.equal(first.sizeInMeters, true);
|
||||
assert.equal(first.scaleByDistance, undefined);
|
||||
assert.match(decodeURIComponent(first.image), /radialGradient/);
|
||||
assert.doesNotMatch(decodeURIComponent(first.image), /text|badge|raven/);
|
||||
const bounds = wildlifeGridCellBounds(rows[0].id);
|
||||
const logicalHeight =
|
||||
(((bounds.north - bounds.south) * Math.PI) / 180) * 6378137;
|
||||
assert.ok(first.width * first.scale >= 1.5 * logicalHeight);
|
||||
assert.equal(first.width, first.height);
|
||||
assert.ok(
|
||||
first.width <= 65536,
|
||||
'encoded dimensions fit Cesium width packing',
|
||||
);
|
||||
assert.ok(
|
||||
first.width * first.scale > 65536,
|
||||
'continental splats retain their true footprint through scale',
|
||||
);
|
||||
collection.update({ passes: { pick: true } });
|
||||
assert.equal(
|
||||
h.counts.updates,
|
||||
0,
|
||||
'the real public pick update is suppressed',
|
||||
);
|
||||
collection.update({ passes: { render: true } });
|
||||
assert.equal(h.counts.updates, 1);
|
||||
h.settle();
|
||||
const rankOf = wildlifeDensityRanks(rows);
|
||||
for (let i = 0; i < collection.length; i++)
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
collection.get(i).color.alpha -
|
||||
wildlifeDensityIntensity(rankOf(rows[i].cellCount)),
|
||||
) < 1e-12,
|
||||
);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('progressive tile arrival fades only new cells and reuses existing glow without flashing', () => {
|
||||
const h = harness();
|
||||
const one = cell(0, 4);
|
||||
const two = cell(1, 10);
|
||||
h.density.update([one]);
|
||||
const first = h.density.collection.get(0);
|
||||
const starting = first.color.alpha;
|
||||
h.fadeStep();
|
||||
assert.ok(first.color.alpha > starting);
|
||||
const middle = first.color.alpha;
|
||||
h.density.update([one, two]);
|
||||
// The arriving tile re-ranks the incumbent (alone it was the hotspot; now
|
||||
// it is the median) but its fade progress is kept, not restarted.
|
||||
const progress = middle / wildlifeDensityIntensity(1);
|
||||
assert.ok(
|
||||
Math.abs(first.color.alpha - wildlifeDensityIntensity(0.5) * progress) <
|
||||
1e-12,
|
||||
'a streamed tile re-ranks but does not restart incumbents',
|
||||
);
|
||||
assert.equal(h.density.collection.get(0), first);
|
||||
assert.equal(h.density.size, 2);
|
||||
h.settle();
|
||||
const settled = first.color.alpha;
|
||||
h.density.update([one, two]);
|
||||
assert.equal(first.color.alpha, settled);
|
||||
h.density.update([two]);
|
||||
assert.equal(h.density.size, 1);
|
||||
assert.equal(h.density.collection.length, 1);
|
||||
h.density.update([]);
|
||||
assert.equal(h.density.size, 0);
|
||||
assert.equal(
|
||||
h.density.collection,
|
||||
null,
|
||||
'empty data releases the atlas and collection',
|
||||
);
|
||||
assert.equal(
|
||||
h.primitives.length,
|
||||
0,
|
||||
'no full-view fill remains over empty data',
|
||||
);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('visibility and antimeridian padding are applied on data/moveEnd work only', () => {
|
||||
const h = harness();
|
||||
const west = cell(1);
|
||||
const east = cell(28);
|
||||
const box = { ...wildlifeGridCellBounds(west.id) };
|
||||
assert.equal(h.density.update([west, east], { boxes: [box] }).size, 1);
|
||||
assert.equal(
|
||||
h.density.update([west], { boxes: [box], isPointVisible: () => false })
|
||||
.size,
|
||||
0,
|
||||
);
|
||||
const id = 'grid:3/0/2:31:1';
|
||||
const bounds = wildlifeGridCellBounds(id);
|
||||
const wrapped = {
|
||||
...west,
|
||||
id,
|
||||
lat: (bounds.south + bounds.north) / 2,
|
||||
lon: -179.3,
|
||||
};
|
||||
const crossed = {
|
||||
south: bounds.south,
|
||||
north: bounds.north,
|
||||
west: 179,
|
||||
east: -179,
|
||||
};
|
||||
assert.equal(h.density.update([wrapped], { boxes: [crossed] }).size, 1);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('density culls far-side cells and front-side kernels that would spill over the limb', () => {
|
||||
const camera = {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 5_000_000),
|
||||
positionCartographic: { height: 5_000_000 },
|
||||
};
|
||||
const h = harness({ camera });
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
camera.positionWC,
|
||||
);
|
||||
const cells = [
|
||||
cell(0, 1, { lat: 0, lon: 0 }),
|
||||
cell(1, 2, { lat: 0, lon: 54 }),
|
||||
cell(2, 4, { lat: 0, lon: 110 }),
|
||||
];
|
||||
assert.equal(
|
||||
occluder.isPointVisible(Cesium.Cartesian3.fromDegrees(54, 0)),
|
||||
true,
|
||||
'the limb fixture has a visible centre but an overflowing footprint',
|
||||
);
|
||||
const result = h.density.update(cells, {
|
||||
isPointVisible: (anchor) => occluder.isPointVisible(anchor),
|
||||
});
|
||||
assert.equal(result.size, 1, 'only the fully front-side kernel can glow');
|
||||
assert.equal(
|
||||
result.sightingCount,
|
||||
3,
|
||||
'visual limb padding never removes visible counts',
|
||||
);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('density fades from regional detail to a clear whole-globe view without frame work or count loss', () => {
|
||||
const camera = { positionCartographic: { height: 3_000_000 } };
|
||||
const h = harness({ camera });
|
||||
const cells = [cell(0, 12)];
|
||||
h.density.update(cells);
|
||||
h.settle();
|
||||
const point = h.density.collection.get(0);
|
||||
const fullAlpha = point.color.alpha;
|
||||
camera.positionCartographic.height = 5_500_000;
|
||||
h.density.update(cells);
|
||||
assert.equal(h.density.collection.get(0), point);
|
||||
assert.ok(Math.abs(point.color.alpha - fullAlpha / 2) < 1e-12);
|
||||
const writes = h.counts.writes;
|
||||
h.density.update(cells);
|
||||
assert.equal(h.counts.writes, writes, 'unchanged height writes nothing');
|
||||
camera.positionCartographic.height = 8_000_000;
|
||||
assert.equal(h.density.update(cells).sightingCount, 12);
|
||||
assert.equal(h.density.collection, null);
|
||||
assert.equal(h.timers.size, 0, 'no invisible fade or frame loop');
|
||||
const positions = h.counts.positions;
|
||||
camera.positionCartographic.height = 3_000_000;
|
||||
h.density.update(cells);
|
||||
h.settle();
|
||||
assert.equal(
|
||||
h.counts.positions,
|
||||
positions,
|
||||
'altitude changes reuse decoded positions',
|
||||
);
|
||||
assert.equal(h.density.collection.get(0).color.alpha, fullAlpha);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('styles follow persisted mono/CRT and cockpit vision overrides, without adding a second ramp', () => {
|
||||
const h = harness({ documentStyle: 'thermal' });
|
||||
h.density.update([cell(0)]);
|
||||
h.settle();
|
||||
const point = h.density.collection.get(0);
|
||||
assert.equal(point.color.red, point.color.green);
|
||||
assert.equal(point.color.green, point.color.blue);
|
||||
const alpha = point.color.alpha;
|
||||
h.eventTarget.emit('gev:style-change', { style: 'retro' });
|
||||
h.flushIdle();
|
||||
assert.ok(point.color.green > point.color.red);
|
||||
h.eventTarget.emit('gev:vision-change', { cockpit: true, style: 'nvg' });
|
||||
h.flushIdle();
|
||||
assert.equal(point.color.red, point.color.green);
|
||||
h.eventTarget.emit('gev:style-change', { style: 'normal' });
|
||||
h.flushIdle();
|
||||
assert.equal(
|
||||
point.color.red,
|
||||
point.color.green,
|
||||
'cockpit vision wins while active',
|
||||
);
|
||||
h.eventTarget.emit('gev:vision-change', { cockpit: false, style: 'normal' });
|
||||
h.flushIdle();
|
||||
assert.notEqual(point.color.red, point.color.green);
|
||||
assert.equal(point.color.alpha, alpha);
|
||||
h.density.destroy();
|
||||
});
|
||||
|
||||
test('clear/disable and destroy release the atlas, primitives, coordinates, timers and listeners', () => {
|
||||
const h = harness();
|
||||
const row = cell(0);
|
||||
h.density.update([row]);
|
||||
const old = h.density.collection;
|
||||
assert.equal(h.timers.size, 1);
|
||||
h.fadeStep();
|
||||
h.density.clear();
|
||||
assert.equal(old.isDestroyed(), true);
|
||||
assert.equal(h.density.collection, null);
|
||||
assert.equal(h.density.size, 0);
|
||||
assert.equal(h.primitives.length, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.idle.size, 0);
|
||||
for (const values of h.listeners.values()) assert.equal(values.size, 0);
|
||||
const priorPositions = h.counts.positions;
|
||||
h.density.update([row]);
|
||||
assert.equal(
|
||||
h.counts.positions,
|
||||
priorPositions + 1,
|
||||
'disable released cached coordinates',
|
||||
);
|
||||
const work = h.density.updateWork([cell(2)]);
|
||||
work.next();
|
||||
h.density.destroy();
|
||||
assert.equal(
|
||||
work.next().done,
|
||||
true,
|
||||
'obsolete data work cannot resurrect a destroyed layer',
|
||||
);
|
||||
assert.equal(h.density.collection, null);
|
||||
assert.equal(h.primitives.length, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
assert.equal(h.idle.size, 0);
|
||||
});
|
||||
|
||||
test('dense six-tile updates have a deterministic linear operation budget and no warmed writes/projections', (t) => {
|
||||
const h = harness();
|
||||
const cells = [];
|
||||
for (let x = 1; x < 3; x++)
|
||||
for (let y = 1; y < 4; y++)
|
||||
for (let row = 1; row < 64; row += 2)
|
||||
for (let column = 1; column < 64; column += 2) {
|
||||
const id = `grid:3/${x}/${y}:${row}:${column}`;
|
||||
const box = wildlifeGridCellBounds(id);
|
||||
cells.push({
|
||||
id,
|
||||
lat: (box.south + box.north) / 2,
|
||||
lon: (box.west + box.east) / 2,
|
||||
cellCount: row * 64 + column,
|
||||
});
|
||||
}
|
||||
let operations;
|
||||
h.density.update(cells, {
|
||||
onOperations: (value) => {
|
||||
operations = value;
|
||||
},
|
||||
});
|
||||
h.settle();
|
||||
assert.equal(operations.densityVisits, 6144);
|
||||
assert.equal(operations.densityMetadataBuilds, 6144);
|
||||
assert.equal(operations.densityPositionBuilds, 6144);
|
||||
assert.equal(operations.densityCreated, 6144);
|
||||
const positions = h.counts.positions;
|
||||
const writes = h.counts.writes;
|
||||
let units = 0;
|
||||
const work = h.density.updateWork(cells, {
|
||||
onOperations: (value) => {
|
||||
operations = value;
|
||||
},
|
||||
});
|
||||
while (!work.next().done) units++;
|
||||
assert.deepEqual(operations, {
|
||||
densityVisits: 6144,
|
||||
densityRetirementVisits: 6144,
|
||||
densityMetadataBuilds: 0,
|
||||
densityPositionBuilds: 0,
|
||||
densityWrites: 0,
|
||||
densityCreated: 0,
|
||||
densityRemoved: 0,
|
||||
});
|
||||
assert.equal(units, Math.ceil(6144 / WILDLIFE_WORK_UNIT) * 2);
|
||||
assert.equal(h.counts.positions, positions);
|
||||
assert.equal(h.counts.writes, writes);
|
||||
t.diagnostic(
|
||||
`6,144 occupied cells: ${units} bounded units, 12,288 linear visits, zero warmed metadata/Cartesian builds, projections or billboard writes`,
|
||||
);
|
||||
h.density.destroy();
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { VOICE_OFF_LAYERS } from '../../voice/layerManifest.js';
|
||||
import { ANALYST_LAYERS } from '../../data/analystEngine.js';
|
||||
|
||||
const read = (path) =>
|
||||
readFileSync(new URL(`../../../${path}`, import.meta.url), 'utf8');
|
||||
const current = read('docs/CURRENT-STATE.md');
|
||||
const start = current.indexOf('## Wildlife Sightings —');
|
||||
const section = current
|
||||
.slice(start, current.indexOf('\n## ', start + 1))
|
||||
.replace(/\s+/g, ' ');
|
||||
|
||||
test('Wildlife docs name the permanent token, voice policy and coverage limits', () => {
|
||||
const token = JSON.parse(
|
||||
read('src/data/layerStateTokenReservations.json'),
|
||||
).find(([id]) => id === 'wildlife-sightings')?.[1];
|
||||
assert.ok(token);
|
||||
assert.match(section, new RegExp(`share-link token \`${token}\``));
|
||||
assert.match(section, /Voice is off for this layer for now/);
|
||||
assert.ok(Object.hasOwn(VOICE_OFF_LAYERS, 'wildlife-sightings'));
|
||||
assert.ok(!Object.hasOwn(ANALYST_LAYERS, 'wildlife-sightings'));
|
||||
assert.match(section, /not a census/i);
|
||||
assert.match(section, /Salish Sea/);
|
||||
assert.match(section, /0\.01°/);
|
||||
assert.match(section, /private records are excluded/);
|
||||
});
|
||||
|
||||
test('Wildlife documentation defines recency by observation time and compact ambient credit', () => {
|
||||
assert.match(section, /observation time/i);
|
||||
assert.match(section, /\bd1\b/);
|
||||
assert.match(section, /48 h/);
|
||||
assert.match(section, /96 px/);
|
||||
assert.match(section, /creator name/i);
|
||||
assert.match(section, /110\/125 km/);
|
||||
assert.match(section, /120 km/);
|
||||
assert.match(section, /random sample/);
|
||||
assert.match(section, /bounded frame cache/);
|
||||
assert.match(read('DATA_SOURCES.md'), /observation time/i);
|
||||
});
|
||||
|
||||
test('Wildlife docs describe direct photo selection and text-only privacy rows', () => {
|
||||
assert.match(section, /compact anchored\s+photo card/);
|
||||
assert.match(section, /object-fit: contain/);
|
||||
assert.match(section, /preserving zoom/);
|
||||
assert.match(section, /compact observed\s+age/);
|
||||
assert.match(section, /Obscured sightings also stay\s+text-only/);
|
||||
assert.match(section, /Acartia stays text-only/);
|
||||
assert.match(section, /closes the card and clears selection/);
|
||||
assert.match(
|
||||
section,
|
||||
/shell teardown also removes its host and subscription/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
section,
|
||||
/opens\s+the \*\*Sighting\*\* panel|Clicking the panel photo/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Wildlife docs record queue failure isolation and the complete badge mapping', () => {
|
||||
assert.match(section, /recovered queue tail/);
|
||||
assert.match(section, /ordinary grid failures/);
|
||||
assert.match(section, /DEGRADED/);
|
||||
assert.match(section, /1.5 px dark outline/);
|
||||
for (const glyph of [
|
||||
'raven',
|
||||
'pets',
|
||||
'egg_alt',
|
||||
'water_drop',
|
||||
'set_meal',
|
||||
'bug_report',
|
||||
'pest_control',
|
||||
'eco',
|
||||
'umbrella',
|
||||
'category',
|
||||
'question_mark',
|
||||
])
|
||||
assert.ok(section.includes('`' + glyph + '`'), glyph);
|
||||
});
|
||||
|
||||
test('Wildlife docs separate density from real observation icons and define persistent selection', () => {
|
||||
assert.match(section, /dedicated, nonpickable billboard/);
|
||||
assert.match(section, /view-relative density ramp/);
|
||||
assert.match(section, /four logical cell widths/);
|
||||
assert.match(
|
||||
section,
|
||||
/Only real sampled observation rows produce taxon badges/,
|
||||
);
|
||||
assert.match(
|
||||
section,
|
||||
/Grid tiles publish even while the independent observation/,
|
||||
);
|
||||
assert.match(section, /until explicit\s+deselection or layer disable/);
|
||||
assert.match(
|
||||
section,
|
||||
/releases\s+the glow collection, texture atlas, timers, queues and style listeners/,
|
||||
);
|
||||
assert.doesNotMatch(section, /grid badge|Clicking a grid cell/);
|
||||
});
|
||||
|
||||
test('Wildlife docs describe one source link, name declutter and globe glow limits', () => {
|
||||
assert.match(section, /selected chrome and paint lane/);
|
||||
assert.match(section, /single source link/);
|
||||
assert.match(section, /80 screen pixels/);
|
||||
assert.match(section, /word boundary/);
|
||||
assert.match(section, /3,000 km/);
|
||||
assert.match(section, /8,000 km/);
|
||||
assert.match(section, /5% radius margin/);
|
||||
assert.match(section, /add no per-frame work/);
|
||||
});
|
||||
|
||||
test('Wildlife docs cite the documented UTFGrid endpoint and the Acartia key request', () => {
|
||||
for (const path of [
|
||||
'docs/KNOWN-ISSUES.md',
|
||||
'DATA_SOURCES.md',
|
||||
'docs/CURRENT-STATE.md',
|
||||
]) {
|
||||
const text = read(path);
|
||||
assert.doesNotMatch(text, /undocumented (iNaturalist|UTFGrid) endpoint/i);
|
||||
assert.doesNotMatch(text, /grid \(undocumented/i);
|
||||
}
|
||||
for (const path of ['docs/KNOWN-ISSUES.md', 'DATA_SOURCES.md'])
|
||||
assert.match(read(path), /Grid Tiles UTFGrid/);
|
||||
assert.match(
|
||||
read('DATA_SOURCES.md'),
|
||||
/create an account and generate an API key/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// Observation icons share 28px screen buckets. The density glow uses finer
|
||||
// 8–14px logical cells, coarsened until the complete view fits the tile budget.
|
||||
export const WILDLIFE_GRID_TARGET_SPACING_PX = 28;
|
||||
export const WILDLIFE_GRID_SAMPLE_SPACING_PX = 14;
|
||||
export const WILDLIFE_GRID_TILE_CAP = 6;
|
||||
export const WILDLIFE_GRID_MAX_ZOOM = 22;
|
||||
// The 64×64 UTFGrid is a hit mask: each logical 8px observation bin
|
||||
// occupies 2×2 of its 4px pixels, plus a dilated hit outline. Returned centres
|
||||
// are at odd pixel coordinates. Count logical bins once, not hit-mask pixels.
|
||||
export const WILDLIFE_GRID_LOGICAL_SIZE = 32;
|
||||
const MAX_LATITUDE = 85.0511287798066;
|
||||
const degrees = (radians) => (radians * 180) / Math.PI;
|
||||
const latitudeY = (latitude) => {
|
||||
const radians =
|
||||
(Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, latitude)) * Math.PI) / 180;
|
||||
return (1 - Math.asinh(Math.tan(radians)) / Math.PI) / 2;
|
||||
};
|
||||
const yLatitude = (value) =>
|
||||
degrees(Math.atan(Math.sinh(Math.PI * (1 - 2 * value))));
|
||||
const validBox = (box) =>
|
||||
!!box &&
|
||||
['south', 'west', 'north', 'east'].every((key) =>
|
||||
Number.isFinite(box[key]),
|
||||
) &&
|
||||
box.south >= -90 &&
|
||||
box.north <= 90 &&
|
||||
box.north > box.south &&
|
||||
box.west >= -180 &&
|
||||
box.west <= 180 &&
|
||||
box.east >= -180 &&
|
||||
box.east <= 180 &&
|
||||
box.west !== box.east;
|
||||
|
||||
function normalizedBoxes(boxes) {
|
||||
if (!Array.isArray(boxes) || !boxes.length || !boxes.every(validBox))
|
||||
return [];
|
||||
return boxes
|
||||
.flatMap((box) =>
|
||||
box.west < box.east
|
||||
? [box]
|
||||
: [
|
||||
{ ...box, east: 180 },
|
||||
{ ...box, west: -180 },
|
||||
],
|
||||
)
|
||||
.filter((box) => box.east > box.west);
|
||||
}
|
||||
|
||||
function tileRanges(boxes, z) {
|
||||
const count = 2 ** z;
|
||||
const index = (value) => Math.max(0, Math.min(count - 1, Math.floor(value)));
|
||||
return normalizedBoxes(boxes).map((box) => ({
|
||||
left: index(((box.west + 180) / 360) * count),
|
||||
right: index(((box.east + 180) / 360) * count - 1e-9),
|
||||
top: index(latitudeY(box.north) * count),
|
||||
bottom: index(latitudeY(box.south) * count - 1e-9),
|
||||
}));
|
||||
}
|
||||
|
||||
function tileCount(boxes, z) {
|
||||
// Split antimeridian ranges cannot overlap except at z=0. Counting that
|
||||
// shared tile twice merely makes the request cap slightly conservative.
|
||||
return tileRanges(boxes, z).reduce(
|
||||
(sum, range) =>
|
||||
sum + (range.right - range.left + 1) * (range.bottom - range.top + 1),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Choose the finest glow spacing whose full viewport fits the tile budget. */
|
||||
export function chooseWildlifeGridZoom({ boxes, width, height } = {}) {
|
||||
const visible = normalizedBoxes(boxes);
|
||||
if (!visible.length || !(width > 0) || !(height > 0)) return 0;
|
||||
const horizontalSpan = Math.min(
|
||||
1,
|
||||
visible.reduce((sum, box) => sum + (box.east - box.west) / 360, 0),
|
||||
);
|
||||
const verticalSpan = Math.max(
|
||||
...visible.map((box) => latitudeY(box.south) - latitudeY(box.north)),
|
||||
);
|
||||
const pixelsPerWorld = Math.min(
|
||||
width / horizontalSpan,
|
||||
height / Math.max(1e-9, verticalSpan),
|
||||
);
|
||||
let z = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
WILDLIFE_GRID_MAX_ZOOM,
|
||||
Math.ceil(
|
||||
Math.log2(
|
||||
pixelsPerWorld /
|
||||
(WILDLIFE_GRID_LOGICAL_SIZE * WILDLIFE_GRID_SAMPLE_SPACING_PX),
|
||||
) - 1e-9,
|
||||
),
|
||||
),
|
||||
);
|
||||
while (z > 0 && tileCount(visible, z) > WILDLIFE_GRID_TILE_CAP) z -= 1;
|
||||
return z;
|
||||
}
|
||||
|
||||
/** Only intersecting tiles, in stable XYZ order, with no neighboring padding.
|
||||
* Direct oversized requests fail closed; zoom selection coarsens beforehand. */
|
||||
export function wildlifeGridTiles({ boxes, z } = {}) {
|
||||
if (!Number.isInteger(z) || z < 0 || z > WILDLIFE_GRID_MAX_ZOOM) return [];
|
||||
if (tileCount(boxes, z) > WILDLIFE_GRID_TILE_CAP) return [];
|
||||
const tiles = new Map();
|
||||
for (const range of tileRanges(boxes, z)) {
|
||||
for (let x = range.left; x <= range.right; x += 1) {
|
||||
for (let y = range.top; y <= range.bottom; y += 1) {
|
||||
tiles.set(`${x}/${y}`, { z, x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...tiles.values()].sort((a, b) => a.x - b.x || a.y - b.y);
|
||||
}
|
||||
|
||||
/** Unlike detail-row viewport queries, grids cover full globe views too. */
|
||||
export function wildlifeGridViewportBoxes(viewer) {
|
||||
const rectangle = viewer?.camera?.computeViewRectangle?.(
|
||||
viewer?.scene?.globe?.ellipsoid,
|
||||
);
|
||||
if (!rectangle) return [];
|
||||
return normalizedBoxes([
|
||||
Object.fromEntries(
|
||||
['south', 'west', 'north', 'east'].map((key) => [
|
||||
key,
|
||||
Math.round(degrees(rectangle[key]) * 1e8) / 1e8,
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Return the geographic footprint of one logical occupied cell. */
|
||||
export function wildlifeGridCellBounds(id) {
|
||||
const match = /^grid:(\d+)\/(\d+)\/(\d+):(\d+):(\d+)$/.exec(id);
|
||||
if (!match) return null;
|
||||
const [z, x, y, row, column] = match.slice(1).map(Number);
|
||||
const count = 2 ** z;
|
||||
if (
|
||||
z > WILDLIFE_GRID_MAX_ZOOM ||
|
||||
x >= count ||
|
||||
y >= count ||
|
||||
row >= 64 ||
|
||||
column >= 64
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
south: yLatitude((y + (Math.floor(row / 2) + 1) / 32) / count),
|
||||
west: ((x + Math.floor(column / 2) / 32) / count) * 360 - 180,
|
||||
north: yLatitude((y + Math.floor(row / 2) / 32) / count),
|
||||
east: ((x + (Math.floor(column / 2) + 1) / 32) / count) * 360 - 180,
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared with the proxy and cached-coverage matching. A tile's hit area can
|
||||
* bleed into its neighbor; the geographic owner makes ids stable. */
|
||||
export function wildlifeGridCellId(lat, lon, z) {
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
!Number.isFinite(lon) ||
|
||||
Math.abs(lon) > 180 ||
|
||||
!Number.isInteger(z) ||
|
||||
z < 0 ||
|
||||
z > WILDLIFE_GRID_MAX_ZOOM
|
||||
)
|
||||
return null;
|
||||
const count = 2 ** z;
|
||||
const worldX = ((((lon + 180) / 360) % 1) + 1) % 1;
|
||||
const worldY = Math.max(0, Math.min(1 - Number.EPSILON, latitudeY(lat)));
|
||||
const tileX = Math.min(count - 1, Math.floor(worldX * count));
|
||||
const tileY = Math.min(count - 1, Math.floor(worldY * count));
|
||||
const column =
|
||||
Math.min(31, Math.floor((worldX * count - tileX) * 32 + 1e-8)) * 2 + 1;
|
||||
const row =
|
||||
Math.min(31, Math.floor((worldY * count - tileY) * 32 + 1e-8)) * 2 + 1;
|
||||
return `grid:${z}/${tileX}/${tileY}:${row}:${column}`;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
chooseWildlifeGridZoom,
|
||||
wildlifeGridTiles,
|
||||
wildlifeGridViewportBoxes,
|
||||
wildlifeGridCellBounds,
|
||||
wildlifeGridCellId,
|
||||
WILDLIFE_GRID_LOGICAL_SIZE,
|
||||
WILDLIFE_GRID_TILE_CAP,
|
||||
} from './gridViewport.js';
|
||||
|
||||
const globe = [
|
||||
{ south: -85.0511287798066, west: -180, north: 85.0511287798066, east: 180 },
|
||||
];
|
||||
const regional = [{ south: 0, west: 0, north: 40.979898069620134, east: 45 }];
|
||||
|
||||
test('glow sampling uses roughly 8–14px cells independently of the 28px icon buckets', () => {
|
||||
for (const [boxes, width, height, expected] of [
|
||||
[globe, 448, 448, 0],
|
||||
[regional, 448, 448, 3],
|
||||
[
|
||||
[{ south: 0, west: 0, north: 0.08789059053082508, east: 0.087890625 }],
|
||||
448,
|
||||
448,
|
||||
12,
|
||||
],
|
||||
]) {
|
||||
const zoom = chooseWildlifeGridZoom({ boxes, width, height });
|
||||
assert.equal(zoom, expected);
|
||||
const worldPixels = (width * 360) / (boxes[0].east - boxes[0].west);
|
||||
const spacing = worldPixels / 2 ** zoom / WILDLIFE_GRID_LOGICAL_SIZE;
|
||||
assert.ok(spacing >= 8 && spacing <= 14, `${zoom}: ${spacing}px`);
|
||||
assert.ok(
|
||||
wildlifeGridTiles({ boxes, z: zoom }).length <= WILDLIFE_GRID_TILE_CAP,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the named six-tile budget preserves complete coverage for common views', () => {
|
||||
for (const [boxes, width, height, expected] of [
|
||||
[globe, 896, 896, 1],
|
||||
[regional, 896, 896, 4],
|
||||
[[{ south: 7, west: -168, north: 72, east: -48 }], 1400, 900, 2],
|
||||
[globe, 4000, 4000, 1],
|
||||
[globe, 30_000, 400, 0],
|
||||
]) {
|
||||
const z = chooseWildlifeGridZoom({ boxes, width, height });
|
||||
assert.equal(z, expected);
|
||||
const tiles = wildlifeGridTiles({ boxes, z });
|
||||
assert.ok(tiles.length > 0 && tiles.length <= WILDLIFE_GRID_TILE_CAP);
|
||||
}
|
||||
});
|
||||
|
||||
test('tile ranges contain only intersecting tiles, no offscreen neighbors or duplicate boundary tiles', () => {
|
||||
assert.deepEqual(wildlifeGridTiles({ boxes: regional, z: 3 }), [
|
||||
{ z: 3, x: 4, y: 3 },
|
||||
]);
|
||||
assert.deepEqual(wildlifeGridTiles({ boxes: globe, z: 0 }), [
|
||||
{ z: 0, x: 0, y: 0 },
|
||||
]);
|
||||
const crossing = [{ south: 10, west: 179, north: 12, east: -178 }];
|
||||
const split = [
|
||||
{ south: 10, west: 179, north: 12, east: 180 },
|
||||
{ south: 10, west: -180, north: 12, east: -178 },
|
||||
];
|
||||
const z = chooseWildlifeGridZoom({
|
||||
boxes: crossing,
|
||||
width: 896,
|
||||
height: 600,
|
||||
});
|
||||
assert.deepEqual(
|
||||
wildlifeGridTiles({ boxes: crossing, z }),
|
||||
wildlifeGridTiles({ boxes: split, z }),
|
||||
);
|
||||
assert.ok(
|
||||
wildlifeGridTiles({ boxes: crossing, z }).every(
|
||||
(tile) =>
|
||||
((tile.x + 1) / 2 ** z) * 360 - 180 > 179 ||
|
||||
(tile.x / 2 ** z) * 360 - 180 < -178,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('tile cap degrades zoom to preserve complete coverage and oversized direct requests fail closed', () => {
|
||||
const z = chooseWildlifeGridZoom({
|
||||
boxes: globe,
|
||||
width: 30_000,
|
||||
height: 400,
|
||||
});
|
||||
assert.ok(
|
||||
wildlifeGridTiles({ boxes: globe, z }).length <= WILDLIFE_GRID_TILE_CAP,
|
||||
);
|
||||
assert.deepEqual(wildlifeGridTiles({ boxes: globe, z: 12 }), []);
|
||||
assert.equal(chooseWildlifeGridZoom({ boxes: [], width: 0, height: 0 }), 0);
|
||||
assert.deepEqual(wildlifeGridTiles({ boxes: [], z: 2 }), []);
|
||||
});
|
||||
|
||||
test('grid camera viewport supports globe and antimeridian without detail-query degree limits', () => {
|
||||
const radians = (value) => (value * Math.PI) / 180;
|
||||
const viewer = {
|
||||
camera: {
|
||||
computeViewRectangle: () => ({
|
||||
south: radians(-80),
|
||||
west: radians(120),
|
||||
north: radians(80),
|
||||
east: radians(-120),
|
||||
}),
|
||||
},
|
||||
};
|
||||
const boxes = wildlifeGridViewportBoxes(viewer);
|
||||
assert.equal(boxes.length, 2);
|
||||
assert.ok(Math.abs(boxes[0].west - 120) < 1e-8);
|
||||
assert.equal(boxes[0].east, 180);
|
||||
assert.equal(boxes[1].west, -180);
|
||||
assert.deepEqual(wildlifeGridViewportBoxes({}), []);
|
||||
});
|
||||
|
||||
test('logical cell ids and bounds share geographic coverage and wrap antimeridian', () => {
|
||||
const id = wildlifeGridCellId(65.94004866768753, -18.28125, 2);
|
||||
assert.equal(id, 'grid:2/1/1:1:51');
|
||||
const bounds = wildlifeGridCellBounds(id);
|
||||
assert.equal(bounds.west, -19.6875);
|
||||
assert.equal(bounds.east, -16.875);
|
||||
assert.ok(bounds.south < 65.94 && bounds.north > 65.94);
|
||||
assert.equal(
|
||||
wildlifeGridCellId(
|
||||
(bounds.south + bounds.north) / 2,
|
||||
(bounds.west + bounds.east) / 2,
|
||||
2,
|
||||
),
|
||||
id,
|
||||
);
|
||||
assert.equal(wildlifeGridCellId(0, 180, 2), wildlifeGridCellId(0, -180, 2));
|
||||
for (const invalid of [
|
||||
'bad',
|
||||
'grid:23/0/0:1:1',
|
||||
'grid:2/4/0:1:1',
|
||||
'grid:2/0/0:64:1',
|
||||
])
|
||||
assert.equal(wildlifeGridCellBounds(invalid), null);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { createPhotoSlots } from './photos.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
|
||||
/** Compose recent wildlife observations without acquiring data at construction. */
|
||||
export function createWildlifeSightingsLayer({
|
||||
source,
|
||||
overlayHost = null,
|
||||
screenSpaceEventHandlerFactory = null,
|
||||
picking = null,
|
||||
pointer = null,
|
||||
sprites = null,
|
||||
badgeSprites = null,
|
||||
render = null,
|
||||
context = null,
|
||||
focus = null,
|
||||
groundFloor = null,
|
||||
now = () => Date.now(),
|
||||
setTimer = (fn, delay) => setTimeout(fn, delay),
|
||||
clearTimer = (timer) => clearTimeout(timer),
|
||||
requestIdleCallback,
|
||||
cancelIdleCallback,
|
||||
imageFactory,
|
||||
eventTarget = globalThis.window,
|
||||
cesium = Cesium,
|
||||
project = (scene, position) =>
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(scene, position),
|
||||
occluderFactory = horizonOccluder,
|
||||
} = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('Wildlife sightings require a snapshot source');
|
||||
const state = {
|
||||
viewer: null,
|
||||
enabled: false,
|
||||
destroyed: false,
|
||||
manager: null,
|
||||
request: null,
|
||||
background: null,
|
||||
cameraTimer: null,
|
||||
removeCamera: null,
|
||||
removeContext: null,
|
||||
clickHandler: null,
|
||||
rows: new Map(),
|
||||
renderedRows: [],
|
||||
rowById: new Map(),
|
||||
points: null,
|
||||
pointById: new Map(),
|
||||
positions: new Map(),
|
||||
cells: [],
|
||||
staging: null,
|
||||
selectedId: null,
|
||||
selectedRow: null,
|
||||
carriers: new Map(),
|
||||
contextIds: new Set(),
|
||||
publishingContext: false,
|
||||
ambientIds: [],
|
||||
ambientRows: [],
|
||||
confirmed: false,
|
||||
taxa: 'all',
|
||||
drawnParams: null,
|
||||
pendingParams: null,
|
||||
failedParams: null,
|
||||
controlsListener: null,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
stale: false,
|
||||
loading: false,
|
||||
gridCells: [],
|
||||
gridParams: null,
|
||||
gridAvailable: false,
|
||||
gridTotal: 0,
|
||||
labelRows: [],
|
||||
query: null,
|
||||
band: null,
|
||||
lastQueryKey: null,
|
||||
retryAt: 0,
|
||||
failures: 0,
|
||||
};
|
||||
const parts = {};
|
||||
const services = {
|
||||
source,
|
||||
overlayHost,
|
||||
screenSpaceEventHandlerFactory,
|
||||
picking,
|
||||
pointer,
|
||||
sprites,
|
||||
badgeSprites,
|
||||
render,
|
||||
context,
|
||||
focus,
|
||||
groundFloor,
|
||||
now,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
requestIdleCallback,
|
||||
cancelIdleCallback,
|
||||
eventTarget,
|
||||
cesium,
|
||||
project,
|
||||
occluderFactory,
|
||||
};
|
||||
const owner = { state, services, parts };
|
||||
parts.photos = createPhotoSlots({
|
||||
imageFactory,
|
||||
now,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
onChange() {
|
||||
if (!state.enabled) return;
|
||||
parts.selection.publishAmbient();
|
||||
},
|
||||
});
|
||||
parts.rendering = createRendering(owner);
|
||||
parts.selection = createSelection(owner);
|
||||
parts.controls = createControls(owner);
|
||||
parts.lifecycle = createLifecycle(owner);
|
||||
return Object.assign({}, parts.controls.methods, parts.lifecycle.methods, {
|
||||
selectSighting: parts.selection.selectAndFocus,
|
||||
clearSightingSelection: parts.selection.clearAndRebuild,
|
||||
subscribeSelection: parts.selection.subscribe,
|
||||
/** Return the current surface anchor for a retained observation. */
|
||||
getSightingPosition(id) {
|
||||
return state.positions.get(id) || null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import { getWildlifeHeightBand, rowInWildlifeBoxes } from './model.js';
|
||||
import {
|
||||
combineWildlifeGridSnapshots,
|
||||
preferredWildlifeFailure,
|
||||
wildlifeFailureDelayMs,
|
||||
wildlifeFailures,
|
||||
wildlifeRetryAfterMs,
|
||||
} from './source.js';
|
||||
import { wildlifeMeasure } from './performance.js';
|
||||
import { isRecentWildlifeSighting } from './records.js';
|
||||
import {
|
||||
wildlifeGridViewportBoxes,
|
||||
chooseWildlifeGridZoom,
|
||||
wildlifeGridTiles,
|
||||
wildlifeGridCellId,
|
||||
} from './gridViewport.js';
|
||||
import { wildlifeViewportBoxes, snapWildlifeBox } from './viewport.js';
|
||||
import {
|
||||
WILDLIFE_DEBOUNCE_MS,
|
||||
WILDLIFE_MAX_ROWS,
|
||||
WILDLIFE_GRID_HEIGHT_METERS,
|
||||
} from './policy.js';
|
||||
|
||||
/** Manager-owned acquisition with camera debounce and guarded transactions. */
|
||||
export function createLifecycle({ state, services, parts }) {
|
||||
let requestQueryKey = null;
|
||||
let removeManagerListener = null;
|
||||
let lifetimeEpoch = 0;
|
||||
let initialCameraDeadline = 0;
|
||||
let initialSettle = null;
|
||||
// The manager invokes update immediately after enable. Settle startup camera
|
||||
// events before dispatching anything, then acquire only the resulting view.
|
||||
const INITIAL_CAMERA_SETTLE_MS = 300;
|
||||
|
||||
function settleInitialCamera(signal) {
|
||||
if (!initialCameraDeadline || services.now() >= initialCameraDeadline) {
|
||||
initialCameraDeadline = 0;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = null;
|
||||
initialSettle = null;
|
||||
initialCameraDeadline = 0;
|
||||
signal?.removeEventListener('abort', finish);
|
||||
resolve();
|
||||
};
|
||||
initialSettle = {
|
||||
schedule() {
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = services.setTimer(
|
||||
finish,
|
||||
Math.max(0, initialCameraDeadline - services.now()),
|
||||
);
|
||||
},
|
||||
finish,
|
||||
};
|
||||
signal?.addEventListener('abort', finish, { once: true });
|
||||
initialSettle.schedule();
|
||||
});
|
||||
}
|
||||
|
||||
function renderBatch(fn) {
|
||||
parts.rendering.beginBatch?.();
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
parts.rendering.endBatch?.();
|
||||
}
|
||||
}
|
||||
|
||||
function clearPending() {
|
||||
if (!state.pendingParams) return;
|
||||
if (state.error) state.failedParams = state.pendingParams;
|
||||
state.pendingParams = null;
|
||||
parts.controls.notify();
|
||||
}
|
||||
|
||||
function failParams(params) {
|
||||
// An acquisition can fail during an ordinary refresh, when no option is
|
||||
// pending. Keep the chosen taxa visible and retryable in that case too.
|
||||
state.failedParams = {
|
||||
...params,
|
||||
keys: [
|
||||
...new Set([
|
||||
...(state.pendingParams?.keys || []),
|
||||
'taxa',
|
||||
...(params.confirmed ? ['confirmed'] : []),
|
||||
]),
|
||||
],
|
||||
};
|
||||
state.pendingParams = null;
|
||||
}
|
||||
|
||||
function applyRetry(
|
||||
errors,
|
||||
retryAfterSec = null,
|
||||
previousMs = state.failures || 0,
|
||||
) {
|
||||
// Only 429/5xx failures impose cooldown; client, network and schema
|
||||
// errors retry on pan. Several requests can fail together: wait for the
|
||||
// longest effective delay among them.
|
||||
const delays = [
|
||||
...wildlifeFailures(errors).map((error) =>
|
||||
wildlifeFailureDelayMs(error, previousMs),
|
||||
),
|
||||
wildlifeRetryAfterMs(retryAfterSec),
|
||||
].filter((delay) => delay !== null);
|
||||
if (!delays.length) {
|
||||
state.failures = 0;
|
||||
state.retryAt = 0;
|
||||
return;
|
||||
}
|
||||
const backoff = Math.max(...delays);
|
||||
state.failures = backoff;
|
||||
state.retryAt = services.now() + backoff;
|
||||
}
|
||||
|
||||
function pageHidden() {
|
||||
return (
|
||||
services.eventTarget?.document?.hidden || globalThis.document?.hidden
|
||||
);
|
||||
}
|
||||
|
||||
function currentQuery() {
|
||||
const height = state.viewer?.camera?.positionCartographic?.height;
|
||||
state.band = getWildlifeHeightBand(height ?? Infinity, state.band);
|
||||
const boxes =
|
||||
state.band === 'global'
|
||||
? null
|
||||
: wildlifeViewportBoxes(state.viewer)?.map((box) =>
|
||||
snapWildlifeBox(box),
|
||||
);
|
||||
const visibleBoxes = wildlifeGridViewportBoxes(state.viewer);
|
||||
const scope =
|
||||
state.band === 'global' ||
|
||||
(!boxes && (height ?? Infinity) > WILDLIFE_GRID_HEIGHT_METERS)
|
||||
? 'global'
|
||||
: 'view';
|
||||
if (scope === 'view' && (!boxes?.length || boxes.some((box) => !box)))
|
||||
return null;
|
||||
const baseKey = JSON.stringify([
|
||||
scope,
|
||||
scope === 'global' ? null : boxes,
|
||||
state.confirmed,
|
||||
state.taxa,
|
||||
]);
|
||||
const canvas = state.viewer?.scene?.canvas;
|
||||
const zoom = visibleBoxes?.length
|
||||
? chooseWildlifeGridZoom({
|
||||
boxes: visibleBoxes,
|
||||
width: canvas?.clientWidth || 1400,
|
||||
height: canvas?.clientHeight || 900,
|
||||
})
|
||||
: 0;
|
||||
const tiles = visibleBoxes?.length
|
||||
? wildlifeGridTiles({ boxes: visibleBoxes, z: zoom })
|
||||
: [];
|
||||
return {
|
||||
scope,
|
||||
boxes: scope === 'global' ? null : boxes,
|
||||
visibleBoxes,
|
||||
tiles,
|
||||
key: JSON.stringify([baseKey, tiles]),
|
||||
};
|
||||
}
|
||||
|
||||
function refreshView() {
|
||||
const query = currentQuery();
|
||||
const superseded = state.query?.key !== query?.key;
|
||||
state.query = query;
|
||||
if (superseded) {
|
||||
state.request?.abort();
|
||||
state.background?.abort();
|
||||
}
|
||||
// Keep the previous validated display while changing filters. Its rows
|
||||
// and parameter chips are replaced together when acquisition succeeds.
|
||||
renderBatch(() => {
|
||||
const pending =
|
||||
!state.pendingClear && parts.rendering.rebuild() === 'pending';
|
||||
parts.rendering.cull();
|
||||
if (pending) return;
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.refreshContexts();
|
||||
parts.selection.syncCredits();
|
||||
});
|
||||
return query;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!state.enabled || pageHidden()) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const pendingParams = state.pendingParams;
|
||||
state.background?.abort();
|
||||
state.background = controller;
|
||||
try {
|
||||
if (state.manager)
|
||||
await state.manager.refreshLayer('wildlife-sightings', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
else await methods.update(undefined, { signal: controller.signal });
|
||||
} catch {
|
||||
// update exposes expected source errors through stats; manager failures
|
||||
// already have their own normalized status path.
|
||||
} finally {
|
||||
if (state.background === controller) {
|
||||
state.background = null;
|
||||
// A manager may decline acquisition while it is enabling, or the view
|
||||
// may disappear before update. Only queued/in-flight work is busy.
|
||||
if (
|
||||
state.pendingParams === pendingParams &&
|
||||
!state.loading &&
|
||||
state.cameraTimer == null
|
||||
)
|
||||
clearPending();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = null;
|
||||
if (!state.enabled || pageHidden()) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
if (initialSettle) {
|
||||
initialSettle.schedule();
|
||||
return;
|
||||
}
|
||||
const query = currentQuery();
|
||||
if (!query || query.key === state.lastQueryKey) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
query.key === requestQueryKey &&
|
||||
state.request &&
|
||||
!state.request.signal.aborted
|
||||
)
|
||||
return;
|
||||
state.cameraTimer = services.setTimer(
|
||||
() => {
|
||||
state.cameraTimer = null;
|
||||
if (!state.enabled || pageHidden()) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
const query = currentQuery();
|
||||
if (!query || query.key === state.lastQueryKey) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
query.key === requestQueryKey &&
|
||||
state.request &&
|
||||
!state.request.signal.aborted
|
||||
)
|
||||
return;
|
||||
if (services.now() < state.retryAt) {
|
||||
scheduleRefresh();
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
},
|
||||
Math.max(WILDLIFE_DEBOUNCE_MS, state.retryAt - services.now()),
|
||||
);
|
||||
}
|
||||
|
||||
function applyAccepted({
|
||||
snapshot,
|
||||
grid,
|
||||
gridError,
|
||||
query,
|
||||
params,
|
||||
request,
|
||||
signal,
|
||||
epoch,
|
||||
previousFailures,
|
||||
replaceRows = true,
|
||||
}) {
|
||||
const current = currentQuery();
|
||||
if (
|
||||
!state.enabled ||
|
||||
state.destroyed ||
|
||||
epoch !== lifetimeEpoch ||
|
||||
request.signal.aborted ||
|
||||
signal?.aborted ||
|
||||
params.taxa !== state.taxa ||
|
||||
params.confirmed !== state.confirmed ||
|
||||
current?.key !== query.key
|
||||
)
|
||||
return;
|
||||
renderBatch(() => {
|
||||
const sameParams =
|
||||
params.taxa === state.drawnParams?.taxa &&
|
||||
params.confirmed === state.drawnParams?.confirmed;
|
||||
const gridParams = state.gridParams || state.drawnParams;
|
||||
const sameGridParams =
|
||||
params.taxa === gridParams?.taxa &&
|
||||
params.confirmed === gridParams?.confirmed;
|
||||
// Tiles may beat a slow observation page. Publish their density without
|
||||
// replacing observations or claiming their filter transaction succeeded.
|
||||
// A pending filter keeps its previous display until both paths agree.
|
||||
if (!snapshot) {
|
||||
if (state.drawnParams && !sameParams) return;
|
||||
if (grid) {
|
||||
state.gridCells = grid.cells || [];
|
||||
state.gridParams = params;
|
||||
state.gridAvailable = true;
|
||||
state.gridTotal = grid.totalResults || 0;
|
||||
if (state.lastUpdate == null)
|
||||
state.lastUpdate = Number.isFinite(grid.fetchedAt)
|
||||
? grid.fetchedAt
|
||||
: services.now();
|
||||
const pendingRender = parts.rendering.rebuild() === 'pending';
|
||||
if (!pendingRender) {
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.refreshContexts();
|
||||
parts.selection.syncCredits();
|
||||
}
|
||||
parts.controls.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (replaceRows) {
|
||||
if (state.selectedId && !state.selectedRow)
|
||||
state.selectedRow =
|
||||
state.rowById.get(state.selectedId) ||
|
||||
state.rows.get(state.selectedId) ||
|
||||
null;
|
||||
const retainedRows = sameParams
|
||||
? (parts.selection.retainedAmbientRows?.() || []).filter(
|
||||
(row) =>
|
||||
state.rows.has(row.stableId) &&
|
||||
isRecentWildlifeSighting(row, services.now(), params.taxa),
|
||||
)
|
||||
: [];
|
||||
const retainedIds = new Set(retainedRows.map((row) => row.stableId));
|
||||
if (state.selectedRow) retainedIds.add(state.selectedRow.stableId);
|
||||
if (state.pendingClear || state.loadedScope !== query.scope)
|
||||
state.rows.clear();
|
||||
state.loadedScope = query.scope;
|
||||
state.pendingClear = false;
|
||||
// A successful page replaces this queried area, including a healthy
|
||||
// empty page. Keep only other visited areas in the bounded LRU. Areas
|
||||
// whose iNaturalist request failed keep their last iNaturalist rows.
|
||||
const inat = snapshot.sources?.inaturalist;
|
||||
const failedInatBoxes =
|
||||
inat?.ok === false ? inat.failedBoxes || query.boxes : null;
|
||||
for (const [id, row] of state.rows) {
|
||||
if (
|
||||
failedInatBoxes &&
|
||||
row.origin === 'inaturalist' &&
|
||||
rowInWildlifeBoxes(row, failedInatBoxes, 0)
|
||||
)
|
||||
continue;
|
||||
if (rowInWildlifeBoxes(row, query.boxes, 0)) state.rows.delete(id);
|
||||
}
|
||||
for (const row of snapshot.rows) {
|
||||
state.rows.delete(row.stableId);
|
||||
state.rows.set(row.stableId, row);
|
||||
if (row.stableId === state.selectedId) state.selectedRow = row;
|
||||
}
|
||||
// Selection owns a real observation until explicit deselection. It is
|
||||
// independent of sample/window/filter and ambient-card eligibility.
|
||||
if (state.selectedRow && !state.rows.has(state.selectedId))
|
||||
state.rows.set(state.selectedId, state.selectedRow);
|
||||
// Random samples may omit a photograph already displayed. Its bounded
|
||||
// incumbent row stays selectable until the card leaves the view/window.
|
||||
for (const row of retainedRows) {
|
||||
if (!state.rows.has(row.stableId)) state.rows.set(row.stableId, row);
|
||||
}
|
||||
if (state.rows.size > WILDLIFE_MAX_ROWS) {
|
||||
for (const id of state.rows.keys()) {
|
||||
if (!retainedIds.has(id)) state.rows.delete(id);
|
||||
if (state.rows.size <= WILDLIFE_MAX_ROWS) break;
|
||||
}
|
||||
}
|
||||
if (state.rows.size > WILDLIFE_MAX_ROWS) {
|
||||
for (const id of state.rows.keys()) {
|
||||
if (id !== state.selectedId) state.rows.delete(id);
|
||||
if (state.rows.size <= WILDLIFE_MAX_ROWS) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const failedStale = [snapshot, grid].filter(
|
||||
(value) =>
|
||||
value?.stale === true &&
|
||||
Number.isFinite(value.retryAfterSec) &&
|
||||
(value.upstreamStatus === undefined ||
|
||||
value.upstreamStatus === 429 ||
|
||||
value.upstreamStatus >= 500),
|
||||
);
|
||||
// The filter above admits only 429/5xx or an unmarked stale answer.
|
||||
const staleError = failedStale.length
|
||||
? Object.assign(
|
||||
new Error(msg('Wildlife sightings source unavailable')),
|
||||
{
|
||||
upstreamStatus: Math.max(
|
||||
...failedStale.map((value) => value.upstreamStatus || 503),
|
||||
),
|
||||
retryAfterSec: Math.max(
|
||||
...failedStale.map((value) => value.retryAfterSec),
|
||||
),
|
||||
},
|
||||
)
|
||||
: null;
|
||||
const inat = snapshot.sources?.inaturalist;
|
||||
const acartia = snapshot.sources?.acartia;
|
||||
// Keep each failed request's status and Retry-After; the displayed
|
||||
// error is the one whose effective delay sets the cooldown.
|
||||
const sourceErrors = [
|
||||
gridError,
|
||||
staleError,
|
||||
inat?.ok === false
|
||||
? inat.error ||
|
||||
new Error(msg('Wildlife sightings source unavailable'))
|
||||
: null,
|
||||
acartia?.ok === false
|
||||
? Object.assign(new Error(msg('Acartia feed unavailable')), {
|
||||
...(acartia.error?.status
|
||||
? { status: acartia.error.status }
|
||||
: {}),
|
||||
...(acartia.error?.upstreamStatus
|
||||
? { upstreamStatus: acartia.error.upstreamStatus }
|
||||
: {}),
|
||||
...(Number.isFinite(acartia.error?.retryAfterSec)
|
||||
? { retryAfterSec: acartia.error.retryAfterSec }
|
||||
: {}),
|
||||
})
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const sourceError = preferredWildlifeFailure(
|
||||
sourceErrors,
|
||||
previousFailures,
|
||||
);
|
||||
state.stale =
|
||||
!sourceError && (snapshot.stale === true || grid?.stale === true);
|
||||
const fetchedAt = [snapshot.fetchedAt, grid?.fetchedAt].filter(
|
||||
Number.isFinite,
|
||||
);
|
||||
state.lastUpdate =
|
||||
(snapshot.stale === true || grid?.stale === true) && fetchedAt.length
|
||||
? Math.min(...fetchedAt)
|
||||
: services.now();
|
||||
state.error = sourceError?.message || null;
|
||||
state.drawnParams = params;
|
||||
const retryAfter = [snapshot, grid]
|
||||
.filter(
|
||||
(value) =>
|
||||
value &&
|
||||
(value.upstreamStatus === undefined ||
|
||||
value.upstreamStatus === 429 ||
|
||||
value.upstreamStatus >= 500),
|
||||
)
|
||||
.map((value) => value.retryAfterSec)
|
||||
.filter(Number.isFinite);
|
||||
if (sourceError) {
|
||||
failParams(params);
|
||||
applyRetry(
|
||||
sourceErrors,
|
||||
retryAfter.length ? Math.max(...retryAfter) : null,
|
||||
previousFailures,
|
||||
);
|
||||
} else {
|
||||
state.pendingParams = null;
|
||||
state.failedParams = null;
|
||||
state.retryAt = retryAfter.length
|
||||
? services.now() + wildlifeRetryAfterMs(Math.max(...retryAfter))
|
||||
: 0;
|
||||
state.failures = 0;
|
||||
}
|
||||
state.query = currentQuery();
|
||||
if (grid !== undefined || !sameGridParams) {
|
||||
state.gridCells = grid?.cells || [];
|
||||
state.gridParams = grid ? params : null;
|
||||
state.gridAvailable = !!grid;
|
||||
state.gridTotal = grid?.totalResults || 0;
|
||||
}
|
||||
const pendingRender = parts.rendering.rebuild() === 'pending';
|
||||
if (pendingRender) {
|
||||
state.lastQueryKey = sourceError ? null : state.query?.key || query.key;
|
||||
parts.controls.notify();
|
||||
return;
|
||||
}
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.notify();
|
||||
parts.selection.refreshContexts();
|
||||
parts.selection.syncCredits();
|
||||
state.lastQueryKey = sourceError ? null : state.query?.key || query.key;
|
||||
parts.controls.notify();
|
||||
});
|
||||
}
|
||||
|
||||
function disable() {
|
||||
lifetimeEpoch += 1;
|
||||
initialSettle?.finish();
|
||||
initialCameraDeadline = 0;
|
||||
state.enabled = false;
|
||||
services.clearTimer(state.cameraTimer);
|
||||
state.cameraTimer = null;
|
||||
state.request?.abort();
|
||||
state.request = null;
|
||||
requestQueryKey = null;
|
||||
state.background?.abort();
|
||||
state.background = null;
|
||||
state.loading = false;
|
||||
clearPending();
|
||||
state.removeCamera?.();
|
||||
state.removeCamera = null;
|
||||
parts.rendering.deactivate?.();
|
||||
removeManagerListener?.();
|
||||
removeManagerListener = null;
|
||||
parts.selection.remove();
|
||||
services.picking?.unregisterPickOwner?.('wildlife-sightings');
|
||||
parts.rendering.clear();
|
||||
parts.photos.clear();
|
||||
parts.controls.notify();
|
||||
parts.rendering.requestRender('disable');
|
||||
return true;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/** Attach this layer's Cesium resources to one viewer. */
|
||||
init(viewer, { signal } = {}) {
|
||||
if (state.viewer)
|
||||
throw new Error('Wildlife sightings layer is already initialized');
|
||||
if (state.destroyed || signal?.aborted) return false;
|
||||
state.viewer = viewer;
|
||||
parts.rendering.init();
|
||||
return true;
|
||||
},
|
||||
/** Show retained observations and acquire the settled camera view. */
|
||||
enable(_viewer, { signal } = {}) {
|
||||
if (state.destroyed || signal?.aborted) return false;
|
||||
if (!state.viewer)
|
||||
throw new Error('Wildlife sightings layer must be initialized');
|
||||
if (state.enabled) return true;
|
||||
state.enabled = true;
|
||||
initialCameraDeadline = services.now() + INITIAL_CAMERA_SETTLE_MS;
|
||||
parts.rendering.activate?.();
|
||||
state.points.show = true;
|
||||
refreshView();
|
||||
for (const id of parts.selection.sourceIds)
|
||||
services.overlayHost?.setVisible?.(id, true);
|
||||
parts.selection.install();
|
||||
services.picking?.registerPickOwner?.(
|
||||
'wildlife-sightings',
|
||||
(id) =>
|
||||
typeof id === 'string' &&
|
||||
id.startsWith('wildlife:') &&
|
||||
state.rowById.has(id.slice(9)),
|
||||
);
|
||||
state.removeCamera = state.viewer.camera?.moveEnd?.addEventListener?.(
|
||||
() => {
|
||||
if (!state.enabled) return;
|
||||
wildlifeMeasure('moveEnd', () => {
|
||||
if (
|
||||
initialCameraDeadline &&
|
||||
services.now() < initialCameraDeadline
|
||||
) {
|
||||
initialCameraDeadline = services.now() + INITIAL_CAMERA_SETTLE_MS;
|
||||
refreshView();
|
||||
initialSettle?.schedule();
|
||||
} else {
|
||||
refreshView();
|
||||
scheduleRefresh();
|
||||
}
|
||||
parts.controls.notify();
|
||||
});
|
||||
},
|
||||
);
|
||||
// A debounce can expire while the manager is still enabling and cannot
|
||||
// accept refreshLayer yet. Its settled visibility event is the one
|
||||
// catch-up edge; completed initial queries are skipped by their key.
|
||||
removeManagerListener =
|
||||
state.manager?.subscribe?.((change) => {
|
||||
if (
|
||||
change.type === 'visibility' &&
|
||||
change.layerId === 'wildlife-sightings' &&
|
||||
change.enabled === true &&
|
||||
state.enabled
|
||||
)
|
||||
scheduleRefresh();
|
||||
}) || null;
|
||||
if (services.sprites?.restoreSpriteOrderOnEnable)
|
||||
services.sprites.restoreSpriteOrderOnEnable(
|
||||
'wildlife-sightings',
|
||||
state.viewer,
|
||||
);
|
||||
else services.sprites?.restoreSpriteOrder?.(state.viewer);
|
||||
return true;
|
||||
},
|
||||
/** Refresh observations and density independently for the current filters. */
|
||||
async update(_viewer, { signal } = {}) {
|
||||
if (
|
||||
!state.enabled ||
|
||||
!state.points ||
|
||||
state.destroyed ||
|
||||
signal?.aborted
|
||||
) {
|
||||
if (!state.request) clearPending();
|
||||
return false;
|
||||
}
|
||||
if (initialCameraDeadline && services.now() < initialCameraDeadline)
|
||||
await settleInitialCamera(signal);
|
||||
initialCameraDeadline = 0;
|
||||
if (!state.enabled || state.destroyed || signal?.aborted) return false;
|
||||
const query = currentQuery();
|
||||
state.query = query;
|
||||
if (!query || pageHidden()) {
|
||||
clearPending();
|
||||
return true;
|
||||
}
|
||||
if (services.now() < state.retryAt) {
|
||||
if (state.pendingParams && state.cameraTimer == null) scheduleRefresh();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
requestQueryKey === query.key &&
|
||||
state.request &&
|
||||
!state.request.signal.aborted
|
||||
)
|
||||
return true;
|
||||
state.request?.abort();
|
||||
const request = new AbortController();
|
||||
const requestLifetime = lifetimeEpoch;
|
||||
// One acquisition publishes several times (tiles, rows, final grid).
|
||||
// Each derives its default backoff from the backoff before it started.
|
||||
const previousFailures = state.failures || 0;
|
||||
const supersededResult = () =>
|
||||
state.enabled &&
|
||||
!state.destroyed &&
|
||||
lifetimeEpoch === requestLifetime &&
|
||||
!signal?.aborted;
|
||||
state.request = request;
|
||||
requestQueryKey = query.key;
|
||||
const onAbort = () => request.abort(signal.reason);
|
||||
signal?.addEventListener?.('abort', onAbort, { once: true });
|
||||
const params = { confirmed: state.confirmed, taxa: state.taxa };
|
||||
state.loading = true;
|
||||
if (!state.pendingParams) {
|
||||
const drawn = state.drawnParams || { confirmed: false, taxa: null };
|
||||
const keys = ['confirmed', 'taxa'].filter(
|
||||
(key) => params[key] !== drawn[key],
|
||||
);
|
||||
if (keys.length) state.pendingParams = { ...params, keys };
|
||||
}
|
||||
parts.controls.notify();
|
||||
let received = false;
|
||||
let gridPending = false;
|
||||
let acceptedSnapshot = null;
|
||||
let gridRequest = null;
|
||||
let gridResult = null;
|
||||
const tileSnapshots = new Map();
|
||||
const gridParams = state.gridParams || state.drawnParams;
|
||||
const retainedGrid =
|
||||
params.taxa === gridParams?.taxa &&
|
||||
params.confirmed === gridParams?.confirmed
|
||||
? state.gridCells || []
|
||||
: [];
|
||||
function aggregateTiles(complete = false) {
|
||||
if (!tileSnapshots.size) return undefined;
|
||||
const grid = combineWildlifeGridSnapshots([...tileSnapshots.values()]);
|
||||
if (!complete && retainedGrid.length) {
|
||||
const zoom = query.tiles[0]?.z;
|
||||
const covered = new Set(tileSnapshots.keys());
|
||||
const retained = retainedGrid.filter(
|
||||
(cell) =>
|
||||
!covered.has(
|
||||
wildlifeGridCellId(cell.lat, cell.lon, zoom)?.split(':')[1],
|
||||
),
|
||||
);
|
||||
return combineWildlifeGridSnapshots([
|
||||
{ ...grid, cells: [...retained, ...grid.cells] },
|
||||
]);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
function completedGrid(value) {
|
||||
if (!value) return value;
|
||||
const aggregate = aggregateTiles(!value.error);
|
||||
return aggregate
|
||||
? combineWildlifeGridSnapshots([aggregate, value], value.error)
|
||||
: value;
|
||||
}
|
||||
function publish(grid, gridError = null, replaceRows = false) {
|
||||
applyAccepted({
|
||||
snapshot: acceptedSnapshot,
|
||||
grid,
|
||||
gridError,
|
||||
replaceRows,
|
||||
query,
|
||||
params,
|
||||
request,
|
||||
signal,
|
||||
epoch: requestLifetime,
|
||||
previousFailures,
|
||||
});
|
||||
}
|
||||
function finishTiles() {
|
||||
signal?.removeEventListener?.('abort', onAbort);
|
||||
if (state.request === request) {
|
||||
state.request = null;
|
||||
requestQueryKey = null;
|
||||
state.loading = false;
|
||||
if (request.signal.aborted && state.cameraTimer == null)
|
||||
clearPending();
|
||||
parts.controls.notify();
|
||||
}
|
||||
}
|
||||
function continueTiles() {
|
||||
if (!gridRequest || gridResult) return;
|
||||
gridPending = true;
|
||||
void gridRequest
|
||||
.then((result) => {
|
||||
if (
|
||||
acceptedSnapshot &&
|
||||
!request.signal.aborted &&
|
||||
state.request === request &&
|
||||
state.enabled
|
||||
)
|
||||
publish(result?.value, result?.error || result?.value?.error);
|
||||
})
|
||||
.finally(finishTiles);
|
||||
}
|
||||
try {
|
||||
const options = {
|
||||
signal: request.signal,
|
||||
boxes: query.boxes,
|
||||
box: query.boxes?.[0] || null,
|
||||
scope: query.scope,
|
||||
...params,
|
||||
};
|
||||
// Observation requests enter the shared proxy queue first. A slow tile
|
||||
// never holds the manager's initial enable or the first observations.
|
||||
const observationRequest =
|
||||
typeof services.source.getSnapshotDetail === 'function'
|
||||
? services.source.getSnapshotDetail(options)
|
||||
: Promise.resolve(services.source.getSnapshot(options)).then(
|
||||
(rows) => ({ rows, stale: false, fetchedAt: services.now() }),
|
||||
);
|
||||
const hasGrid =
|
||||
typeof services.source.getGridSnapshot === 'function' &&
|
||||
query.tiles.length;
|
||||
gridRequest = hasGrid
|
||||
? services.source
|
||||
.getGridSnapshot({
|
||||
signal: request.signal,
|
||||
tiles: query.tiles,
|
||||
...params,
|
||||
onTile(value, { tile }) {
|
||||
if (request.signal.aborted || state.request !== request)
|
||||
return;
|
||||
tileSnapshots.set(`${tile.z}/${tile.x}/${tile.y}`, value);
|
||||
publish(aggregateTiles());
|
||||
},
|
||||
})
|
||||
.then(
|
||||
(value) => {
|
||||
gridResult = { value: completedGrid(value) };
|
||||
if (!acceptedSnapshot)
|
||||
publish(gridResult.value, value?.error);
|
||||
return gridResult;
|
||||
},
|
||||
(error) => (gridResult = { error }),
|
||||
)
|
||||
: null;
|
||||
const snapshot = await observationRequest;
|
||||
if (
|
||||
request.signal.aborted ||
|
||||
state.request !== request ||
|
||||
!state.enabled
|
||||
)
|
||||
return supersededResult();
|
||||
if (!Array.isArray(snapshot?.rows))
|
||||
throw new Error('Malformed iNaturalist response');
|
||||
received = true;
|
||||
acceptedSnapshot = snapshot;
|
||||
publish(
|
||||
gridResult?.value || aggregateTiles(),
|
||||
gridResult?.error || gridResult?.value?.error,
|
||||
true,
|
||||
);
|
||||
if (hasGrid && !gridResult) {
|
||||
continueTiles();
|
||||
} else if (gridResult && !gridResult.value && gridResult.error) {
|
||||
// Observation rows remain visible when the first tile set fails.
|
||||
publish(undefined, gridResult.error);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
request.signal.aborted ||
|
||||
state.request !== request ||
|
||||
!state.enabled
|
||||
)
|
||||
return supersededResult();
|
||||
if (received) throw error;
|
||||
// Observation failures must not cancel independent density tiles.
|
||||
continueTiles();
|
||||
state.stale = false;
|
||||
state.error =
|
||||
preferredWildlifeFailure(error, previousFailures)?.message ||
|
||||
msg('Wildlife sightings source unavailable');
|
||||
failParams(params);
|
||||
state.lastQueryKey = null;
|
||||
requestQueryKey = null;
|
||||
applyRetry(error, null, previousFailures);
|
||||
console.warn('[Data:WildlifeSightings] Update failed:', state.error);
|
||||
return true;
|
||||
} finally {
|
||||
if (!gridPending) finishTiles();
|
||||
}
|
||||
},
|
||||
/** Stop acquisition and release visible resources while retaining filters. */
|
||||
disable,
|
||||
/** Release requests, subscriptions, caches and scene resources. */
|
||||
destroy() {
|
||||
if (state.destroyed) return;
|
||||
disable();
|
||||
state.destroyed = true;
|
||||
parts.selection.destroy();
|
||||
parts.photos.destroy();
|
||||
parts.rendering.destroy();
|
||||
state.rows.clear();
|
||||
state.lastUpdate = null;
|
||||
state.error = null;
|
||||
state.stale = false;
|
||||
state.drawnParams = null;
|
||||
state.gridParams = null;
|
||||
state.pendingParams = null;
|
||||
state.failedParams = null;
|
||||
state.controlsListener = null;
|
||||
state.manager = null;
|
||||
state.viewer = null;
|
||||
},
|
||||
};
|
||||
return {
|
||||
methods,
|
||||
scheduleRefresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
WILDLIFE_HEIGHT_BANDS,
|
||||
WILDLIFE_HEIGHT_HYSTERESIS,
|
||||
WILDLIFE_RENDER_LIMIT,
|
||||
WILDLIFE_VIEW_PADDING,
|
||||
} from './policy.js';
|
||||
import { WILDLIFE_GRID_TARGET_SPACING_PX } from './gridViewport.js';
|
||||
import { drainWildlifeWork, WILDLIFE_WORK_UNIT } from './performance.js';
|
||||
|
||||
const BAND_ORDER = Object.freeze(['cards', 'cells', 'regional', 'global']);
|
||||
const BAND_THRESHOLDS = Object.freeze([
|
||||
WILDLIFE_HEIGHT_BANDS.cards,
|
||||
WILDLIFE_HEIGHT_BANDS.cells,
|
||||
WILDLIFE_HEIGHT_BANDS.global,
|
||||
]);
|
||||
|
||||
/** A previous band is retained until a boundary is crossed by ten percent. */
|
||||
export function getWildlifeHeightBand(heightM, previous) {
|
||||
const height = Number.isFinite(heightM) ? Math.max(0, heightM) : 0;
|
||||
let index = BAND_ORDER.indexOf(previous);
|
||||
if (index < 0)
|
||||
return BAND_ORDER[
|
||||
BAND_THRESHOLDS.filter((boundary) => height > boundary).length
|
||||
];
|
||||
while (
|
||||
index < BAND_ORDER.length - 1 &&
|
||||
height > BAND_THRESHOLDS[index] * (1 + WILDLIFE_HEIGHT_HYSTERESIS)
|
||||
)
|
||||
index += 1;
|
||||
while (
|
||||
index > 0 &&
|
||||
height < BAND_THRESHOLDS[index - 1] * (1 - WILDLIFE_HEIGHT_HYSTERESIS)
|
||||
)
|
||||
index -= 1;
|
||||
return BAND_ORDER[index];
|
||||
}
|
||||
|
||||
/** Share the legend's animal grouping and unknown fallback with sprites. */
|
||||
export function wildlifeIconicGroup(iconic) {
|
||||
if (iconic === 'Mollusca' || iconic === 'Animalia') return 'Animalia';
|
||||
return [
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'Reptilia',
|
||||
'Amphibia',
|
||||
'Actinopterygii',
|
||||
'Insecta',
|
||||
'Arachnida',
|
||||
'Plantae',
|
||||
'Fungi',
|
||||
].includes(iconic)
|
||||
? iconic
|
||||
: 'Unknown';
|
||||
}
|
||||
|
||||
function validIconPosition(row) {
|
||||
return (
|
||||
Number.isFinite(row?.lat) &&
|
||||
Math.abs(row.lat) <= 90 &&
|
||||
Number.isFinite(row?.lon) &&
|
||||
Math.abs(row.lon) <= 180
|
||||
);
|
||||
}
|
||||
|
||||
/** Compare provider IDs consistently across locales. */
|
||||
export function compareWildlifeIds(a, b) {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Prefer a selected/identified recent contact when a population exceeds its
|
||||
* cap. Density is never converted into a larger billboard. */
|
||||
export function wildlifeIconRelevance(row, selectedId = null) {
|
||||
return (
|
||||
(row.stableId === selectedId ? 1e15 : 0) +
|
||||
(row.commonName ? 1e13 : 0) +
|
||||
(row.quality === 'research' ? 1e12 : 0) +
|
||||
(Number.isFinite(row.observedAt) ? row.observedAt / 1000 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Real observations alone compete for 28px screen buckets. Density is an
|
||||
* independent visual and can never supply or suppress a sighting's identity.
|
||||
* A selected observation keeps its slot even outside the new camera view. */
|
||||
export function buildWildlifeIconModel(options = {}) {
|
||||
return drainWildlifeWork(wildlifeIconModelWork(options));
|
||||
}
|
||||
|
||||
/** Yield bounded units while selecting real observations for screen buckets. */
|
||||
export function* wildlifeIconModelWork({
|
||||
rows = [],
|
||||
boxes = null,
|
||||
selectedId = null,
|
||||
selectedRow = null,
|
||||
preferredIds = [],
|
||||
limit = WILDLIFE_RENDER_LIMIT,
|
||||
projectRow = null,
|
||||
onOperations = null,
|
||||
} = {}) {
|
||||
const operations = {
|
||||
observationVisits: 0,
|
||||
projections: 0,
|
||||
rankComparisons: 0,
|
||||
};
|
||||
const preferred = new Set(preferredIds);
|
||||
const seen = new Set();
|
||||
const buckets = new Map();
|
||||
let sightingCount = 0;
|
||||
let acartiaCount = 0;
|
||||
const score = (row) =>
|
||||
wildlifeIconRelevance(row, selectedId) +
|
||||
(preferred.has(row.stableId) ? 1e14 : 0);
|
||||
const candidates =
|
||||
selectedRow && !rows.includes(selectedRow) ? [...rows, selectedRow] : rows;
|
||||
yield 'bucket';
|
||||
for (const row of candidates) {
|
||||
if (
|
||||
operations.observationVisits &&
|
||||
operations.observationVisits % WILDLIFE_WORK_UNIT === 0
|
||||
)
|
||||
yield 'bucket';
|
||||
operations.observationVisits++;
|
||||
if (
|
||||
typeof row?.stableId !== 'string' ||
|
||||
!row.stableId.length ||
|
||||
!validIconPosition(row) ||
|
||||
seen.has(row.stableId)
|
||||
)
|
||||
continue;
|
||||
seen.add(row.stableId);
|
||||
const selected = row.stableId === selectedId;
|
||||
const inView = rowInWildlifeBoxes(row, boxes, 0);
|
||||
if (!selected && !inView) continue;
|
||||
let key = row.stableId;
|
||||
let visible = inView;
|
||||
if (typeof projectRow === 'function') {
|
||||
operations.projections++;
|
||||
const screen = projectRow(row);
|
||||
visible &&=
|
||||
!!screen && Number.isFinite(screen.x) && Number.isFinite(screen.y);
|
||||
if (!visible && !selected) continue;
|
||||
if (visible) {
|
||||
const x = Math.floor(screen.x / WILDLIFE_GRID_TARGET_SPACING_PX);
|
||||
const y = Math.floor(screen.y / WILDLIFE_GRID_TARGET_SPACING_PX);
|
||||
key =
|
||||
Math.abs(x) < 1048576 && Math.abs(y) < 1048576
|
||||
? x * 2097152 + y
|
||||
: `${x}:${y}`;
|
||||
}
|
||||
}
|
||||
if (visible) {
|
||||
sightingCount++;
|
||||
if (row.origin === 'acartia') acartiaCount++;
|
||||
}
|
||||
const incumbent = buckets.get(key);
|
||||
if (
|
||||
!incumbent ||
|
||||
score(row) > score(incumbent) ||
|
||||
(score(row) === score(incumbent) && row.stableId < incumbent.stableId)
|
||||
)
|
||||
buckets.set(key, row);
|
||||
}
|
||||
yield 'rank';
|
||||
const population = [...buckets.values()];
|
||||
const cap = Math.max(selectedId ? 1 : 0, Math.floor(limit));
|
||||
const saturated = population.length > cap;
|
||||
if (saturated)
|
||||
population.sort((a, b) => {
|
||||
operations.rankComparisons++;
|
||||
return score(b) - score(a) || compareWildlifeIds(a.stableId, b.stableId);
|
||||
});
|
||||
onOperations?.(operations);
|
||||
return {
|
||||
rows: saturated ? population.slice(0, cap) : population,
|
||||
saturated,
|
||||
totalPoints: population.length,
|
||||
sightingCount,
|
||||
acartiaCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Test the padded view in degrees, including across the antimeridian. Null
|
||||
* boxes are the global sample, whose rows all remain candidates. */
|
||||
export function rowInWildlifeBoxes(
|
||||
row,
|
||||
boxes,
|
||||
padding = WILDLIFE_VIEW_PADDING,
|
||||
) {
|
||||
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) return false;
|
||||
if (!Array.isArray(boxes)) return true;
|
||||
for (const box of boxes) {
|
||||
const latPad = (box.north - box.south) * padding;
|
||||
const width =
|
||||
box.east >= box.west ? box.east - box.west : 360 - box.west + box.east;
|
||||
const lonPad = width * padding;
|
||||
if (
|
||||
row.lat < Math.max(-90, box.south - latPad) ||
|
||||
row.lat > Math.min(90, box.north + latPad)
|
||||
)
|
||||
continue;
|
||||
const relative = row.lon - box.west;
|
||||
if (relative >= -lonPad && relative <= width + lonPad) return true;
|
||||
if (relative + 360 >= -lonPad && relative + 360 <= width + lonPad)
|
||||
return true;
|
||||
if (relative - 360 >= -lonPad && relative - 360 <= width + lonPad)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import test from 'node:test';
|
||||
import { perfBudgetTestOptions } from '../../../scripts/run-perf-tests.mjs';
|
||||
import {
|
||||
harness,
|
||||
syntheticRows,
|
||||
changePositions,
|
||||
WILDLIFE_BENCHMARK_REFRESHES,
|
||||
} from '../../testSupport/wildlifeBillboardModel.mjs';
|
||||
|
||||
// Pure model and incremental reconciliation, measured after warmup.
|
||||
// The 2,000-contact allowance is a fraction of one 60Hz frame; the 5,000 cap
|
||||
// gets a proportional allowance. No renderer, network or real sightings run.
|
||||
const WILDLIFE_MODEL_UPDATE_BUDGET_MS = Object.freeze({
|
||||
500: 4,
|
||||
2000: 8,
|
||||
5000: 20,
|
||||
});
|
||||
test(
|
||||
'500 / 2,000 / 5,000 icon model updates stay within their named timing budgets',
|
||||
perfBudgetTestOptions(),
|
||||
(t) => {
|
||||
// Warm the same pure hot paths before recording CPU time.
|
||||
const warm = harness();
|
||||
let warmRows = syntheticRows(2000);
|
||||
warm.apply(warmRows);
|
||||
for (let i = 0; i < 25; i++) {
|
||||
warmRows = changePositions(warmRows, i);
|
||||
warm.apply(warmRows);
|
||||
}
|
||||
for (const n of [500, 2000, 5000]) {
|
||||
const h = harness();
|
||||
let rows = syntheticRows(n);
|
||||
const start = performance.now();
|
||||
const initial = h.apply(rows);
|
||||
const initialMs = performance.now() - start;
|
||||
assert.equal(initial.created, n);
|
||||
const durations = [];
|
||||
for (let refresh = 0; refresh < WILDLIFE_BENCHMARK_REFRESHES; refresh++) {
|
||||
rows = changePositions(rows, refresh);
|
||||
const before = performance.now();
|
||||
const update = h.apply(rows);
|
||||
durations.push(performance.now() - before);
|
||||
assert.deepEqual(update, {
|
||||
created: 0,
|
||||
removed: 0,
|
||||
updated: n / 10,
|
||||
writes: n / 10,
|
||||
});
|
||||
}
|
||||
const meanMs =
|
||||
durations.reduce((sum, value) => sum + value, 0) / durations.length;
|
||||
const maxMs = Math.max(...durations);
|
||||
assert.ok(
|
||||
maxMs <= WILDLIFE_MODEL_UPDATE_BUDGET_MS[n],
|
||||
`${n}: ${maxMs.toFixed(3)}ms update exceeds ${WILDLIFE_MODEL_UPDATE_BUDGET_MS[n]}ms`,
|
||||
);
|
||||
const before = { ...h.counters };
|
||||
assert.deepEqual(h.apply([...rows].reverse()), {
|
||||
created: 0,
|
||||
removed: 0,
|
||||
updated: 0,
|
||||
writes: 0,
|
||||
});
|
||||
assert.deepEqual(
|
||||
h.counters,
|
||||
before,
|
||||
'unchanged content performs zero billboard writes or allocations',
|
||||
);
|
||||
t.diagnostic(
|
||||
`N=${n}; initial=${initialMs.toFixed(3)}ms; incremental10% mean=${meanMs.toFixed(3)}ms, max=${maxMs.toFixed(3)}ms; budget=${WILDLIFE_MODEL_UPDATE_BUDGET_MS[n]}ms; unchanged writes=0`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,162 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
wildlifeIconicGroup,
|
||||
rowInWildlifeBoxes,
|
||||
buildWildlifeIconModel,
|
||||
} from './model.js';
|
||||
import { ICONIC_TINTS, ICONIC_LEGEND_ORDER, TAXA_PRESETS } from './policy.js';
|
||||
|
||||
const row = {
|
||||
stableId: 'inat:1',
|
||||
lat: 47.6,
|
||||
lon: -122.3,
|
||||
cell: null,
|
||||
iconic: 'Aves',
|
||||
quality: 'research',
|
||||
count: null,
|
||||
obscured: false,
|
||||
};
|
||||
|
||||
test('unknown groups are slate and molluscs fold into other animals', () => {
|
||||
assert.equal(wildlifeIconicGroup('Mollusca'), 'Animalia');
|
||||
for (const iconic of ['Unknown', 'Protozoa', 'Chromista', undefined])
|
||||
assert.equal(wildlifeIconicGroup(iconic), 'Unknown');
|
||||
assert.equal(ICONIC_TINTS.Unknown, '#9aa7b3');
|
||||
assert.equal(ICONIC_LEGEND_ORDER.at(-1), 'Unknown');
|
||||
assert.equal(ICONIC_LEGEND_ORDER.length, 11);
|
||||
assert.equal(TAXA_PRESETS.length, 6);
|
||||
assert.equal(TAXA_PRESETS.filter((item) => item.label === 'ALL').length, 1);
|
||||
assert.equal(Object.isFrozen(ICONIC_TINTS), true);
|
||||
assert.equal(Object.isFrozen(TAXA_PRESETS[0]), true);
|
||||
});
|
||||
|
||||
test('view filtering includes padding and both sides of the antimeridian', () => {
|
||||
const boxes = [{ south: 47, west: -123, north: 48, east: -122 }];
|
||||
assert.equal(rowInWildlifeBoxes(row, boxes), true);
|
||||
assert.equal(rowInWildlifeBoxes({ ...row, lat: 48.2 }, boxes), true);
|
||||
assert.equal(rowInWildlifeBoxes({ ...row, lat: 48.4 }, boxes), false);
|
||||
assert.equal(
|
||||
rowInWildlifeBoxes({ ...row, lat: 10.5, lon: -179.9 }, [
|
||||
{ south: 10, west: 179, north: 11, east: 180 },
|
||||
]),
|
||||
true,
|
||||
);
|
||||
assert.equal(rowInWildlifeBoxes(row, null), true);
|
||||
assert.equal(rowInWildlifeBoxes(row, []), false);
|
||||
});
|
||||
|
||||
test('grid cells never enter the observation icon model', () => {
|
||||
const cells = [
|
||||
{ id: 'grid:2/0/0:1:1', lat: 47.6, lon: -122.3, cellCount: 999 },
|
||||
];
|
||||
assert.deepEqual(buildWildlifeIconModel({ rows: cells }).rows, []);
|
||||
assert.deepEqual(buildWildlifeIconModel({ rows: [...cells, row] }).rows, [
|
||||
row,
|
||||
]);
|
||||
});
|
||||
|
||||
test('screen buckets retain incumbent photos at their real sighting positions', () => {
|
||||
const incumbent = {
|
||||
...row,
|
||||
commonName: 'Incumbent',
|
||||
photo: { square: 'synthetic.jpg' },
|
||||
};
|
||||
const challenger = {
|
||||
...row,
|
||||
stableId: 'inat:2',
|
||||
commonName: 'Challenger',
|
||||
observedAt: Date.UTC(2026, 10, 4),
|
||||
};
|
||||
const distant = { ...row, stableId: 'inat:3', lon: row.lon + 1 };
|
||||
const model = buildWildlifeIconModel({
|
||||
rows: [challenger, incumbent, distant],
|
||||
preferredIds: [incumbent.stableId],
|
||||
projectRow: (item) => ({ x: (item.lon - row.lon) * 28 + 14, y: 14 }),
|
||||
});
|
||||
assert.deepEqual(model.rows, [incumbent, distant]);
|
||||
assert.equal(model.sightingCount, 3);
|
||||
assert.equal(model.rows[0].photo, incumbent.photo);
|
||||
});
|
||||
|
||||
test('visible observation counts precede per-area and global caps', () => {
|
||||
const rows = Array.from({ length: 6000 }, (_, i) => ({
|
||||
...row,
|
||||
stableId: `inat:${i}`,
|
||||
lon: -160 + i * 0.01,
|
||||
}));
|
||||
let projections = 0;
|
||||
const model = buildWildlifeIconModel({
|
||||
rows,
|
||||
projectRow(item) {
|
||||
projections++;
|
||||
return { x: (item.lon + 160) * 5600 + 14, y: 14 };
|
||||
},
|
||||
});
|
||||
assert.equal(projections, 6000);
|
||||
assert.equal(model.rows.length, 5000);
|
||||
assert.equal(model.totalPoints, 6000);
|
||||
assert.equal(model.saturated, true);
|
||||
assert.equal(model.sightingCount, 6000);
|
||||
});
|
||||
|
||||
test('selected observations win their bucket and survive a sample, viewport and projection change', () => {
|
||||
const selected = { ...row, commonName: 'Selected sighting' };
|
||||
const challenger = {
|
||||
...row,
|
||||
stableId: 'inat:2',
|
||||
commonName: 'Newer',
|
||||
observedAt: Date.UTC(2026, 10, 4),
|
||||
};
|
||||
const input = {
|
||||
selectedId: row.stableId,
|
||||
selectedRow: selected,
|
||||
rows: [challenger],
|
||||
limit: 1,
|
||||
};
|
||||
assert.deepEqual(
|
||||
buildWildlifeIconModel({ ...input, projectRow: () => ({ x: 14, y: 14 }) })
|
||||
.rows,
|
||||
[selected],
|
||||
);
|
||||
const offscreen = buildWildlifeIconModel({
|
||||
...input,
|
||||
boxes: [{ south: 0, west: 0, north: 1, east: 1 }],
|
||||
projectRow: () => null,
|
||||
});
|
||||
assert.deepEqual(offscreen.rows, [selected]);
|
||||
assert.equal(
|
||||
offscreen.sightingCount,
|
||||
0,
|
||||
'an offscreen pin is not a visible observation',
|
||||
);
|
||||
});
|
||||
|
||||
test('observation reduction is stable under input order, selected priority and visible filtering', () => {
|
||||
const named = { ...row, stableId: 'inat:2', commonName: 'Known bird' };
|
||||
const outside = { ...row, stableId: 'inat:3', lat: 0 };
|
||||
const input = {
|
||||
boxes: [{ south: 47, west: -123, north: 48, east: -122 }],
|
||||
limit: 1,
|
||||
};
|
||||
const model = buildWildlifeIconModel({
|
||||
...input,
|
||||
rows: [row, named, outside],
|
||||
});
|
||||
assert.equal(model.saturated, true);
|
||||
assert.equal(model.totalPoints, 2);
|
||||
assert.equal(model.sightingCount, 2);
|
||||
assert.deepEqual(model.rows, [named]);
|
||||
assert.deepEqual(
|
||||
buildWildlifeIconModel({ ...input, rows: [outside, named, row] }),
|
||||
model,
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildWildlifeIconModel({
|
||||
...input,
|
||||
rows: [named, row],
|
||||
selectedId: row.stableId,
|
||||
}).rows,
|
||||
[row],
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,115 @@
|
||||
/** Camera work runs on moveEnd/data arrival only. Measures remain observable
|
||||
* through PerformanceObserver while clearing entries prevents a long session
|
||||
* accumulating one timing record for every pan and streamed tile. */
|
||||
export const WILDLIFE_MOVE_END_BUDGET_MS = 4;
|
||||
const measureSpans = new Map();
|
||||
|
||||
function measureSpan(stage) {
|
||||
let span = measureSpans.get(stage);
|
||||
if (!span) {
|
||||
span = { name: `wildlife-sightings:${stage}`, depth: 0, frames: [] };
|
||||
measureSpans.set(stage, span);
|
||||
}
|
||||
const depth = span.depth++;
|
||||
if (!span.frames[depth]) {
|
||||
const start = `${span.name}:start:${depth}`;
|
||||
span.frames[depth] = { start, options: { start } };
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
/** Measure a render phase without accumulating performance entries. */
|
||||
export function wildlifeMeasure(stage, work) {
|
||||
const clock = globalThis.performance;
|
||||
if (typeof clock?.mark !== 'function' || typeof clock?.measure !== 'function')
|
||||
return work();
|
||||
// Reuse names/options; nesting depth distinguishes simultaneous spans.
|
||||
const span = measureSpan(stage);
|
||||
const frame = span.frames[span.depth - 1];
|
||||
clock.mark(frame.start);
|
||||
try {
|
||||
return work();
|
||||
} finally {
|
||||
clock.measure(span.name, frame.options);
|
||||
clock.clearMarks?.(frame.start);
|
||||
clock.clearMeasures?.(span.name);
|
||||
span.depth--;
|
||||
}
|
||||
}
|
||||
|
||||
// A unit is at most 128 cells/billboards. The one-millisecond target leaves
|
||||
// room for the last unit, Cesium setters and the overlay host within 4ms.
|
||||
export const WILDLIFE_WORK_UNIT = 128;
|
||||
const WILDLIFE_WORK_SLICE_MS = 1;
|
||||
export const WILDLIFE_SYNC_CELL_LIMIT = 256;
|
||||
|
||||
function wildlifeWorkStep(work, stage = 'bucket') {
|
||||
return wildlifeMeasure(stage, () => work.next());
|
||||
}
|
||||
|
||||
/** Run bounded work synchronously for a small population. */
|
||||
export function drainWildlifeWork(work) {
|
||||
let stage = 'bucket';
|
||||
for (;;) {
|
||||
const step = wildlifeWorkStep(work, stage);
|
||||
if (step.done) return step.value;
|
||||
stage = step.value || stage;
|
||||
}
|
||||
}
|
||||
|
||||
/** A cancellable idle queue, never a frame listener. Superseding a camera view
|
||||
* drops unrun work. No network or requestAnimationFrame enters this queue. */
|
||||
export function createWildlifeWorkQueue({
|
||||
requestIdle = globalThis.requestIdleCallback?.bind(globalThis),
|
||||
cancelIdle = globalThis.cancelIdleCallback?.bind(globalThis),
|
||||
now = () => performance.now(),
|
||||
onSlice = () => {},
|
||||
} = {}) {
|
||||
let active = null;
|
||||
function cancel() {
|
||||
if (!active) return;
|
||||
const old = active;
|
||||
active = null;
|
||||
if (requestIdle) cancelIdle?.(old.handle);
|
||||
else clearTimeout(old.handle);
|
||||
old.work.return?.();
|
||||
}
|
||||
function start(work, complete) {
|
||||
cancel();
|
||||
const job = { work, handle: null, stage: 'bucket' };
|
||||
active = job;
|
||||
function schedule() {
|
||||
job.handle = requestIdle
|
||||
? requestIdle(run, { timeout: 32 })
|
||||
: setTimeout(run, 0);
|
||||
}
|
||||
function run() {
|
||||
if (active !== job) return;
|
||||
const began = now();
|
||||
let units = 0;
|
||||
let result;
|
||||
wildlifeMeasure('moveEnd:slice', () => {
|
||||
do {
|
||||
result = wildlifeWorkStep(work, job.stage);
|
||||
units++;
|
||||
if (result.done) {
|
||||
active = null;
|
||||
complete(result.value);
|
||||
break;
|
||||
}
|
||||
job.stage = result.value || job.stage;
|
||||
} while (units < 8 && now() - began < WILDLIFE_WORK_SLICE_MS);
|
||||
});
|
||||
onSlice({ startTimeMs: began, durationMs: now() - began, units });
|
||||
if (!result.done && active === job) schedule();
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
return {
|
||||
start,
|
||||
cancel,
|
||||
get pending() {
|
||||
return active !== null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
harness,
|
||||
syntheticRows,
|
||||
changePositions,
|
||||
WILDLIFE_BENCHMARK_REFRESHES,
|
||||
} from '../../testSupport/wildlifeBillboardModel.mjs';
|
||||
|
||||
test('500 / 2,000 / 5,000 contacts write only position deltas and do no work for unchanged input', () => {
|
||||
for (const n of [500, 2000, 5000]) {
|
||||
const h = harness();
|
||||
let rows = syntheticRows(n);
|
||||
assert.equal(h.apply(rows).created, n);
|
||||
const retained = h.pointById.get(rows[1].stableId);
|
||||
for (let refresh = 0; refresh < WILDLIFE_BENCHMARK_REFRESHES; refresh++) {
|
||||
rows = changePositions(rows, refresh);
|
||||
assert.deepEqual(h.apply(rows), {
|
||||
created: 0,
|
||||
removed: 0,
|
||||
updated: n / 10,
|
||||
writes: n / 10,
|
||||
});
|
||||
assert.equal(h.pointById.get(rows[1].stableId), retained);
|
||||
}
|
||||
assert.equal(h.counters.created, n);
|
||||
assert.equal(h.counters.destroyed, 0);
|
||||
const before = { ...h.counters };
|
||||
assert.deepEqual(h.apply([...rows].reverse()), {
|
||||
created: 0,
|
||||
removed: 0,
|
||||
updated: 0,
|
||||
writes: 0,
|
||||
});
|
||||
assert.deepEqual(
|
||||
h.counters,
|
||||
before,
|
||||
'unchanged content writes and allocates nothing',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('ten 10% membership refreshes allocate and remove exactly their delta', (t) => {
|
||||
const n = 2000;
|
||||
const h = harness();
|
||||
let rows = syntheticRows(n);
|
||||
h.apply(rows);
|
||||
const retainedId = rows[1].stableId;
|
||||
const retained = h.pointById.get(retainedId);
|
||||
for (let refresh = 0; refresh < WILDLIFE_BENCHMARK_REFRESHES; refresh++) {
|
||||
rows = rows.map((row, i) =>
|
||||
i % 10 === 0 ? { ...row, stableId: `replacement:${refresh}:${i}` } : row,
|
||||
);
|
||||
assert.deepEqual(h.apply(rows), {
|
||||
created: n / 10,
|
||||
removed: n / 10,
|
||||
updated: 0,
|
||||
writes: (n / 10) * 5,
|
||||
});
|
||||
assert.equal(h.pointById.get(retainedId), retained);
|
||||
assert.equal(h.points.size, n);
|
||||
}
|
||||
assert.equal(h.counters.created, n + (n / 10) * WILDLIFE_BENCHMARK_REFRESHES);
|
||||
assert.equal(h.counters.destroyed, (n / 10) * WILDLIFE_BENCHMARK_REFRESHES);
|
||||
t.diagnostic(
|
||||
`N=${n}, 10 refreshes: initial creates=${n}, delta creates=2000, delta removes=2000, retained billboard identity unchanged`,
|
||||
);
|
||||
});
|
||||
|
||||
test('count, grade and name edits produce no billboard writes or resampling', () => {
|
||||
const h = harness();
|
||||
const rows = syntheticRows(500);
|
||||
h.model.update(rows);
|
||||
const before = { ...h.counters };
|
||||
assert.deepEqual(
|
||||
h.model.update(
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
count: 1000,
|
||||
quality: 'needs_id',
|
||||
commonName: 'Synthetic name',
|
||||
})),
|
||||
),
|
||||
{ created: 0, removed: 0, updated: 0, writes: 0 },
|
||||
);
|
||||
assert.deepEqual(h.counters, before);
|
||||
});
|
||||
|
||||
import { PerformanceObserver } from 'node:perf_hooks';
|
||||
import {
|
||||
denseContinentalRendering,
|
||||
DENSE_CONTINENT_CELLS,
|
||||
DENSE_CONTINENT_ROWS,
|
||||
} from './renderPerformanceFixtures.mjs';
|
||||
import {
|
||||
WILDLIFE_MOVE_END_BUDGET_MS,
|
||||
WILDLIFE_WORK_UNIT,
|
||||
createWildlifeWorkQueue,
|
||||
} from './performance.js';
|
||||
|
||||
test('dense continental moveEnd has a deterministic linear operation budget and reuses decoded metadata/coordinates', () => {
|
||||
const h = denseContinentalRendering();
|
||||
try {
|
||||
assert.equal(
|
||||
h.moveEnd(),
|
||||
'pending',
|
||||
'dense work must yield before blocking camera input',
|
||||
);
|
||||
assert.equal(
|
||||
h.metrics.projections,
|
||||
0,
|
||||
'moveEnd enqueues work without processing a tile',
|
||||
);
|
||||
const cold = h.flush();
|
||||
assert.ok(
|
||||
cold.glowPrimitives > 4000 &&
|
||||
cold.billboards > 50 &&
|
||||
cold.billboards <= DENSE_CONTINENT_ROWS,
|
||||
'benchmark exercises a dense glow with only real sampled sighting badges',
|
||||
);
|
||||
assert.equal(
|
||||
h.state.renderPipelineOperations.densityVisits,
|
||||
DENSE_CONTINENT_CELLS,
|
||||
);
|
||||
assert.equal(
|
||||
h.state.renderPipelineOperations.densityMetadataBuilds,
|
||||
DENSE_CONTINENT_CELLS,
|
||||
);
|
||||
assert.ok(cold.slices.length > 1);
|
||||
for (let move = 1; move <= 4; move++) {
|
||||
h.moveEnd(move * 3);
|
||||
const result = h.flush();
|
||||
const ops = h.state.renderPipelineOperations;
|
||||
assert.equal(ops.densityVisits, DENSE_CONTINENT_CELLS);
|
||||
assert.equal(ops.observationVisits, DENSE_CONTINENT_ROWS);
|
||||
assert.equal(ops.densityMetadataBuilds, 0);
|
||||
assert.equal(ops.densityWrites, 0);
|
||||
assert.equal(ops.densityPositionBuilds, 0);
|
||||
assert.ok(ops.densityRetirementVisits <= DENSE_CONTINENT_CELLS);
|
||||
assert.equal(ops.densityCreated, 0);
|
||||
assert.equal(ops.densityRemoved, 0);
|
||||
assert.equal(
|
||||
result.cartesianBuilds,
|
||||
0,
|
||||
'no repeated longitude/latitude trig per camera pass',
|
||||
);
|
||||
assert.ok(ops.projections <= DENSE_CONTINENT_ROWS);
|
||||
assert.ok(result.projections <= DENSE_CONTINENT_ROWS + 150);
|
||||
assert.ok(
|
||||
ops.billboardVisits <= result.billboards * 3 + WILDLIFE_WORK_UNIT,
|
||||
);
|
||||
assert.equal(
|
||||
ops.rankComparisons,
|
||||
0,
|
||||
'continental buckets never sort the source population',
|
||||
);
|
||||
assert.ok(ops.spacingTests <= result.billboards * 9);
|
||||
assert.ok(result.slices.length <= 144);
|
||||
assert.ok(result.slices.every((slice) => slice.units <= 8));
|
||||
assert.equal(
|
||||
result.publications,
|
||||
3,
|
||||
'publish overlays once after the accepted view is ready',
|
||||
);
|
||||
}
|
||||
h.moveEnd(12);
|
||||
h.flush();
|
||||
assert.equal(h.state.renderPipelineOperations.billboardWrites, 0);
|
||||
assert.equal(
|
||||
h.state.renderPipelineOperations.billboardVisits,
|
||||
h.state.renderedRows.length * 2,
|
||||
'the delta owner scans each row and retained billboard once',
|
||||
);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('superseded dense camera work is dropped and idle callbacks are removed at teardown', () => {
|
||||
const h = denseContinentalRendering();
|
||||
assert.equal(h.moveEnd(0), 'pending');
|
||||
assert.equal(h.moveEnd(12), 'pending');
|
||||
const result = h.flush();
|
||||
assert.equal(
|
||||
h.state.renderPipelineOperations.densityVisits,
|
||||
DENSE_CONTINENT_CELLS,
|
||||
);
|
||||
assert.equal(result.publications, 3);
|
||||
assert.equal(h.moveEnd(24), 'pending');
|
||||
h.destroy();
|
||||
assert.equal(h.flush().slices.length, 0);
|
||||
});
|
||||
|
||||
test('performance marks cover each render phase and leave no accumulated entries', async () => {
|
||||
const observed = [];
|
||||
const observer = new PerformanceObserver((list) =>
|
||||
observed.push(...list.getEntries()),
|
||||
);
|
||||
observer.observe({ entryTypes: ['measure'] });
|
||||
const h = denseContinentalRendering();
|
||||
try {
|
||||
h.moveEnd();
|
||||
h.flush();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
for (const phase of [
|
||||
'density',
|
||||
'bucket',
|
||||
'rank',
|
||||
'billboard-update',
|
||||
'overlay-publish',
|
||||
'moveEnd:slice',
|
||||
])
|
||||
assert.ok(
|
||||
observed.some((entry) => entry.name === `wildlife-sightings:${phase}`),
|
||||
phase,
|
||||
);
|
||||
assert.deepEqual(
|
||||
performance
|
||||
.getEntriesByType('mark')
|
||||
.filter((entry) => entry.name.startsWith('wildlife-sightings:')),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
performance
|
||||
.getEntriesByType('measure')
|
||||
.filter((entry) => entry.name.startsWith('wildlife-sightings:')),
|
||||
[],
|
||||
);
|
||||
assert.equal(WILDLIFE_MOVE_END_BUDGET_MS, 4);
|
||||
} finally {
|
||||
observer.disconnect();
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('idle work has a fixed unit ceiling even when the clock does not advance', () => {
|
||||
const callbacks = new Map();
|
||||
let id = 0;
|
||||
let slices = 0;
|
||||
let done = false;
|
||||
const queue = createWildlifeWorkQueue({
|
||||
requestIdle(fn) {
|
||||
callbacks.set(++id, fn);
|
||||
return id;
|
||||
},
|
||||
cancelIdle(handle) {
|
||||
callbacks.delete(handle);
|
||||
},
|
||||
now: () => 0,
|
||||
onSlice({ units }) {
|
||||
assert.ok(units <= 8);
|
||||
slices++;
|
||||
},
|
||||
});
|
||||
function* work() {
|
||||
for (let i = 0; i < 20; i++) yield 'bucket';
|
||||
return 42;
|
||||
}
|
||||
queue.start(work(), (result) => {
|
||||
assert.equal(result, 42);
|
||||
done = true;
|
||||
});
|
||||
assert.equal(done, false);
|
||||
while (callbacks.size) {
|
||||
const [handle, fn] = callbacks.entries().next().value;
|
||||
callbacks.delete(handle);
|
||||
fn();
|
||||
}
|
||||
assert.equal(done, true);
|
||||
assert.equal(slices, 3);
|
||||
assert.equal(queue.pending, false);
|
||||
});
|
||||
|
||||
test('returning to a previous camera after partially applying a billboard delta still reconciles the collection', (t) => {
|
||||
// Slices end after eight units or 1 ms. A clock that advances 2 ms per read
|
||||
// makes every slice one unit, so the half-applied delta is observable.
|
||||
let clock = 0;
|
||||
t.mock.method(performance, 'now', () => (clock += 2));
|
||||
const h = denseContinentalRendering();
|
||||
try {
|
||||
h.moveEnd(0);
|
||||
h.flush();
|
||||
const originalIds = [...h.state.pointById.keys()].sort();
|
||||
const membership = () => [...h.state.pointById.keys()].sort();
|
||||
h.moveEnd(12);
|
||||
// Advance just until real billboard mutations begin, then supersede the
|
||||
// partially applied view by returning to the original camera.
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
h.flush(1);
|
||||
if (JSON.stringify(membership()) !== JSON.stringify(originalIds)) break;
|
||||
}
|
||||
assert.notDeepEqual(membership(), originalIds);
|
||||
h.moveEnd(0);
|
||||
h.flush();
|
||||
assert.deepEqual(membership(), originalIds);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/** Compact card ages in translated minutes, hours or days. */
|
||||
export function formatAge(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
|
||||
const hours = Math.floor(deltaMs / 3_600_000);
|
||||
if (hours < 1)
|
||||
return t('{count}m', {
|
||||
context: 'age',
|
||||
count: Math.max(1, Math.floor(deltaMs / 60_000)),
|
||||
});
|
||||
if (hours < 24) return t('{count}h', { context: 'age', count: hours });
|
||||
return t('{count}d', { context: 'age', count: Math.floor(hours / 24) });
|
||||
}
|
||||
|
||||
/** Use the creator portion of the supplied attribution, never a guessed
|
||||
* observer/login. iNaturalist appends rights and licence text after it. */
|
||||
export function photoCreator(photo) {
|
||||
if (typeof photo?.attribution !== 'string') return null;
|
||||
const creator = photo.attribution
|
||||
.replace(/^\s*(?:\(c\)|©|copyright)\s*/i, '')
|
||||
.replace(/,\s*(?:some|no|all)\s+rights\s+reserved\b.*$/i, '')
|
||||
.replace(
|
||||
/(?:,\s*|\s*\()(?:CC[\s-]+BY(?:[\s-]+(?:NC|SA|ND))*|CC0|public\s+domain)(?:\s+\d(?:\.\d)?)?\)?\s*$/i,
|
||||
'',
|
||||
)
|
||||
.trim();
|
||||
if (
|
||||
!creator ||
|
||||
/^(?:(?:some|no|all)\s+rights\s+reserved\b|CC(?:0|[\s-]+BY)\b)/i.test(
|
||||
creator,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return creator;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { ambientScore } from './cards.js';
|
||||
import { compareWildlifeIds } from './model.js';
|
||||
import { WILDLIFE_PHOTO_FETCH, WILDLIFE_PHOTO_CACHE_MAX } from './policy.js';
|
||||
|
||||
/** Own decoded ambient images and their cancellable, ranked queue. */
|
||||
export function createPhotoSlots({
|
||||
imageFactory = () => new Image(),
|
||||
now = () => Date.now(),
|
||||
setTimer = (fn, delay) => setTimeout(fn, delay),
|
||||
clearTimer = (timer) => clearTimeout(timer),
|
||||
onChange = null,
|
||||
} = {}) {
|
||||
const slots = new Map();
|
||||
let queue = [];
|
||||
let timer = null;
|
||||
let destroyed = false;
|
||||
let liveKeys = new Set();
|
||||
let cohortKey = null;
|
||||
|
||||
function notify() {
|
||||
if (!destroyed) onChange?.();
|
||||
}
|
||||
|
||||
function release(slot) {
|
||||
slot.cancelled = true;
|
||||
if (!slot.image) return;
|
||||
slot.image.onload = null;
|
||||
slot.image.onerror = null;
|
||||
// Detaching listeners alone would leave pending network and decoded data.
|
||||
slot.image.src = '';
|
||||
}
|
||||
|
||||
function prune() {
|
||||
if (slots.size <= WILDLIFE_PHOTO_CACHE_MAX) return;
|
||||
const ranked = [...slots].sort(([a, slotA], [b, slotB]) => {
|
||||
const keepA = liveKeys.has(a);
|
||||
const keepB = liveKeys.has(b);
|
||||
const frameA = slotA.displayed;
|
||||
const frameB = slotB.displayed;
|
||||
// Decoded photographs that have actually been painted outrank unseen
|
||||
// prefetches. Preserve their Image objects for a flash-free return pan.
|
||||
return Number(keepA) - Number(keepB) || Number(frameA) - Number(frameB);
|
||||
});
|
||||
for (const [key, slot] of ranked) {
|
||||
if (slots.size <= WILDLIFE_PHOTO_CACHE_MAX) break;
|
||||
release(slot);
|
||||
slots.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function ensure(row) {
|
||||
const url = row?.photo?.square;
|
||||
if (!url) return null;
|
||||
const key = row.stableId;
|
||||
let slot = slots.get(key);
|
||||
if (slot && slot.url !== url) {
|
||||
release(slot);
|
||||
slots.delete(key);
|
||||
slot = null;
|
||||
}
|
||||
if (!slot) {
|
||||
slot = {
|
||||
key,
|
||||
url,
|
||||
image: null,
|
||||
state: 'queued',
|
||||
cancelled: false,
|
||||
displayed: false,
|
||||
};
|
||||
} else slots.delete(key);
|
||||
slots.set(key, slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
function launch(slot) {
|
||||
if (
|
||||
destroyed ||
|
||||
slot.cancelled ||
|
||||
slots.get(slot.key) !== slot ||
|
||||
slot.state !== 'queued'
|
||||
)
|
||||
return;
|
||||
slot.state = 'loading';
|
||||
let image;
|
||||
try {
|
||||
image = imageFactory();
|
||||
slot.image = image;
|
||||
// Deliberately non-CORS: the overlay canvas is display-only and is
|
||||
// never read back. Viewport capture reads the separate Cesium canvas.
|
||||
image.onload = () => {
|
||||
if (destroyed || slot.cancelled || slots.get(slot.key) !== slot) return;
|
||||
slot.state = 'ready';
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
notify();
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (destroyed || slot.cancelled || slots.get(slot.key) !== slot) return;
|
||||
slot.state = 'error';
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
notify();
|
||||
};
|
||||
image.src = slot.url;
|
||||
} catch {
|
||||
slot.state = 'error';
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
function pace() {
|
||||
timer = null;
|
||||
if (destroyed) return;
|
||||
while (queue.length) {
|
||||
const slot = queue.shift();
|
||||
if (slot.cancelled || slots.get(slot.key) !== slot) continue;
|
||||
launch(slot);
|
||||
break;
|
||||
}
|
||||
if (queue.length) timer = setTimer(pace, WILDLIFE_PHOTO_FETCH.intervalMs);
|
||||
}
|
||||
|
||||
function setAmbient(rows) {
|
||||
if (destroyed) return;
|
||||
const ranked = [...rows].sort(
|
||||
(a, b) =>
|
||||
ambientScore(b, now()) - ambientScore(a, now()) ||
|
||||
compareWildlifeIds(a.stableId, b.stableId),
|
||||
);
|
||||
const nextKey = JSON.stringify(
|
||||
ranked.map((row) => [row.stableId, row.photo?.square]),
|
||||
);
|
||||
// A same-cohort refresh must not turn the paced tail into another burst.
|
||||
if (cohortKey === nextKey) return;
|
||||
cohortKey = nextKey;
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
liveKeys = new Set(ranked.map((row) => row.stableId));
|
||||
queue = ranked.map(ensure).filter(Boolean);
|
||||
prune();
|
||||
queue = queue.filter(
|
||||
(slot) => slots.get(slot.key) === slot && slot.state === 'queued',
|
||||
);
|
||||
for (
|
||||
let index = 0;
|
||||
index < WILDLIFE_PHOTO_FETCH.burst && queue.length;
|
||||
index++
|
||||
)
|
||||
launch(queue.shift());
|
||||
if (queue.length) timer = setTimer(pace, WILDLIFE_PHOTO_FETCH.intervalMs);
|
||||
}
|
||||
|
||||
function get(row) {
|
||||
const key = row?.stableId;
|
||||
const slot = slots.get(key);
|
||||
if (!slot || slot.state !== 'ready') return null;
|
||||
slot.displayed = true;
|
||||
slots.delete(key);
|
||||
slots.set(key, slot);
|
||||
return slot.image;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
queue = [];
|
||||
for (const slot of slots.values()) release(slot);
|
||||
slots.clear();
|
||||
liveKeys.clear();
|
||||
cohortKey = null;
|
||||
}
|
||||
|
||||
return {
|
||||
setAmbient,
|
||||
get,
|
||||
clear,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
clear();
|
||||
},
|
||||
diagnostics: () => ({ slots: slots.size, queued: queue.length }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPhotoSlots } from './photos.js';
|
||||
|
||||
function clock() {
|
||||
let time = 0;
|
||||
let next = 0;
|
||||
const timers = new Map();
|
||||
return {
|
||||
now: () => time,
|
||||
setTimer(fn, delay) {
|
||||
const id = ++next;
|
||||
timers.set(id, { fn, at: time + delay });
|
||||
return id;
|
||||
},
|
||||
clearTimer(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
tick(ms) {
|
||||
const until = time + ms;
|
||||
while (true) {
|
||||
const pending = [...timers].sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!pending || pending[1].at > until) break;
|
||||
time = pending[1].at;
|
||||
timers.delete(pending[0]);
|
||||
pending[1].fn();
|
||||
}
|
||||
time = until;
|
||||
},
|
||||
count: () => timers.size,
|
||||
};
|
||||
}
|
||||
|
||||
const row = (index) => ({
|
||||
stableId: `inat:${index}`,
|
||||
quality: 'research',
|
||||
observedAt: 0,
|
||||
favesCount: 100 - index,
|
||||
identificationAgreements: 0,
|
||||
photo: {
|
||||
square: `https://inaturalist-open-data.s3.amazonaws.com/photos/${index}/square.jpg`,
|
||||
medium: `https://inaturalist-open-data.s3.amazonaws.com/photos/${index}/medium.jpg`,
|
||||
licence: 'cc-by',
|
||||
attribution: '(c) Synthetic Photographer',
|
||||
},
|
||||
});
|
||||
|
||||
function harness() {
|
||||
const time = clock();
|
||||
const launches = [];
|
||||
const images = [];
|
||||
let changes = 0;
|
||||
const slots = createPhotoSlots({
|
||||
...time,
|
||||
onChange: () => changes++,
|
||||
imageFactory: () => {
|
||||
const image = {
|
||||
set src(url) {
|
||||
this.url = url;
|
||||
if (url) launches.push({ at: time.now(), url });
|
||||
},
|
||||
};
|
||||
images.push(image);
|
||||
return image;
|
||||
},
|
||||
});
|
||||
return { slots, time, images, launches, changes: () => changes };
|
||||
}
|
||||
|
||||
test('24 photos start in rank order: eight immediately, then one per 250 ms', () => {
|
||||
const h = harness();
|
||||
const rows = Array.from({ length: 24 }, (_, index) => row(index + 1));
|
||||
h.slots.setAmbient([...rows].reverse());
|
||||
assert.equal(h.launches.length, 8);
|
||||
h.time.tick(249);
|
||||
assert.equal(h.launches.length, 8);
|
||||
h.time.tick(1);
|
||||
assert.equal(h.launches.length, 9);
|
||||
h.time.tick(3750);
|
||||
assert.equal(h.launches.length, 24);
|
||||
assert.deepEqual(
|
||||
h.launches.map(({ url }) => Number(url.match(/photos\/(\d+)/)[1])),
|
||||
rows.map((_, index) => index + 1),
|
||||
);
|
||||
assert.deepEqual(
|
||||
h.launches.slice(8).map(({ at }) => at),
|
||||
Array.from({ length: 16 }, (_, i) => (i + 1) * 250),
|
||||
);
|
||||
h.slots.destroy();
|
||||
});
|
||||
|
||||
test('same-cohort refresh keeps the ambient queue pacing without restarting its burst', () => {
|
||||
const h = harness();
|
||||
const rows = Array.from({ length: 24 }, (_, i) => row(i + 1));
|
||||
h.slots.setAmbient(rows);
|
||||
assert.equal(h.launches.length, 8);
|
||||
h.time.tick(100);
|
||||
h.slots.setAmbient(rows);
|
||||
assert.equal(h.launches.length, 8);
|
||||
h.time.tick(150);
|
||||
assert.equal(h.launches.length, 9);
|
||||
assert.ok(h.launches.every(({ url }) => /square.jpg$/.test(url)));
|
||||
h.slots.destroy();
|
||||
});
|
||||
|
||||
test('images settle through slots, are pruned to 96, and late loads cannot publish after clear', () => {
|
||||
const h = harness();
|
||||
h.slots.setAmbient([row(1)]);
|
||||
const loaded = h.images[0];
|
||||
loaded.onload();
|
||||
assert.equal(h.slots.get(row(1)), loaded);
|
||||
assert.equal(h.changes(), 1);
|
||||
for (let index = 2; index <= 130; index++) h.slots.setAmbient([row(index)]);
|
||||
assert.equal(h.slots.diagnostics().slots, 96);
|
||||
const last = h.images.at(-1);
|
||||
const late = last.onload;
|
||||
h.slots.clear();
|
||||
late();
|
||||
assert.equal(h.changes(), 1);
|
||||
assert.equal(h.slots.diagnostics().slots, 0);
|
||||
assert.equal(h.time.count(), 0);
|
||||
assert.ok(h.images.every((image) => image.url === ''));
|
||||
});
|
||||
|
||||
test('returning cards reuse their decoded frame after a pan or height-band hide', () => {
|
||||
const h = harness();
|
||||
const first = row(1);
|
||||
h.slots.setAmbient([first]);
|
||||
const frame = h.images[0];
|
||||
frame.onload();
|
||||
assert.equal(h.slots.get(first), frame);
|
||||
h.slots.setAmbient([]);
|
||||
h.slots.setAmbient([row(2)]);
|
||||
h.images[1].onload();
|
||||
for (let index = 3; index < 105; index++) h.slots.setAmbient([row(index)]);
|
||||
h.slots.setAmbient([first]);
|
||||
assert.equal(
|
||||
h.slots.get(first),
|
||||
frame,
|
||||
'the returning image is already ready, with no empty frame',
|
||||
);
|
||||
assert.equal(
|
||||
h.launches.filter(({ url }) => url.includes('/photos/1/')).length,
|
||||
1,
|
||||
);
|
||||
h.slots.destroy();
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
|
||||
export const WILDLIFE_UNKNOWN_COLOR = '#9aa7b3';
|
||||
export const WILDLIFE_ACCENT = '#7bd88f';
|
||||
export const ICONIC_TINTS = Object.freeze({
|
||||
Aves: '#9fa8ff',
|
||||
Mammalia: '#e0a96d',
|
||||
Reptilia: '#c5d86d',
|
||||
Amphibia: '#5fd3bc',
|
||||
Actinopterygii: '#6fa8dc',
|
||||
Insecta: '#f0b35a',
|
||||
Arachnida: '#d98c6a',
|
||||
Plantae: WILDLIFE_ACCENT,
|
||||
Fungi: '#e8a0a8',
|
||||
Animalia: '#b0bec5',
|
||||
Unknown: WILDLIFE_UNKNOWN_COLOR,
|
||||
});
|
||||
|
||||
export const ICONIC_LEGEND_ORDER = Object.freeze([
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'Reptilia',
|
||||
'Amphibia',
|
||||
'Actinopterygii',
|
||||
'Insecta',
|
||||
'Arachnida',
|
||||
'Plantae',
|
||||
'Fungi',
|
||||
'Animalia',
|
||||
'Unknown',
|
||||
]);
|
||||
|
||||
// Keep badges compact among dense anchors, labels and photo cards.
|
||||
export const WILDLIFE_SCALE_BY_DISTANCE = Object.freeze([
|
||||
1_000, 1.1, 8_000_000, 0.6,
|
||||
]);
|
||||
export const WILDLIFE_SPRITE_PX = 20;
|
||||
export const WILDLIFE_BADGE_MAX_PX =
|
||||
WILDLIFE_SPRITE_PX * WILDLIFE_SCALE_BY_DISTANCE[1];
|
||||
|
||||
export const WILDLIFE_GRID_HEIGHT_METERS = 400_000;
|
||||
// Bound billboard population after screen bucketing.
|
||||
export const WILDLIFE_RENDER_LIMIT = 5_000;
|
||||
// Bound candidates before the overlay host's final declutter.
|
||||
export const WILDLIFE_LABEL_LIMIT = 150;
|
||||
export const WILDLIFE_LABEL_NAME_SEPARATION_PX = 80;
|
||||
// Hidden ground batches may need several render passes to compile. Retire a
|
||||
// failed staging batch after a bounded number instead of holding rendering.
|
||||
export const WILDLIFE_GROUND_READY_MAX_CHECKS = 16;
|
||||
|
||||
// Camera-height thresholds use +/-10% hysteresis to keep representations still
|
||||
// while a camera settles near a boundary.
|
||||
export const WILDLIFE_HEIGHT_HYSTERESIS = 0.1;
|
||||
export const WILDLIFE_HEIGHT_BANDS = Object.freeze({
|
||||
global: 3_000_000,
|
||||
cells: 1_500_000,
|
||||
cards: 400_000,
|
||||
});
|
||||
export const WILDLIFE_MAX_ROWS = 2_000;
|
||||
export const WILDLIFE_CARD_LIMIT = 24;
|
||||
export const WILDLIFE_CARD_MIN_SEP_PX = 112;
|
||||
export const WILDLIFE_CARD_REGION_COLUMNS = 4;
|
||||
export const WILDLIFE_CARD_REGION_ROWS = 3;
|
||||
export const WILDLIFE_PHOTO_CACHE_MAX = 96;
|
||||
|
||||
// Static S3 thumbnails do not spend the API quota.
|
||||
export const WILDLIFE_PHOTO_FETCH = Object.freeze({
|
||||
burst: 8,
|
||||
intervalMs: 250,
|
||||
});
|
||||
|
||||
export const WILDLIFE_DEBOUNCE_MS = 600;
|
||||
export { WILDLIFE_MAX_VIEW_DEGREES } from './bounds.js';
|
||||
export const WILDLIFE_VIEW_PADDING = 0.3;
|
||||
export const WILDLIFE_RETRY_MIN_MS = 30_000;
|
||||
export const WILDLIFE_RETRY_MAX_MS = 240_000;
|
||||
// Cap a Retry-After so one reply can never stall refresh forever. The proxy's
|
||||
// daily-budget reply waits until the next UTC midnight, so allow one day.
|
||||
export const WILDLIFE_RETRY_AFTER_MAX_SEC = 86_400;
|
||||
|
||||
// Faster polling would reread the proxy's five-minute cache.
|
||||
export const WILDLIFE_UPDATE_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
export const WILDLIFE_CARD_SCALE_FULL_METERS = 20_000;
|
||||
export const WILDLIFE_CARD_SCALE_MID_METERS = 120_000;
|
||||
export const WILDLIFE_CARD_SCALE_END_METERS = 125_000;
|
||||
// Separate entry and exit thresholds prevent churn during small zooms.
|
||||
export const WILDLIFE_CARD_SHOW_MAX_METERS = 110_000;
|
||||
export const WILDLIFE_CARD_HIDE_MAX_METERS = 125_000;
|
||||
export const WILDLIFE_CARD_FADE_LOW_METERS = 70_000;
|
||||
export const WILDLIFE_CARD_FADE_HIGH_METERS = 125_000;
|
||||
export const WILDLIFE_SELECTED_LABEL_MAX_CHARS = 40;
|
||||
export const WILDLIFE_SELECTED_LABEL_FADE_LOW_METERS = 250_000;
|
||||
export const WILDLIFE_SELECTED_LABEL_FADE_HIGH_METERS = 400_000;
|
||||
export const WILDLIFE_CARD_MAX_DISTANCE_METERS = 600_000;
|
||||
export const WILDLIFE_CARD_ALTITUDE_SCALE = Object.freeze({
|
||||
fullEnd: WILDLIFE_CARD_SCALE_FULL_METERS,
|
||||
midEnd: WILDLIFE_CARD_SCALE_MID_METERS,
|
||||
end: WILDLIFE_CARD_SCALE_END_METERS,
|
||||
midValue: 0.9,
|
||||
endValue: 0.85,
|
||||
smoothToMid: true,
|
||||
});
|
||||
|
||||
export { ACARTIA_GUARD_BOX } from './acartiaTaxa.js';
|
||||
export const WILDLIFE_CONFIRMED_CHIP_ID = 'quality-confirmed';
|
||||
|
||||
export const TAXA_PRESETS = Object.freeze([
|
||||
Object.freeze({
|
||||
value: 'all',
|
||||
id: 'taxa-all',
|
||||
label: msg('ALL'),
|
||||
title: msg('Show all taxa'),
|
||||
}),
|
||||
Object.freeze({
|
||||
value: 'Aves',
|
||||
id: 'taxa-birds',
|
||||
label: msg('BIRDS'),
|
||||
title: msg('Show only birds'),
|
||||
}),
|
||||
Object.freeze({
|
||||
value: 'Mammalia',
|
||||
id: 'taxa-mammals',
|
||||
label: msg('MAMMALS'),
|
||||
title: msg('Show only mammals'),
|
||||
}),
|
||||
Object.freeze({
|
||||
value: 'whales',
|
||||
id: 'taxa-whales',
|
||||
label: msg('WHALES'),
|
||||
title: msg('Show only whales and dolphins (7 days)'),
|
||||
}),
|
||||
Object.freeze({
|
||||
value: 'Insecta',
|
||||
id: 'taxa-insects',
|
||||
label: msg('INSECTS'),
|
||||
title: msg('Show only insects'),
|
||||
}),
|
||||
Object.freeze({
|
||||
value: 'Plantae',
|
||||
id: 'taxa-plants',
|
||||
label: msg('PLANTS'),
|
||||
title: msg('Show only plants'),
|
||||
}),
|
||||
]);
|
||||
@@ -0,0 +1,341 @@
|
||||
// The server imports this module. Keep the projection independent of Cesium,
|
||||
// DOM and the translation catalog, and never copy an upstream record wholesale.
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import { acartiaSpeciesGroup, inAcartiaCoverage } from './acartiaTaxa.js';
|
||||
|
||||
const OPEN_PHOTO_LICENCES = Object.freeze([
|
||||
'cc0',
|
||||
'cc-by',
|
||||
'cc-by-sa',
|
||||
'cc-by-nc',
|
||||
'cc-by-nc-sa',
|
||||
]);
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const ACARTIA_MAX_AGE_MS = 8 * DAY_MS;
|
||||
const OBSERVATION_FUTURE_TOLERANCE_MS = 10 * 60_000;
|
||||
// A JavaScript $ can match before a final newline; require the true end too.
|
||||
const INAT_PHOTO =
|
||||
/^https:\/\/inaturalist-open-data\.s3\.amazonaws\.com\/photos\/\d+\/square\.(jpe?g|png)$(?![\s\S])/i;
|
||||
const INAT_OBSERVATION =
|
||||
/^https:\/\/www\.inaturalist\.org\/observations\/\d+$(?![\s\S])/;
|
||||
|
||||
/** Accept only canonical iNaturalist observation pages, without URL suffixes. */
|
||||
function inatObservationUri(value) {
|
||||
return typeof value === 'string' && INAT_OBSERVATION.test(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function cleanText(value, maxLength) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const text = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim();
|
||||
return text ? text.slice(0, maxLength) : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') return null;
|
||||
if (typeof value === 'string' && !value.trim()) return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function validPosition(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Math.abs(lat) <= 90 &&
|
||||
Number.isFinite(lon) &&
|
||||
Math.abs(lon) <= 180
|
||||
);
|
||||
}
|
||||
|
||||
function dateOnly(value) {
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
||||
return null;
|
||||
const time = Date.parse(value);
|
||||
return Number.isFinite(time) &&
|
||||
new Date(time).toISOString().slice(0, 10) === value
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Date-only records cannot prove a time within a partly expired cutoff day.
|
||||
* Use its start conservatively, including for privacy-preserving cached cells. */
|
||||
function withinObservationWindow(observedAt, observedOn, nowMs, windowMs) {
|
||||
const time = Number.isFinite(observedAt)
|
||||
? observedAt
|
||||
: dateOnly(observedOn)
|
||||
? Date.parse(`${observedOn}T00:00:00Z`)
|
||||
: NaN;
|
||||
return (
|
||||
Number.isFinite(time) &&
|
||||
Number.isFinite(nowMs) &&
|
||||
Number.isFinite(windowMs) &&
|
||||
windowMs >= 0 &&
|
||||
time >= nowMs - windowMs &&
|
||||
time <= nowMs + OBSERVATION_FUTURE_TOLERANCE_MS
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply the displayed observation window to projected and retained LRU rows. */
|
||||
export function isRecentWildlifeSighting(row, nowMs, taxa = 'all') {
|
||||
return withinObservationWindow(
|
||||
row?.observedAt,
|
||||
row?.observedOn,
|
||||
nowMs,
|
||||
(taxa === 'whales' ? 7 : 2) * DAY_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Strictly parse the two documented Acartia forms. A timezone-free SQL-style
|
||||
* value is UTC; ISO timestamps must carry a timezone rather than using the host's. */
|
||||
export function parseAcartiaTime(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const text = value.trim();
|
||||
const sql = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text);
|
||||
const iso =
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.test(
|
||||
text,
|
||||
);
|
||||
if (!sql && !iso) return null;
|
||||
const day = dateOnly(text.slice(0, 10));
|
||||
if (!day) return null;
|
||||
const [hour, minute, second] = text.slice(11, 19).split(':').map(Number);
|
||||
if (hour > 23 || minute > 59 || second > 59) return null;
|
||||
const time = Date.parse(sql ? `${text.replace(' ', 'T')}Z` : text);
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
function coarsen(value, precision = 2) {
|
||||
const scale = 10 ** precision;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
|
||||
/** Public iNaturalist obscuration is a 0.2-degree cell aligned to zero. The
|
||||
* rounding removes floating-point noise, including at negative longitudes. */
|
||||
export function obscuredCell(lat, lon) {
|
||||
if (!validPosition(lat, lon)) return null;
|
||||
const south = Math.min(89.8, Math.floor(lat * 5) / 5);
|
||||
const west = Math.min(179.8, Math.floor(lon * 5) / 5);
|
||||
return {
|
||||
south: coarsen(south, 1),
|
||||
north: coarsen(south + 0.2, 1),
|
||||
west: coarsen(west, 1),
|
||||
east: coarsen(west + 0.2, 1),
|
||||
};
|
||||
}
|
||||
|
||||
/** Only the documented open-data square URL can be transformed to a display size. */
|
||||
export function inatPhotoUrl(url, size = 'square') {
|
||||
if (typeof url !== 'string' || !INAT_PHOTO.test(url)) return null;
|
||||
if (size !== 'square' && size !== 'medium') return null;
|
||||
return size === 'square' ? url : url.replace(/\/square\./i, `/${size}.`);
|
||||
}
|
||||
|
||||
/** Accept either the first upstream photo or the explicitly projected photo. */
|
||||
export function isOpenPhoto(photo) {
|
||||
if (!photo || typeof photo !== 'object') return false;
|
||||
const licence = photo.license_code ?? photo.licence;
|
||||
return (
|
||||
OPEN_PHOTO_LICENCES.includes(licence) &&
|
||||
inatPhotoUrl(photo.url ?? photo.square) !== null &&
|
||||
cleanText(photo.attribution, 160) !== null
|
||||
);
|
||||
}
|
||||
|
||||
/** Licence codes remain provider data; the all-rights-reserved fallback is
|
||||
* marked for translation when cards display it. */
|
||||
export function inatLicenceLabel(code) {
|
||||
switch (code) {
|
||||
case 'cc0':
|
||||
return 'CC0';
|
||||
case 'cc-by':
|
||||
return 'CC BY';
|
||||
case 'cc-by-sa':
|
||||
return 'CC BY-SA';
|
||||
case 'cc-by-nc':
|
||||
return 'CC BY-NC';
|
||||
case 'cc-by-nc-sa':
|
||||
return 'CC BY-NC-SA';
|
||||
case 'cc-by-nd':
|
||||
return 'CC BY-ND';
|
||||
case 'cc-by-nc-nd':
|
||||
return 'CC BY-NC-ND';
|
||||
default:
|
||||
return msg('All rights reserved');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project API-v1 observation results (the results ARRAY, not the response
|
||||
* envelope). Malformed rows are skipped; only a non-array rejects a snapshot.
|
||||
*/
|
||||
export function normalizeInatObservations(
|
||||
payload,
|
||||
{ nowMs, windowMs = 2 * DAY_MS } = {},
|
||||
) {
|
||||
if (!Array.isArray(payload)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const raw of payload) {
|
||||
if (!raw || !Number.isSafeInteger(raw.id) || raw.id <= 0) continue;
|
||||
if (raw.geoprivacy === 'private' || raw.taxon_geoprivacy === 'private')
|
||||
continue;
|
||||
if (raw.quality_grade !== 'research' && raw.quality_grade !== 'needs_id')
|
||||
continue;
|
||||
const stableId = `inat:${raw.id}`;
|
||||
if (ids.has(stableId)) continue;
|
||||
let lon = raw.geojson?.coordinates?.[0];
|
||||
let lat = raw.geojson?.coordinates?.[1];
|
||||
if (!validPosition(lat, lon) && typeof raw.location === 'string') {
|
||||
const parts = raw.location.split(',');
|
||||
if (parts.length === 2) [lat, lon] = parts.map(numberOrNull);
|
||||
}
|
||||
if (!validPosition(lat, lon)) continue;
|
||||
const obscured =
|
||||
raw.obscured === true ||
|
||||
raw.geoprivacy === 'obscured' ||
|
||||
raw.taxon_geoprivacy === 'obscured';
|
||||
const cell = obscured ? obscuredCell(lat, lon) : null;
|
||||
const time = parseAcartiaTime(raw.time_observed_at);
|
||||
const observedOn =
|
||||
dateOnly(raw.observed_on) ||
|
||||
(time !== null ? new Date(time).toISOString().slice(0, 10) : null);
|
||||
// The v1 d1 parameter is a DATE filter, so its first day over-fetches the
|
||||
// exact rolling window. Filter before hiding an obscured record's time.
|
||||
if (
|
||||
Number.isFinite(nowMs) &&
|
||||
!withinObservationWindow(time, observedOn, nowMs, windowMs)
|
||||
)
|
||||
continue;
|
||||
const photo = Array.isArray(raw.photos) ? raw.photos[0] : null;
|
||||
const taxon = raw.taxon;
|
||||
const faves = numberOrNull(raw.faves_count);
|
||||
const agreements = numberOrNull(raw.num_identification_agreements);
|
||||
ids.add(stableId);
|
||||
rows.push({
|
||||
stableId,
|
||||
origin: 'inaturalist',
|
||||
lat: cell ? coarsen((cell.south + cell.north) / 2, 1) : lat,
|
||||
lon: cell ? coarsen((cell.west + cell.east) / 2, 1) : lon,
|
||||
obscured,
|
||||
cell,
|
||||
observedAt: obscured ? null : time,
|
||||
observedOn,
|
||||
quality: raw.quality_grade,
|
||||
commonName: cleanText(taxon?.preferred_common_name, 100),
|
||||
scientificName: cleanText(taxon?.name, 100),
|
||||
iconic: cleanText(taxon?.iconic_taxon_name, 40) || 'Unknown',
|
||||
threatened: taxon?.threatened === true,
|
||||
provider: null,
|
||||
link:
|
||||
inatObservationUri(raw.uri) ||
|
||||
`https://www.inaturalist.org/observations/${raw.id}`,
|
||||
photo: isOpenPhoto(photo)
|
||||
? {
|
||||
square: inatPhotoUrl(photo.url),
|
||||
licence: photo.license_code,
|
||||
attribution: cleanText(photo.attribution, 160),
|
||||
}
|
||||
: null,
|
||||
// These public aggregate counters are used only for ambient photo rank.
|
||||
favesCount: faves !== null && faves >= 0 ? Math.floor(faves) : 0,
|
||||
identificationAgreements:
|
||||
agreements !== null && agreements >= 0 ? Math.floor(agreements) : 0,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Acartia has no documented per-sighting page, and photo_url is supplied by the
|
||||
// contributor. The source link is therefore always the Acartia site itself.
|
||||
export const ACARTIA_SITE = 'https://acartia.io/';
|
||||
|
||||
/** Acartia documents flat rows only. Name and entity are text fields; unknown
|
||||
* shapes are skipped rather than guessing nested provider data. */
|
||||
export function normalizeAcartiaSightings(payload, nowMs) {
|
||||
if (!Array.isArray(payload)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const raw of payload) {
|
||||
const id =
|
||||
typeof raw?.entry_id === 'string'
|
||||
? cleanText(raw.entry_id, 120)
|
||||
: Number.isFinite(raw?.entry_id)
|
||||
? String(raw.entry_id)
|
||||
: null;
|
||||
if (!id) continue;
|
||||
const stableId = `acartia:${id}`;
|
||||
if (ids.has(stableId)) continue;
|
||||
const lat = numberOrNull(raw.latitude);
|
||||
const lon = numberOrNull(raw.longitude);
|
||||
const observedAt = parseAcartiaTime(raw.created);
|
||||
const name = cleanText(raw.data_source_name, 60);
|
||||
const species = cleanText(raw.type, 100);
|
||||
// Validate the original field: truncating a long, ambiguous name could
|
||||
// otherwise turn a prefix such as "Orca ... or seal" into a valid species.
|
||||
const speciesGroup = acartiaSpeciesGroup(raw.type);
|
||||
if (
|
||||
!validPosition(lat, lon) ||
|
||||
!inAcartiaCoverage(lat, lon) ||
|
||||
!name ||
|
||||
!speciesGroup ||
|
||||
observedAt === null ||
|
||||
!Number.isFinite(nowMs) ||
|
||||
nowMs - observedAt > ACARTIA_MAX_AGE_MS ||
|
||||
observedAt - nowMs > OBSERVATION_FUTURE_TOLERANCE_MS
|
||||
)
|
||||
continue;
|
||||
ids.add(stableId);
|
||||
rows.push({
|
||||
stableId,
|
||||
origin: 'acartia',
|
||||
lat: coarsen(lat),
|
||||
lon: coarsen(lon),
|
||||
obscured: false,
|
||||
cell: null,
|
||||
observedAt,
|
||||
observedOn: new Date(observedAt).toISOString().slice(0, 10),
|
||||
quality:
|
||||
raw.trusted === true || raw.trusted === 1 || raw.trusted === 'true'
|
||||
? 'research'
|
||||
: 'needs_id',
|
||||
commonName: species,
|
||||
scientificName: null,
|
||||
iconic: 'Mammalia',
|
||||
cetacean: speciesGroup === 'cetacean',
|
||||
threatened: false,
|
||||
provider: { name, entity: cleanText(raw.data_source_entity, 60) },
|
||||
link: ACARTIA_SITE,
|
||||
photo: null,
|
||||
favesCount: 0,
|
||||
identificationAgreements: 0,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Recheck observation age on projected snapshots, including stale cache rows. */
|
||||
export function mergeSightings(
|
||||
inatRows,
|
||||
acartiaRows,
|
||||
{ nowMs, taxa = 'all', confirmed = false } = {},
|
||||
) {
|
||||
const byId = new Map();
|
||||
for (const row of Array.isArray(inatRows) ? inatRows : []) {
|
||||
if (isRecentWildlifeSighting(row, nowMs, taxa)) byId.set(row.stableId, row);
|
||||
}
|
||||
for (const row of Array.isArray(acartiaRows) ? acartiaRows : []) {
|
||||
if (
|
||||
!inAcartiaCoverage(row.lat, row.lon) ||
|
||||
!isRecentWildlifeSighting(row, nowMs, taxa)
|
||||
)
|
||||
continue;
|
||||
if (confirmed && row.quality !== 'research') continue;
|
||||
if (/inaturalist/i.test(row.provider?.name || '')) continue;
|
||||
if (!['all', 'Mammalia', 'whales'].includes(taxa)) continue;
|
||||
if (taxa === 'whales' && row.cetacean !== true) continue;
|
||||
byId.set(row.stableId, row);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
normalizeInatObservations,
|
||||
normalizeAcartiaSightings,
|
||||
parseAcartiaTime,
|
||||
inatPhotoUrl,
|
||||
isOpenPhoto,
|
||||
obscuredCell,
|
||||
inatLicenceLabel,
|
||||
mergeSightings,
|
||||
isRecentWildlifeSighting,
|
||||
} from './records.js';
|
||||
|
||||
const now = Date.UTC(2026, 10, 4, 18);
|
||||
const hour = 3_600_000;
|
||||
const photo = {
|
||||
url: 'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpeg',
|
||||
license_code: 'cc-by-nc',
|
||||
attribution: '(c) Synthetic Photographer, some rights reserved',
|
||||
};
|
||||
const observation = (overrides = {}) => ({
|
||||
id: 123,
|
||||
uri: 'https://www.inaturalist.org/observations/123',
|
||||
geojson: { coordinates: [-122.351, 47.613] },
|
||||
positional_accuracy: 12,
|
||||
public_positional_accuracy: 12,
|
||||
obscured: false,
|
||||
geoprivacy: null,
|
||||
taxon_geoprivacy: null,
|
||||
time_observed_at: '2026-11-04T16:00:00Z',
|
||||
observed_on: '2026-11-04',
|
||||
created_at: '2026-11-04T17:00:00Z',
|
||||
quality_grade: 'research',
|
||||
license_code: 'cc-by-nc',
|
||||
faves_count: 6,
|
||||
num_identification_agreements: 3,
|
||||
taxon: {
|
||||
name: 'Calypte anna',
|
||||
preferred_common_name: "Anna's Hummingbird",
|
||||
iconic_taxon_name: 'Aves',
|
||||
threatened: false,
|
||||
},
|
||||
photos: [photo],
|
||||
user: { login: 'SHOULD_NEVER_ESCAPE' },
|
||||
...overrides,
|
||||
});
|
||||
const sighting = (overrides = {}) => ({
|
||||
entry_id: 'abc',
|
||||
latitude: 48.12345,
|
||||
longitude: -122.54321,
|
||||
created: '2026-11-04 16:00:00',
|
||||
type: 'Orca',
|
||||
no_sighted: '5',
|
||||
photo_url: 'https://provider.example/photo.jpg',
|
||||
data_source_name: 'Synthetic Network',
|
||||
data_source_entity: 'Whale Alert',
|
||||
trusted: true,
|
||||
data_source_comments: 'PRIVATE PHONE AND NAME',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('normalizers reject only non-array snapshots and dedupe validated ids', () => {
|
||||
for (const input of [null, {}, { results: [] }, '']) {
|
||||
assert.equal(normalizeInatObservations(input), null);
|
||||
assert.equal(normalizeAcartiaSightings(input, now), null);
|
||||
}
|
||||
assert.deepEqual(normalizeInatObservations([]), []);
|
||||
assert.equal(
|
||||
normalizeInatObservations([null, observation(), observation()]).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeAcartiaSightings([null, sighting(), sighting()], now).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('iNaturalist projection retains only the listed display fields and aggregate photo rank', () => {
|
||||
const [row] = normalizeInatObservations([observation()]);
|
||||
assert.equal(row.stableId, 'inat:123');
|
||||
assert.equal(row.favesCount, 6);
|
||||
assert.equal(row.identificationAgreements, 3);
|
||||
assert.equal(row.photo.attribution, photo.attribution);
|
||||
assert.equal(row.observedAt, now - 2 * hour);
|
||||
for (const key of [
|
||||
'user',
|
||||
'login',
|
||||
'public_positional_accuracy',
|
||||
'geojson',
|
||||
'created_at',
|
||||
])
|
||||
assert.equal(key in row, false);
|
||||
assert.doesNotMatch(JSON.stringify(row), /SHOULD_NEVER_ESCAPE/);
|
||||
});
|
||||
|
||||
test('observation links accept only canonical source pages and fall back to the id', () => {
|
||||
for (const uri of [
|
||||
undefined,
|
||||
'https://www.inaturalist.org/observations/123\n',
|
||||
'https://www.inaturalist.org/observations/123?redirect=elsewhere',
|
||||
'http://www.inaturalist.org/observations/123',
|
||||
'https://www.inaturalist.org.evil.example/observations/123',
|
||||
]) {
|
||||
const [row] = normalizeInatObservations([observation({ uri })]);
|
||||
assert.equal(
|
||||
row.link,
|
||||
observation().uri,
|
||||
'the existing detail card keeps its canonical fallback',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('wildlife package entries construct without acquiring data or a DOM', async () => {
|
||||
const api = await import('gods-eye-view/layers/wildlife-sightings');
|
||||
const sources =
|
||||
await import('gods-eye-view/layers/wildlife-sightings/source');
|
||||
const layer = api.createWildlifeSightingsLayer({
|
||||
source: sources.createWildlifeSightingsSource({
|
||||
fetchImpl() {
|
||||
assert.fail('construction must not acquire');
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(layer.id, 'wildlife-sightings');
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('only allow-listed photo licences with square open-data hosts and attribution render', () => {
|
||||
for (const license_code of [
|
||||
'cc0',
|
||||
'cc-by',
|
||||
'cc-by-sa',
|
||||
'cc-by-nc',
|
||||
'cc-by-nc-sa',
|
||||
]) {
|
||||
assert.equal(isOpenPhoto({ ...photo, license_code }), true);
|
||||
assert.ok(
|
||||
normalizeInatObservations([
|
||||
observation({ photos: [{ ...photo, license_code }] }),
|
||||
])[0].photo,
|
||||
);
|
||||
}
|
||||
for (const badPhoto of [
|
||||
{ ...photo, license_code: null },
|
||||
{ ...photo, license_code: 'cc-by-nd' },
|
||||
{ ...photo, license_code: 'cc-by-nc-nd' },
|
||||
{ ...photo, license_code: 'all rights reserved' },
|
||||
{ ...photo, url: 'https://static.inaturalist.org/photos/123/square.jpg' },
|
||||
{ ...photo, url: `${photo.url}?size=square` },
|
||||
{ ...photo, url: photo.url.replace('https:', 'http:') },
|
||||
{ ...photo, attribution: '' },
|
||||
{ ...photo, url: photo.url.replace('/square.', '/medium.') },
|
||||
]) {
|
||||
assert.equal(isOpenPhoto(badPhoto), false);
|
||||
assert.equal(
|
||||
normalizeInatObservations([observation({ photos: [badPhoto] })])[0].photo,
|
||||
null,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
inatPhotoUrl(photo.url, 'medium'),
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/123/medium.jpeg',
|
||||
);
|
||||
for (const extension of ['jpg', 'jpeg', 'PNG']) {
|
||||
const square = photo.url.replace('square.jpeg', `square.${extension}`);
|
||||
assert.equal(
|
||||
inatPhotoUrl(square, 'medium'),
|
||||
square.replace('/square.', '/medium.'),
|
||||
);
|
||||
}
|
||||
for (const invalid of [
|
||||
null,
|
||||
new URL(photo.url),
|
||||
photo.url.replace('https:', 'http:'),
|
||||
photo.url.replace('amazonaws.com', 'amazonaws.com.evil.example'),
|
||||
photo.url.replace('https://', 'https://user:secret@'),
|
||||
photo.url.replace('https://', 'https://evil.example@'),
|
||||
photo.url.replace('/photos/', '/other/photos/'),
|
||||
photo.url.replace('/123/', '/abc/'),
|
||||
photo.url.replace('/square.', '/medium.'),
|
||||
photo.url.replace('/square.', '/large.'),
|
||||
photo.url.replace('.jpeg', '.webp'),
|
||||
`${photo.url}?size=large`,
|
||||
`${photo.url}#large`,
|
||||
`${photo.url}\n`,
|
||||
]) {
|
||||
assert.equal(inatPhotoUrl(invalid, 'medium'), null, String(invalid));
|
||||
}
|
||||
for (const invalidSize of [
|
||||
'original',
|
||||
'small',
|
||||
'large',
|
||||
'Medium',
|
||||
'../medium',
|
||||
'medium?query=1',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(inatPhotoUrl(photo.url, invalidSize), null);
|
||||
}
|
||||
assert.equal(inatLicenceLabel('cc-by-nc-sa'), 'CC BY-NC-SA');
|
||||
assert.equal(inatLicenceLabel(null), 'All rights reserved');
|
||||
});
|
||||
|
||||
test('photo attribution is trimmed, control-free and bounded', () => {
|
||||
const attribution = ` Photo\u0000\n${'A'.repeat(200)} `;
|
||||
const [row] = normalizeInatObservations([
|
||||
observation({ photos: [{ ...photo, attribution }] }),
|
||||
]);
|
||||
assert.equal(row.photo.attribution.length, 160);
|
||||
assert.match(row.photo.attribution, /^PhotoA/);
|
||||
assert.doesNotMatch(row.photo.attribution, /[\u0000-\u001f]/);
|
||||
});
|
||||
|
||||
test('obscured rows reveal only cell centres and date, without accuracy', () => {
|
||||
assert.deepEqual(obscuredCell(47.613, -122.351), {
|
||||
south: 47.6,
|
||||
north: 47.8,
|
||||
west: -122.4,
|
||||
east: -122.2,
|
||||
});
|
||||
for (const hiding of [
|
||||
{ obscured: true },
|
||||
{ geoprivacy: 'obscured' },
|
||||
{ taxon_geoprivacy: 'obscured' },
|
||||
]) {
|
||||
const [row] = normalizeInatObservations([observation(hiding)]);
|
||||
assert.equal(row.obscured, true);
|
||||
assert.equal(row.lat, 47.7);
|
||||
assert.equal(row.lon, -122.3);
|
||||
assert.equal('accuracyM' in row, false);
|
||||
assert.equal(row.observedAt, null);
|
||||
assert.equal(row.observedOn, '2026-11-04');
|
||||
assert.doesNotMatch(JSON.stringify(row), /47\.613|-122\.351|16:00/);
|
||||
}
|
||||
assert.deepEqual(obscuredCell(90, 180), {
|
||||
south: 89.8,
|
||||
north: 90,
|
||||
west: 179.8,
|
||||
east: 180,
|
||||
});
|
||||
});
|
||||
|
||||
test('private, casual, invalid coordinates and invalid ids are skipped', () => {
|
||||
const invalid = [
|
||||
observation({ geoprivacy: 'private' }),
|
||||
observation({ taxon_geoprivacy: 'private' }),
|
||||
observation({ quality_grade: 'casual' }),
|
||||
observation({ id: -1 }),
|
||||
observation({ id: 1.5 }),
|
||||
observation({ geojson: { coordinates: [181, 47] } }),
|
||||
observation({ geojson: null }),
|
||||
];
|
||||
assert.deepEqual(normalizeInatObservations(invalid), []);
|
||||
const [fallback] = normalizeInatObservations([
|
||||
observation({
|
||||
geojson: null,
|
||||
location: '47.613,-122.351',
|
||||
uri: 'https://bad.example',
|
||||
}),
|
||||
]);
|
||||
assert.equal(fallback.lat, 47.613);
|
||||
assert.equal(fallback.link, 'https://www.inaturalist.org/observations/123');
|
||||
assert.deepEqual(
|
||||
normalizeInatObservations([observation({ geojson: null, location: ',0' })]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Acartia accepts only the two stated UTC timestamp formats', () => {
|
||||
assert.equal(parseAcartiaTime('2026-11-04 16:00:00'), now - 2 * hour);
|
||||
assert.equal(parseAcartiaTime('2026-11-04T16:00:00Z'), now - 2 * hour);
|
||||
assert.equal(parseAcartiaTime('2026-11-04T18:00:00+02:00'), now - 2 * hour);
|
||||
for (const value of [
|
||||
'2026-11-04T16:00:00',
|
||||
'11/04/2026',
|
||||
'2026-02-30 12:00:00',
|
||||
'2026-11-04 25:00:00',
|
||||
123,
|
||||
null,
|
||||
])
|
||||
assert.equal(parseAcartiaTime(value), null);
|
||||
});
|
||||
|
||||
test('Acartia coarsens before returning data, keeps provider, and never reads comments', () => {
|
||||
const raw = sighting();
|
||||
Object.defineProperty(raw, 'data_source_comments', {
|
||||
get() {
|
||||
assert.fail('comments read');
|
||||
},
|
||||
});
|
||||
const [row] = normalizeAcartiaSightings([raw], now);
|
||||
assert.equal(row.lat, 48.12);
|
||||
assert.equal(row.lon, -122.54);
|
||||
assert.deepEqual(row.provider, {
|
||||
name: 'Synthetic Network',
|
||||
entity: 'Whale Alert',
|
||||
});
|
||||
assert.equal(row.quality, 'research');
|
||||
assert.equal(row.photo, null);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(row),
|
||||
/data_source_comments|PRIVATE|48\.12345|-122\.54321/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Acartia requires flat provider text, handles trust, and sanitizes type', () => {
|
||||
for (const provider of ['', null, {}, { name: 'Invented nested shape' }]) {
|
||||
assert.deepEqual(
|
||||
normalizeAcartiaSightings(
|
||||
[sighting({ data_source_name: provider })],
|
||||
now,
|
||||
),
|
||||
[],
|
||||
);
|
||||
}
|
||||
for (const trusted of [true, 1, 'true'])
|
||||
assert.equal(
|
||||
normalizeAcartiaSightings([sighting({ trusted })], now)[0].quality,
|
||||
'research',
|
||||
);
|
||||
for (const trusted of [false, 0, 'false', '1'])
|
||||
assert.equal(
|
||||
normalizeAcartiaSightings([sighting({ trusted })], now)[0].quality,
|
||||
'needs_id',
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeAcartiaSightings([sighting({ type: ' \u0000 ' })], now),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Acartia rejects invalid times/positions and its link is always the Acartia site', () => {
|
||||
for (const overrides of [
|
||||
{ created: new Date(now - 8 * 24 * hour - 1).toISOString() },
|
||||
{ created: new Date(now + 10 * 60_000 + 1).toISOString() },
|
||||
{ created: 'not a timestamp' },
|
||||
{ latitude: 91 },
|
||||
{ longitude: -181 },
|
||||
{ entry_id: '' },
|
||||
])
|
||||
assert.deepEqual(normalizeAcartiaSightings([sighting(overrides)], now), []);
|
||||
for (const photo_url of [
|
||||
// A contributor-supplied URL is never labelled as the Acartia source.
|
||||
'https://provider.example/photo.jpg',
|
||||
'https://phishing.example/acartia.io/login',
|
||||
'https://acartia.io.attacker.example/',
|
||||
'http://provider.example/p.jpg',
|
||||
'https://user:[email protected]/p.jpg',
|
||||
'javascript:bad()',
|
||||
'https://' + 'x'.repeat(501),
|
||||
null,
|
||||
]) {
|
||||
assert.equal(
|
||||
normalizeAcartiaSightings([sighting({ photo_url })], now)[0].link,
|
||||
'https://acartia.io/',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('merge applies Acartia windows, trust, preset and iNat dedupe', () => {
|
||||
const inat = normalizeInatObservations([observation()]);
|
||||
const rows = normalizeAcartiaSightings(
|
||||
[
|
||||
sighting(),
|
||||
sighting({
|
||||
entry_id: 'old',
|
||||
created: new Date(now - 3 * 24 * hour).toISOString(),
|
||||
}),
|
||||
sighting({ entry_id: 'untrusted', trusted: false }),
|
||||
sighting({
|
||||
entry_id: 'inat',
|
||||
data_source_name: 'iNaturalist observations',
|
||||
}),
|
||||
],
|
||||
now,
|
||||
);
|
||||
const ids = (options) =>
|
||||
mergeSightings(inat, rows, { nowMs: now, ...options }).map(
|
||||
(row) => row.stableId,
|
||||
);
|
||||
assert.deepEqual(ids({}), ['inat:123', 'acartia:abc', 'acartia:untrusted']);
|
||||
assert.deepEqual(ids({ confirmed: true }), ['inat:123', 'acartia:abc']);
|
||||
assert.ok(ids({ taxa: 'whales' }).includes('acartia:old'));
|
||||
assert.deepEqual(ids({ taxa: 'Aves' }), ['inat:123']);
|
||||
});
|
||||
|
||||
test('iNaturalist recency uses observation time and the exact preset window', () => {
|
||||
const raw = [
|
||||
observation({
|
||||
id: 1,
|
||||
time_observed_at: '2010-11-04T16:00:00Z',
|
||||
observed_on: '2010-11-04',
|
||||
created_at: new Date(now).toISOString(),
|
||||
}),
|
||||
observation({
|
||||
id: 2,
|
||||
time_observed_at: new Date(now - 48 * hour - 1).toISOString(),
|
||||
observed_on: '2026-11-02',
|
||||
}),
|
||||
observation({
|
||||
id: 3,
|
||||
time_observed_at: new Date(now - 48 * hour).toISOString(),
|
||||
observed_on: '2026-11-02',
|
||||
created_at: '2026-10-01T00:00:00Z',
|
||||
}),
|
||||
observation({
|
||||
id: 4,
|
||||
time_observed_at: new Date(now - 3 * 24 * hour).toISOString(),
|
||||
observed_on: '2026-11-01',
|
||||
}),
|
||||
];
|
||||
const ids = (rows) => rows.map((row) => row.stableId);
|
||||
assert.deepEqual(ids(normalizeInatObservations(raw, { nowMs: now })), [
|
||||
'inat:3',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
ids(
|
||||
normalizeInatObservations(raw, { nowMs: now, windowMs: 7 * 24 * hour }),
|
||||
),
|
||||
['inat:2', 'inat:3', 'inat:4'],
|
||||
);
|
||||
// Recheck cached/projected rows as the clock moves between server refreshes.
|
||||
assert.deepEqual(
|
||||
ids(mergeSightings(normalizeInatObservations(raw), [], { nowMs: now })),
|
||||
['inat:3'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
mergeSightings(normalizeInatObservations([raw[2]]), [], { nowMs: now + 1 }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('date-only and obscured iNaturalist recency never includes a partly expired date', () => {
|
||||
const rows = normalizeInatObservations(
|
||||
[
|
||||
observation({ id: 1, time_observed_at: null, observed_on: '2026-11-02' }),
|
||||
observation({ id: 2, time_observed_at: null, observed_on: '2026-11-03' }),
|
||||
observation({ id: 3, time_observed_at: null, observed_on: null }),
|
||||
observation({
|
||||
id: 4,
|
||||
obscured: true,
|
||||
time_observed_at: '2010-11-04T16:00:00Z',
|
||||
observed_on: '2010-11-04',
|
||||
}),
|
||||
],
|
||||
{ nowMs: now },
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.stableId),
|
||||
['inat:2'],
|
||||
);
|
||||
});
|
||||
|
||||
test('retained sightings use the same exact, date-only and preset recency limits', () => {
|
||||
const boundary = { observedAt: now - 48 * hour };
|
||||
assert.equal(isRecentWildlifeSighting(boundary, now), true);
|
||||
assert.equal(isRecentWildlifeSighting(boundary, now + 1), false);
|
||||
assert.equal(isRecentWildlifeSighting(boundary, now + 1, 'whales'), true);
|
||||
const whaleBoundary = { observedAt: now - 7 * 24 * hour };
|
||||
assert.equal(isRecentWildlifeSighting(whaleBoundary, now, 'whales'), true);
|
||||
assert.equal(
|
||||
isRecentWildlifeSighting(whaleBoundary, now + 1, 'whales'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isRecentWildlifeSighting(whaleBoundary, now, 'Mammalia'), false);
|
||||
for (const [observedOn, expected] of [
|
||||
['2026-11-02', false],
|
||||
['2026-11-03', true],
|
||||
['2026-11-04', true],
|
||||
]) {
|
||||
assert.equal(
|
||||
isRecentWildlifeSighting({ observedAt: null, observedOn }, now),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
isRecentWildlifeSighting({ observedOn: '2026-11-03' }, now + 24 * hour),
|
||||
false,
|
||||
);
|
||||
for (const row of [
|
||||
null,
|
||||
{},
|
||||
{ observedOn: '2010-11-04' },
|
||||
{ observedOn: 'invalid' },
|
||||
]) {
|
||||
assert.equal(isRecentWildlifeSighting(row, now), false);
|
||||
}
|
||||
assert.equal(isRecentWildlifeSighting(boundary, NaN), false);
|
||||
});
|
||||
|
||||
test('Acartia validates the documented type field before applying the WHALES preset', () => {
|
||||
const rows = normalizeAcartiaSightings(
|
||||
[
|
||||
sighting({ entry_id: 'orca', type: 'Orca' }),
|
||||
sighting({ entry_id: 'scientific', type: 'Phocoena phocoena' }),
|
||||
sighting({ entry_id: 'seal', type: 'Harbor seal' }),
|
||||
sighting({
|
||||
entry_id: 'unidentified',
|
||||
type: 'Unidentified marine mammal',
|
||||
}),
|
||||
sighting({ entry_id: 'unknown-whale', type: 'Unidentified whale' }),
|
||||
sighting({ entry_id: 'unknown', type: 'Unknown' }),
|
||||
sighting({ entry_id: 'empty', type: '' }),
|
||||
sighting({ entry_id: 'fish', type: 'Whale shark' }),
|
||||
sighting({ entry_id: 'text', type: 'Maybe an Orca or a seal' }),
|
||||
sighting({ entry_id: 'prefix', type: `Orca${' '.repeat(120)}or a seal` }),
|
||||
sighting({ entry_id: 'invented-shape', type: { species: 'Orca' } }),
|
||||
],
|
||||
now,
|
||||
);
|
||||
const ids = (taxa) =>
|
||||
mergeSightings([], rows, { nowMs: now, taxa }).map((row) => row.stableId);
|
||||
assert.deepEqual(ids('whales'), ['acartia:orca', 'acartia:scientific']);
|
||||
const mammals = [
|
||||
'acartia:orca',
|
||||
'acartia:scientific',
|
||||
'acartia:seal',
|
||||
'acartia:unidentified',
|
||||
'acartia:unknown-whale',
|
||||
];
|
||||
assert.deepEqual(ids('all'), mammals);
|
||||
assert.deepEqual(ids('Mammalia'), mammals);
|
||||
assert.deepEqual(ids('Aves'), []);
|
||||
});
|
||||
|
||||
test('Acartia rejects out-of-coverage rows before rounding or caching', () => {
|
||||
for (const position of [
|
||||
{ latitude: 45.49999 },
|
||||
{ latitude: 50.50001 },
|
||||
{ longitude: -126.00001 },
|
||||
{ longitude: -120.99999 },
|
||||
{ latitude: 37.8, longitude: -122.4 },
|
||||
]) {
|
||||
assert.deepEqual(normalizeAcartiaSightings([sighting(position)], now), []);
|
||||
}
|
||||
for (const position of [
|
||||
{ latitude: 45.5, longitude: -126 },
|
||||
{ latitude: 50.5, longitude: -121 },
|
||||
]) {
|
||||
assert.equal(
|
||||
normalizeAcartiaSightings([sighting(position)], now).length,
|
||||
1,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { performance, PerformanceObserver } from 'node:perf_hooks';
|
||||
import test from 'node:test';
|
||||
import { perfBudgetTestOptions } from '../../../scripts/run-perf-tests.mjs';
|
||||
import { denseContinentalRendering } from './renderPerformanceFixtures.mjs';
|
||||
import { WILDLIFE_MOVE_END_BUDGET_MS } from './performance.js';
|
||||
|
||||
const median = (values) =>
|
||||
[...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
|
||||
const percentile95 = (values) =>
|
||||
[...values].sort((a, b) => a - b)[Math.floor(values.length * 0.95)];
|
||||
|
||||
// A GC pause is outside the layer's CPU work. Keep raw wall times in the
|
||||
// diagnostic and subtract only directly observed, overlapping GC intervals
|
||||
// for the strict CPU budget; never hide a slow layer phase as an outlier.
|
||||
function activeMs(slice, collections) {
|
||||
const start = slice.startTimeMs;
|
||||
const end = start + slice.durationMs;
|
||||
return (
|
||||
slice.durationMs -
|
||||
collections.reduce(
|
||||
(sum, gc) =>
|
||||
sum +
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(end, gc.startTime + gc.duration) -
|
||||
Math.max(start, gc.startTime),
|
||||
),
|
||||
0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'dense continental production moveEnd pipeline yields every 4ms, including cold tiles and real Cesium billboard updates',
|
||||
perfBudgetTestOptions(),
|
||||
async (t) => {
|
||||
// Settle CPU-triggered runtime startup before creating the cold layer;
|
||||
// cells, coordinates and billboard collections remain unwarmed.
|
||||
const calibrationStart = performance.now();
|
||||
let calibrationChecksum = 0;
|
||||
for (;;) {
|
||||
const cpu = process.threadCpuUsage();
|
||||
if (cpu.user + cpu.system >= 1_100_000) break;
|
||||
for (let i = 0; i < 65536; i++)
|
||||
calibrationChecksum =
|
||||
(calibrationChecksum + i) ^ (calibrationChecksum >>> 16);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
const runtimeSettleMs = performance.now() - calibrationStart;
|
||||
const collections = [];
|
||||
const phases = [];
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries())
|
||||
(entry.entryType === 'gc' ? collections : phases).push(entry);
|
||||
});
|
||||
observer.observe({ entryTypes: ['gc', 'measure'] });
|
||||
const h = denseContinentalRendering();
|
||||
const runs = [];
|
||||
try {
|
||||
for (let i = 0; i < 65; i++) {
|
||||
const start = performance.now();
|
||||
assert.equal(h.moveEnd((i % 4) * 3), 'pending');
|
||||
const enqueueMs = performance.now() - start;
|
||||
const result = h.flush();
|
||||
runs.push({
|
||||
startTimeMs: start,
|
||||
endTimeMs: performance.now(),
|
||||
enqueueMs,
|
||||
...result,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
for (const entry of observer.takeRecords())
|
||||
(entry.entryType === 'gc' ? collections : phases).push(entry);
|
||||
const warm = runs.slice(25);
|
||||
const allSlices = runs.flatMap((run) => run.slices);
|
||||
const warmSlices = warm.flatMap((run) => run.slices);
|
||||
const activeSlices = allSlices.map((slice) =>
|
||||
activeMs(slice, collections),
|
||||
);
|
||||
const warmTotal = warm.map((run) =>
|
||||
run.slices.reduce(
|
||||
(sum, slice) => sum + activeMs(slice, collections),
|
||||
run.enqueueMs,
|
||||
),
|
||||
);
|
||||
const enqueueMax = Math.max(...runs.map((run) => run.enqueueMs));
|
||||
const coldMax = Math.max(
|
||||
...runs[0].slices.map((slice) => activeMs(slice, collections)),
|
||||
);
|
||||
const sliceMax = Math.max(...activeSlices);
|
||||
if (sliceMax > WILDLIFE_MOVE_END_BUDGET_MS) {
|
||||
const slow = allSlices[activeSlices.indexOf(sliceMax)];
|
||||
const entries = phases
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.entryType === 'measure' &&
|
||||
entry.name !== 'wildlife-sightings:moveEnd:slice' &&
|
||||
entry.startTime >= slow.startTimeMs &&
|
||||
entry.startTime < slow.startTimeMs + slow.durationMs,
|
||||
)
|
||||
.map((entry) => ({
|
||||
phase: entry.name,
|
||||
durationMs: entry.duration,
|
||||
activeMs: activeMs(
|
||||
{ startTimeMs: entry.startTime, durationMs: entry.duration },
|
||||
collections,
|
||||
),
|
||||
}));
|
||||
t.diagnostic(
|
||||
`Slow slice ${JSON.stringify({ slice: slow, phases: entries, gc: collections.map((entry) => ({ start: entry.startTime, duration: entry.duration })), now: performance.now(), uptimeMs: process.uptime() * 1000 })}`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
enqueueMax <= WILDLIFE_MOVE_END_BUDGET_MS,
|
||||
`moveEnd enqueue ${enqueueMax.toFixed(3)}ms`,
|
||||
);
|
||||
assert.ok(
|
||||
sliceMax <= WILDLIFE_MOVE_END_BUDGET_MS,
|
||||
`longest layer CPU slice ${sliceMax.toFixed(3)}ms exceeds 4ms`,
|
||||
);
|
||||
assert.ok(
|
||||
median(warmTotal) <= WILDLIFE_MOVE_END_BUDGET_MS,
|
||||
`median full moveEnd CPU ${median(warmTotal).toFixed(3)}ms exceeds 4ms`,
|
||||
);
|
||||
assert.equal(warm.at(-1).cartesianBuilds, 0);
|
||||
const phaseMeans = Object.fromEntries(
|
||||
[
|
||||
'density',
|
||||
'bucket',
|
||||
'rank',
|
||||
'billboard-update',
|
||||
'overlay-publish',
|
||||
].map((name) => {
|
||||
const duration = phases
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.name === `wildlife-sightings:${name}` &&
|
||||
entry.startTime >= warm[0].startTimeMs,
|
||||
)
|
||||
.reduce(
|
||||
(sum, entry) =>
|
||||
sum +
|
||||
activeMs(
|
||||
{
|
||||
startTimeMs: entry.startTime,
|
||||
durationMs: entry.duration,
|
||||
},
|
||||
collections,
|
||||
),
|
||||
0,
|
||||
);
|
||||
return [name, (duration / warm.length).toFixed(3)];
|
||||
}),
|
||||
);
|
||||
t.diagnostic(
|
||||
`6,144 cells + 500 observations, ${runs.at(-1).glowPrimitives} glow splats + ${runs.at(-1).billboards} real icons; moveEnd enqueue max=${enqueueMax.toFixed(3)}ms; cold max CPU slice=${coldMax.toFixed(3)}ms; all max CPU slice=${sliceMax.toFixed(3)}ms; warm total CPU median=${median(warmTotal).toFixed(3)}ms; warm slice p95=${percentile95(warmSlices.map((slice) => activeMs(slice, collections))).toFixed(3)}ms; raw wall slice max=${Math.max(...allSlices.map((slice) => slice.durationMs)).toFixed(3)}ms; observed GC pauses=${collections.length}; repeated Cartesian builds=0; CPU startup calibration=${runtimeSettleMs.toFixed(0)}ms, checksum=${calibrationChecksum} (cold fixture created afterward); warm phase mean ms=${JSON.stringify(phaseMeans)}`,
|
||||
);
|
||||
} finally {
|
||||
h.destroy();
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createSelection } from './selection.js';
|
||||
|
||||
export const DENSE_CONTINENT_CELLS = 6 * 32 * 32;
|
||||
export const DENSE_CONTINENT_ROWS = 500;
|
||||
const NOW = Date.parse('2026-11-04T18:00:00Z');
|
||||
|
||||
/** The real render + selection pipeline with Cesium coordinates/billboards.
|
||||
* Only the WebGL scene and external overlay host are replaced. Six fully
|
||||
* occupied logical tiles deliberately include off-window cells and collisions. */
|
||||
export function denseContinentalRendering({ onOperations } = {}) {
|
||||
const cells = [];
|
||||
const z = 3;
|
||||
for (let x = 1; x < 3; x++)
|
||||
for (let y = 1; y < 4; y++)
|
||||
for (let row = 1; row < 64; row += 2)
|
||||
for (let column = 1; column < 64; column += 2)
|
||||
cells.push({
|
||||
id: `grid:${z}/${x}/${y}:${row}:${column}`,
|
||||
lat:
|
||||
(Math.atan(
|
||||
Math.sinh(Math.PI * (1 - (2 * (y + row / 64)) / 2 ** z)),
|
||||
) *
|
||||
180) /
|
||||
Math.PI,
|
||||
lon: ((x + column / 64) / 2 ** z) * 360 - 180,
|
||||
cellCount: row * 64 + column,
|
||||
});
|
||||
const rows = cells
|
||||
.slice(1200, 1200 + DENSE_CONTINENT_ROWS)
|
||||
.map((cell, i) => ({
|
||||
stableId: `synthetic:continent:${i}`,
|
||||
origin: 'inaturalist',
|
||||
lat: cell.lat,
|
||||
lon: cell.lon,
|
||||
iconic: 'Aves',
|
||||
commonName: i % 2 ? `Synthetic bird ${i}` : null,
|
||||
observedAt: NOW - i * 1000,
|
||||
quality: 'research',
|
||||
obscured: false,
|
||||
cell: null,
|
||||
}));
|
||||
const primitives = {
|
||||
add: (value) => value,
|
||||
remove(value) {
|
||||
value.destroy();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const scene = {
|
||||
canvas: { clientWidth: 1440, clientHeight: 900 },
|
||||
primitives,
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
viewer: {
|
||||
scene,
|
||||
camera: { positionWC: {}, positionCartographic: { height: 2_000_000 } },
|
||||
},
|
||||
rows: new Map(rows.map((row) => [row.stableId, row])),
|
||||
gridCells: cells,
|
||||
gridAvailable: true,
|
||||
renderedRows: [],
|
||||
rowById: new Map(),
|
||||
pointById: new Map(),
|
||||
positions: new Map(),
|
||||
cells: [],
|
||||
staging: null,
|
||||
taxa: 'all',
|
||||
selectedId: null,
|
||||
query: { visibleBoxes: [{ south: 5, west: -130, north: 75, east: -50 }] },
|
||||
band: 'regional',
|
||||
ambientRows: [],
|
||||
ambientIds: [],
|
||||
labelRows: [],
|
||||
carriers: new Map(),
|
||||
contextIds: new Set(),
|
||||
};
|
||||
const centre = Cesium.Cartesian3.fromDegrees(-90, 42);
|
||||
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(centre);
|
||||
const east = Cesium.Matrix4.getColumn(frame, 0, new Cesium.Cartesian4());
|
||||
const north = Cesium.Matrix4.getColumn(frame, 1, new Cesium.Cartesian4());
|
||||
const metrics = { projections: 0, publications: 0, cartesianBuilds: 0 };
|
||||
let pan = 0;
|
||||
let nextIdle = 0;
|
||||
const idle = new Map();
|
||||
const slices = [];
|
||||
const timers = new Map();
|
||||
let nextTimer = 0;
|
||||
const parts = {};
|
||||
const services = {
|
||||
now: () => NOW,
|
||||
setTimer(fn, delay) {
|
||||
timers.set(++nextTimer, { fn, delay });
|
||||
return nextTimer;
|
||||
},
|
||||
clearTimer: (id) => timers.delete(id),
|
||||
requestIdleCallback(fn) {
|
||||
idle.set(++nextIdle, fn);
|
||||
return nextIdle;
|
||||
},
|
||||
cancelIdleCallback(id) {
|
||||
idle.delete(id);
|
||||
},
|
||||
performanceSlice(slice) {
|
||||
slices.push(slice);
|
||||
},
|
||||
cesium: {
|
||||
...Cesium,
|
||||
Cartesian3: class extends Cesium.Cartesian3 {
|
||||
static fromDegrees(...args) {
|
||||
metrics.cartesianBuilds++;
|
||||
return Cesium.Cartesian3.fromDegrees(...args);
|
||||
}
|
||||
},
|
||||
},
|
||||
project(_scene, anchor) {
|
||||
metrics.projections++;
|
||||
return {
|
||||
x:
|
||||
720 +
|
||||
(anchor.x * east.x + anchor.y * east.y + anchor.z * east.z) / 8000 +
|
||||
pan,
|
||||
y:
|
||||
450 -
|
||||
(anchor.x * north.x + anchor.y * north.y + anchor.z * north.z) / 8000,
|
||||
};
|
||||
},
|
||||
occluderFactory: () => ({ isPointVisible: () => true }),
|
||||
badgeSprites: {
|
||||
get: (group) => `data:image/svg+xml,${group}`,
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
overlayHost: {
|
||||
setEntries() {
|
||||
metrics.publications++;
|
||||
},
|
||||
},
|
||||
performanceOperations: onOperations,
|
||||
};
|
||||
parts.rendering = createRendering({ state, services, parts });
|
||||
parts.photos = { get: () => null, setAmbient() {} };
|
||||
parts.selection = createSelection({ state, services, parts });
|
||||
parts.rendering.init();
|
||||
return {
|
||||
state,
|
||||
metrics,
|
||||
moveEnd(offset = 0) {
|
||||
pan = offset;
|
||||
metrics.projections = metrics.publications = metrics.cartesianBuilds = 0;
|
||||
slices.length = 0;
|
||||
const result = parts.rendering.rebuild();
|
||||
if (result !== 'pending') {
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.refreshContexts();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
flush(maxSlices = Infinity) {
|
||||
let count = 0;
|
||||
while (idle.size && count++ < maxSlices) {
|
||||
const [id, fn] = idle.entries().next().value;
|
||||
idle.delete(id);
|
||||
const threadStart = process.threadCpuUsage();
|
||||
fn({ timeRemaining: () => 8, didTimeout: false });
|
||||
const threadUsed = process.threadCpuUsage(threadStart);
|
||||
if (slices.length)
|
||||
slices.at(-1).threadCpuMs =
|
||||
(threadUsed.user + threadUsed.system) / 1000;
|
||||
}
|
||||
return {
|
||||
...metrics,
|
||||
billboards: state.renderedRows.length,
|
||||
glowPrimitives: parts.rendering.density?.size ?? 0,
|
||||
slices: [...slices],
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
parts.rendering.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
import { wildlifeAccent } from './cards.js';
|
||||
import {
|
||||
wildlifeIconModelWork,
|
||||
wildlifeIconRelevance,
|
||||
compareWildlifeIds,
|
||||
} from './model.js';
|
||||
import { createWildlifeBillboardModel } from './billboardModel.js';
|
||||
import { createWildlifeDensity } from './density.js';
|
||||
import { wildlifeSpriteCache } from './sprites.js';
|
||||
import { isRecentWildlifeSighting } from './records.js';
|
||||
import {
|
||||
drainWildlifeWork,
|
||||
createWildlifeWorkQueue,
|
||||
WILDLIFE_SYNC_CELL_LIMIT,
|
||||
WILDLIFE_WORK_UNIT,
|
||||
} from './performance.js';
|
||||
import {
|
||||
WILDLIFE_SCALE_BY_DISTANCE,
|
||||
WILDLIFE_BADGE_MAX_PX,
|
||||
WILDLIFE_GROUND_READY_MAX_CHECKS,
|
||||
WILDLIFE_SPRITE_PX,
|
||||
} from './policy.js';
|
||||
|
||||
/** Own sighting billboards, density glow and batched privacy cells. */
|
||||
export function createRendering({ state, services, parts }) {
|
||||
const C = services.cesium;
|
||||
const badgeSprites = services.badgeSprites ?? wildlifeSpriteCache();
|
||||
let batchDepth = 0;
|
||||
let pendingRenderReason = null;
|
||||
let billboards = null;
|
||||
let density = null;
|
||||
let cellsSignature = null;
|
||||
let ambientAnchorIds = new Set();
|
||||
const badgeScreens = new Map();
|
||||
const crowdedBadgeIds = new Set();
|
||||
const photoCardIds = new Set();
|
||||
const appearances = new Map();
|
||||
const surfaceAnchors = new WeakMap();
|
||||
let operations = null;
|
||||
const workQueue = createWildlifeWorkQueue({
|
||||
requestIdle: services.requestIdleCallback,
|
||||
cancelIdle: services.cancelIdleCallback,
|
||||
onSlice: (slice) => services.performanceSlice?.(slice),
|
||||
});
|
||||
|
||||
function surfaceAnchor(row) {
|
||||
const old = surfaceAnchors.get(row);
|
||||
if (old && old.lat === row.lat && old.lon === row.lon) return old.anchor;
|
||||
const anchor = C.Cartesian3.fromDegrees(row.lon, row.lat);
|
||||
surfaceAnchors.set(row, { lat: row.lat, lon: row.lon, anchor });
|
||||
if (operations) operations.positionBuilds++;
|
||||
return anchor;
|
||||
}
|
||||
const requestRender = (reason) => {
|
||||
if (batchDepth) {
|
||||
pendingRenderReason = reason;
|
||||
return;
|
||||
}
|
||||
services.render?.governorRequestRender?.(`wildlife-sightings-${reason}`);
|
||||
};
|
||||
function beginBatch() {
|
||||
batchDepth += 1;
|
||||
}
|
||||
function endBatch() {
|
||||
batchDepth = Math.max(0, batchDepth - 1);
|
||||
if (batchDepth || !pendingRenderReason) return;
|
||||
const reason = pendingRenderReason;
|
||||
pendingRenderReason = null;
|
||||
requestRender(reason);
|
||||
}
|
||||
const surfaceRemovers = [];
|
||||
const tilesetRemovers = new Map();
|
||||
|
||||
// Reuse the shared ground cache. One bounded resolve lifts cold anchors
|
||||
// without per-sighting scene renders and pixel readback.
|
||||
let floorResolve = null;
|
||||
let floorColdPending = false;
|
||||
function position(row) {
|
||||
if (row.origin === 'acartia') return surfaceAnchor(row);
|
||||
const floor = services.groundFloor?.cachedGroundFloor?.(row.lat, row.lon);
|
||||
if (floor == null && !floorColdPending) {
|
||||
// Anchors are built after the rows publish; warm once the pass ends.
|
||||
floorColdPending = true;
|
||||
queueMicrotask(warmColdFloors);
|
||||
}
|
||||
return Number.isFinite(floor) && floor !== 0
|
||||
? C.Cartesian3.fromDegrees(row.lon, row.lat, floor)
|
||||
: surfaceAnchor(row);
|
||||
}
|
||||
|
||||
function warmColdFloors() {
|
||||
if (!floorColdPending || floorResolve) return;
|
||||
floorColdPending = false;
|
||||
const resolve = services.groundFloor?.resolveGroundFloorCellsBounded;
|
||||
if (!state.enabled || typeof resolve !== 'function') return;
|
||||
const points = state.renderedRows
|
||||
.filter((row) => row.origin !== 'acartia')
|
||||
.map((row) => ({ lat: row.lat, lon: row.lon }));
|
||||
if (!points.length) return;
|
||||
floorResolve = Promise.resolve(resolve(points))
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
floorResolve = null;
|
||||
resampleAnchors();
|
||||
});
|
||||
}
|
||||
|
||||
function occluder() {
|
||||
const camera = state.viewer?.camera;
|
||||
return camera?.positionWC ? services.occluderFactory?.(camera) : null;
|
||||
}
|
||||
|
||||
function cull() {
|
||||
if (!state.enabled) return false;
|
||||
const horizon = occluder();
|
||||
let changed = false;
|
||||
// Only observation badges yield at adjacent bucket edges or to a painted
|
||||
// card. Density never participates in picking or icon spacing.
|
||||
for (const [id, point] of state.pointById) {
|
||||
const anchor = state.positions.get(id);
|
||||
const show =
|
||||
!photoCardIds.has(id) &&
|
||||
!crowdedBadgeIds.has(id) &&
|
||||
(!horizon || horizon.isPointVisible(anchor) === true);
|
||||
if (point.show === show) continue;
|
||||
point.show = show;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) requestRender('cull');
|
||||
return changed;
|
||||
}
|
||||
|
||||
function updateBadgeSpacing() {
|
||||
return drainWildlifeWork(updateBadgeSpacingWork());
|
||||
}
|
||||
|
||||
function* updateBadgeSpacingWork() {
|
||||
yield 'rank';
|
||||
crowdedBadgeIds.clear();
|
||||
const buckets = new Map();
|
||||
const rows = [...state.renderedRows].sort(
|
||||
(a, b) =>
|
||||
wildlifeIconRelevance(b, state.selectedId) -
|
||||
wildlifeIconRelevance(a, state.selectedId) ||
|
||||
compareWildlifeIds(a.stableId, b.stableId),
|
||||
);
|
||||
yield 'rank';
|
||||
let visits = 0;
|
||||
for (const row of rows) {
|
||||
if (visits++ % WILDLIFE_WORK_UNIT === 0) yield 'rank';
|
||||
if (photoCardIds.has(row.stableId)) continue;
|
||||
const screen = badgeScreens.get(row.stableId);
|
||||
if (!screen) continue;
|
||||
const x = Math.floor(screen.x / WILDLIFE_BADGE_MAX_PX);
|
||||
const y = Math.floor(screen.y / WILDLIFE_BADGE_MAX_PX);
|
||||
let overlaps = false;
|
||||
for (let dx = -1; dx <= 1 && !overlaps; dx++) {
|
||||
for (let dy = -1; dy <= 1 && !overlaps; dy++) {
|
||||
for (const other of buckets.get((x + dx) * 65536 + y + dy) || []) {
|
||||
if (operations) operations.spacingTests++;
|
||||
if (
|
||||
(screen.x - other.x) ** 2 + (screen.y - other.y) ** 2 <
|
||||
WILDLIFE_BADGE_MAX_PX ** 2
|
||||
) {
|
||||
overlaps = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (overlaps) crowdedBadgeIds.add(row.stableId);
|
||||
else {
|
||||
const key = x * 65536 + y;
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key).push(screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called only when the host's painted membership changes, never per idle
|
||||
// frame. Requested/loading/rejected cards must leave their badges visible.
|
||||
function setPhotoCardVisibility(entryIds) {
|
||||
const next = new Set();
|
||||
for (const id of entryIds)
|
||||
if (id.startsWith('sighting:')) next.add(id.slice(9));
|
||||
const changed =
|
||||
next.size !== photoCardIds.size ||
|
||||
[...next].some((id) => !photoCardIds.has(id));
|
||||
photoCardIds.clear();
|
||||
for (const id of next) photoCardIds.add(id);
|
||||
if (!state.enabled) return;
|
||||
updateBadgeSpacing();
|
||||
cull();
|
||||
// A painted thumbnail already names its sighting; drop the repeat label.
|
||||
if (changed) parts.selection?.photoCardsChanged?.();
|
||||
}
|
||||
|
||||
function resampleAnchors() {
|
||||
if (!state.enabled || !state.points) return;
|
||||
let changed = false;
|
||||
for (const row of state.renderedRows) {
|
||||
if (row.origin === 'acartia') continue;
|
||||
const point = state.pointById.get(row.stableId);
|
||||
if (!point) continue;
|
||||
const anchor = position(row);
|
||||
if (C.Cartesian3.equals(anchor, state.positions.get(row.stableId)))
|
||||
continue;
|
||||
point.position = anchor;
|
||||
state.positions.set(row.stableId, anchor);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
beginBatch();
|
||||
try {
|
||||
cull();
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.notify?.();
|
||||
parts.selection.refreshContexts();
|
||||
requestRender('terrain');
|
||||
} finally {
|
||||
endBatch();
|
||||
}
|
||||
}
|
||||
|
||||
function trackTileset(primitive) {
|
||||
if (
|
||||
tilesetRemovers.has(primitive) ||
|
||||
typeof primitive?.allTilesLoaded?.addEventListener !== 'function'
|
||||
)
|
||||
return false;
|
||||
tilesetRemovers.set(
|
||||
primitive,
|
||||
primitive.allTilesLoaded.addEventListener(resampleAnchors),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
workQueue.cancel();
|
||||
operations = null;
|
||||
for (const remove of surfaceRemovers.splice(0)) remove?.();
|
||||
for (const remove of tilesetRemovers.values()) remove?.();
|
||||
tilesetRemovers.clear();
|
||||
}
|
||||
|
||||
function activate() {
|
||||
deactivate();
|
||||
if (!state.enabled || !state.viewer) return;
|
||||
const scene = state.viewer.scene;
|
||||
const refreshBadgeImages = () => {
|
||||
if (!state.enabled) return;
|
||||
appearances.clear();
|
||||
if (billboards?.refreshImages((group) => badgeSprites.get(group)))
|
||||
requestRender('font-ready');
|
||||
};
|
||||
surfaceRemovers.push(badgeSprites.subscribe(refreshBadgeImages));
|
||||
refreshBadgeImages();
|
||||
const ownEvent = (event, listener) => {
|
||||
const remove = event?.addEventListener?.(listener);
|
||||
if (typeof remove === 'function') surfaceRemovers.push(remove);
|
||||
};
|
||||
ownEvent(scene.globe?.tileLoadProgressEvent, (pending) => {
|
||||
if (pending === 0) resampleAnchors();
|
||||
});
|
||||
// Scene forwards the globe event in Cesium; deduplicate that alias.
|
||||
for (const event of new Set([
|
||||
scene.terrainProviderChanged,
|
||||
scene.globe?.terrainProviderChanged,
|
||||
]))
|
||||
ownEvent(event, resampleAnchors);
|
||||
const primitives = scene.primitives;
|
||||
for (let i = 0; i < (primitives?.length || 0); i++)
|
||||
trackTileset(primitives.get(i));
|
||||
ownEvent(primitives?.primitiveAdded, (primitive) => {
|
||||
if (trackTileset(primitive)) resampleAnchors();
|
||||
});
|
||||
ownEvent(primitives?.primitiveRemoved, (primitive) => {
|
||||
if (!tilesetRemovers.has(primitive)) return;
|
||||
tilesetRemovers.get(primitive)?.();
|
||||
tilesetRemovers.delete(primitive);
|
||||
resampleAnchors();
|
||||
});
|
||||
services.eventTarget?.addEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
resampleAnchors,
|
||||
);
|
||||
surfaceRemovers.push(() =>
|
||||
services.eventTarget?.removeEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
resampleAnchors,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function removeCell(primitive) {
|
||||
const owners =
|
||||
state.viewer?.scene?.groundPrimitives || state.viewer?.scene?.primitives;
|
||||
if (owners?.remove?.(primitive) !== true && !primitive.isDestroyed?.())
|
||||
primitive.destroy?.();
|
||||
}
|
||||
|
||||
function cancelStaging() {
|
||||
const pending = state.staging;
|
||||
if (!pending) return;
|
||||
state.staging = null;
|
||||
pending.remove?.();
|
||||
for (const primitive of pending.primitives) removeCell(primitive);
|
||||
}
|
||||
|
||||
function clearCells() {
|
||||
cancelStaging();
|
||||
for (const primitive of state.cells) removeCell(primitive);
|
||||
state.cells = [];
|
||||
cellsSignature = null;
|
||||
}
|
||||
|
||||
function stageCells(rows) {
|
||||
const cellsVisible = state.band === 'cells' || state.band === 'cards';
|
||||
const unique = new Map();
|
||||
if (cellsVisible) {
|
||||
for (const row of rows) {
|
||||
if (!row.cell) continue;
|
||||
const cell = row.cell;
|
||||
const key = `${cell.south},${cell.west},${cell.north},${cell.east}:${wildlifeAccent(row.iconic)}`;
|
||||
if (!unique.has(key)) unique.set(key, row);
|
||||
}
|
||||
}
|
||||
const signature = JSON.stringify([...unique.keys()].sort());
|
||||
if (signature === cellsSignature) return false;
|
||||
cancelStaging();
|
||||
if (!unique.size) {
|
||||
clearCells();
|
||||
cellsSignature = signature;
|
||||
return true;
|
||||
}
|
||||
cellsSignature = signature;
|
||||
const fills = [];
|
||||
const outlines = [];
|
||||
for (const row of unique.values()) {
|
||||
const { south, west, north, east } = row.cell;
|
||||
const tint = C.Color.fromCssColorString(wildlifeAccent(row.iconic));
|
||||
fills.push(
|
||||
new C.GeometryInstance({
|
||||
geometry: new C.RectangleGeometry({
|
||||
rectangle: C.Rectangle.fromDegrees(west, south, east, north),
|
||||
vertexFormat: C.PerInstanceColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: C.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint.withAlpha(0.25),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
outlines.push(
|
||||
new C.GeometryInstance({
|
||||
geometry: new C.GroundPolylineGeometry({
|
||||
positions: C.Cartesian3.fromDegreesArray([
|
||||
west,
|
||||
south,
|
||||
east,
|
||||
south,
|
||||
east,
|
||||
north,
|
||||
west,
|
||||
north,
|
||||
west,
|
||||
south,
|
||||
]),
|
||||
width: 2,
|
||||
}),
|
||||
attributes: {
|
||||
color: C.ColorGeometryInstanceAttribute.fromColor(
|
||||
tint.withAlpha(0.8),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Hidden primitives still compile their geometry. Poll only these two
|
||||
// nonempty batches, then atomically retire the previous privacy cells.
|
||||
const primitives = [
|
||||
new C.GroundPrimitive({
|
||||
geometryInstances: fills,
|
||||
appearance: new C.PerInstanceColorAppearance({
|
||||
flat: true,
|
||||
translucent: true,
|
||||
}),
|
||||
classificationType: C.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
releaseGeometryInstances: false,
|
||||
show: false,
|
||||
}),
|
||||
new C.GroundPolylinePrimitive({
|
||||
geometryInstances: outlines,
|
||||
appearance: new C.PolylineColorAppearance(),
|
||||
classificationType: C.ClassificationType.BOTH,
|
||||
allowPicking: false,
|
||||
releaseGeometryInstances: false,
|
||||
show: false,
|
||||
}),
|
||||
];
|
||||
const owners =
|
||||
state.viewer.scene.groundPrimitives || state.viewer.scene.primitives;
|
||||
for (const primitive of primitives) owners.add(primitive);
|
||||
const pending = { primitives, remove: null, checks: 0 };
|
||||
state.staging = pending;
|
||||
const ready = () => {
|
||||
if (state.staging !== pending || !state.enabled) return;
|
||||
if (!primitives.every((primitive) => primitive.ready === true)) {
|
||||
pending.checks += 1;
|
||||
if (pending.checks >= WILDLIFE_GROUND_READY_MAX_CHECKS) {
|
||||
cancelStaging();
|
||||
cellsSignature = null;
|
||||
return;
|
||||
}
|
||||
requestRender('staging');
|
||||
return;
|
||||
}
|
||||
pending.remove?.();
|
||||
state.staging = null;
|
||||
for (const primitive of state.cells) removeCell(primitive);
|
||||
state.cells = primitives;
|
||||
for (const primitive of primitives) primitive.show = true;
|
||||
requestRender('render');
|
||||
};
|
||||
pending.remove = state.viewer.scene.postRender?.addEventListener?.(ready);
|
||||
ready();
|
||||
return true;
|
||||
}
|
||||
|
||||
function init() {
|
||||
density = createWildlifeDensity({
|
||||
cesium: C,
|
||||
viewer: state.viewer,
|
||||
positionForCell: surfaceAnchor,
|
||||
requestRender,
|
||||
setTimer: services.setTimer,
|
||||
clearTimer: services.clearTimer,
|
||||
requestIdle: services.requestIdleCallback,
|
||||
cancelIdle: services.cancelIdleCallback,
|
||||
eventTarget: services.eventTarget,
|
||||
});
|
||||
state.points = new C.BillboardCollection();
|
||||
state.points.show = false;
|
||||
state.viewer.scene.primitives.add(state.points);
|
||||
services.sprites?.registerSpriteCollection?.(
|
||||
'wildlife-sightings',
|
||||
state.points,
|
||||
);
|
||||
const distanceScale = new C.NearFarScalar(...WILDLIFE_SCALE_BY_DISTANCE);
|
||||
billboards = createWildlifeBillboardModel({
|
||||
collection: state.points,
|
||||
pointById: state.pointById,
|
||||
positions: state.positions,
|
||||
positionForRow: position,
|
||||
appearanceForGroup(group) {
|
||||
if (!appearances.has(group))
|
||||
appearances.set(group, {
|
||||
image: badgeSprites.get(group),
|
||||
width: WILDLIFE_SPRITE_PX,
|
||||
height: WILDLIFE_SPRITE_PX,
|
||||
// The sprite includes its tint and white glyph. Multiplication
|
||||
// by a taxon colour would tint the glyph as well.
|
||||
color: C.Color.WHITE,
|
||||
scale: 1,
|
||||
scaleByDistance: distanceScale,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
return appearances.get(group);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
if (!state.enabled || !state.points) return false;
|
||||
workQueue.cancel();
|
||||
operations = null;
|
||||
const work = rebuildWork();
|
||||
if (
|
||||
(state.gridCells?.length || 0) > WILDLIFE_SYNC_CELL_LIMIT ||
|
||||
state.rows.size > WILDLIFE_SYNC_CELL_LIMIT
|
||||
) {
|
||||
workQueue.start(work, () => {
|
||||
if (!state.enabled) return;
|
||||
parts.selection.refreshAmbient();
|
||||
parts.selection.publishSelected();
|
||||
parts.selection.refreshContexts();
|
||||
parts.selection.syncCredits?.();
|
||||
parts.selection.notify?.();
|
||||
parts.controls?.notify?.();
|
||||
});
|
||||
return 'pending';
|
||||
}
|
||||
return drainWildlifeWork(work);
|
||||
}
|
||||
|
||||
function* rebuildWork() {
|
||||
operations = {
|
||||
positionBuilds: 0,
|
||||
spacingTests: 0,
|
||||
billboardVisits: 0,
|
||||
billboardWrites: 0,
|
||||
};
|
||||
const nowMs = services.now();
|
||||
const taxa = state.drawnParams?.taxa ?? state.taxa;
|
||||
const scene = state.viewer.scene;
|
||||
const horizon = occluder();
|
||||
const densityResult = yield* density.updateWork(
|
||||
state.gridAvailable ? state.gridCells || [] : [],
|
||||
{
|
||||
boxes: state.query?.visibleBoxes ?? state.query?.boxes,
|
||||
isPointVisible: horizon
|
||||
? (anchor) => horizon.isPointVisible(anchor) === true
|
||||
: null,
|
||||
onOperations: (counts) => Object.assign(operations, counts),
|
||||
},
|
||||
);
|
||||
if (densityResult.changed)
|
||||
services.sprites?.restoreSpriteOrder?.(state.viewer);
|
||||
const model = yield* wildlifeIconModelWork({
|
||||
rows: [...state.rows.values()].filter(
|
||||
(row) =>
|
||||
row.stableId === state.selectedId ||
|
||||
isRecentWildlifeSighting(row, nowMs, taxa),
|
||||
),
|
||||
boxes: state.query?.visibleBoxes ?? state.query?.boxes,
|
||||
selectedId: state.selectedId,
|
||||
selectedRow: state.selectedRow,
|
||||
preferredIds: state.ambientIds,
|
||||
onOperations: (counts) => Object.assign(operations, counts),
|
||||
projectRow:
|
||||
typeof services.project === 'function'
|
||||
? (row) => {
|
||||
const anchor = surfaceAnchor(row);
|
||||
const cached = surfaceAnchors.get(row);
|
||||
cached.screen = null;
|
||||
if (horizon && horizon.isPointVisible(anchor) !== true)
|
||||
return null;
|
||||
const screen = services.project(scene, anchor);
|
||||
if (
|
||||
!screen ||
|
||||
screen.x < 0 ||
|
||||
screen.y < 0 ||
|
||||
screen.x >= (scene.canvas?.clientWidth ?? Infinity) ||
|
||||
screen.y >= (scene.canvas?.clientHeight ?? Infinity)
|
||||
)
|
||||
return null;
|
||||
cached.screen = screen;
|
||||
return screen;
|
||||
}
|
||||
: null,
|
||||
});
|
||||
yield 'rank';
|
||||
badgeScreens.clear();
|
||||
for (const row of model.rows) {
|
||||
const screen = surfaceAnchors.get(row)?.screen;
|
||||
if (screen) badgeScreens.set(row.stableId, screen);
|
||||
}
|
||||
state.renderPipelineOperations = operations;
|
||||
state.renderedRows = model.rows;
|
||||
yield* updateBadgeSpacingWork();
|
||||
yield 'rank';
|
||||
state.sightingCount = state.gridAvailable
|
||||
? Math.max(
|
||||
densityResult.sightingCount,
|
||||
model.sightingCount - model.acartiaCount,
|
||||
) + model.acartiaCount
|
||||
: model.sightingCount;
|
||||
const previousRows = state.rowById;
|
||||
state.rowById = new Map(
|
||||
state.renderedRows.map((row) => [row.stableId, row]),
|
||||
);
|
||||
// Panning can bring two visible photo incumbents into one icon bucket.
|
||||
// Keep the single billboard winner, but preserve both card identities and
|
||||
// anchors until selection's front-side/window/height checks release them.
|
||||
const ambientAnchors = new Map();
|
||||
for (const incumbent of state.ambientRows || []) {
|
||||
const row = state.rows.get(incumbent.stableId);
|
||||
if (!row || !isRecentWildlifeSighting(row, nowMs, taxa)) continue;
|
||||
if (state.rowById.has(row.stableId)) continue;
|
||||
const old = previousRows.get(row.stableId);
|
||||
const cached = state.positions.get(row.stableId);
|
||||
ambientAnchors.set(
|
||||
row.stableId,
|
||||
cached && old?.lat === row.lat && old?.lon === row.lon
|
||||
? cached
|
||||
: position(row),
|
||||
);
|
||||
state.rowById.set(row.stableId, row);
|
||||
}
|
||||
function restoreAmbientAnchors() {
|
||||
for (const id of ambientAnchorIds)
|
||||
if (!ambientAnchors.has(id) && !state.pointById.has(id))
|
||||
state.positions.delete(id);
|
||||
for (const [id, anchor] of ambientAnchors)
|
||||
state.positions.set(id, anchor);
|
||||
ambientAnchorIds = new Set(ambientAnchors.keys());
|
||||
}
|
||||
if (state.selectedId && !state.rowById.has(state.selectedId))
|
||||
parts.selection.clear();
|
||||
yield 'billboard-update';
|
||||
const billboardUpdate = yield* billboards.updateWork(state.renderedRows, {
|
||||
onOperations: (counts) => Object.assign(operations, counts),
|
||||
});
|
||||
yield 'overlay-publish';
|
||||
let changed = densityResult.changed;
|
||||
beginBatch();
|
||||
try {
|
||||
restoreAmbientAnchors();
|
||||
changed = stageCells(state.renderedRows) || changed;
|
||||
changed = cull() || changed;
|
||||
if (
|
||||
billboardUpdate.created ||
|
||||
billboardUpdate.removed ||
|
||||
billboardUpdate.writes
|
||||
)
|
||||
changed = true;
|
||||
if (changed) requestRender('render');
|
||||
} finally {
|
||||
endBatch();
|
||||
}
|
||||
services.performanceOperations?.(operations);
|
||||
operations = null;
|
||||
return changed;
|
||||
}
|
||||
|
||||
function refreshSelection() {
|
||||
if (!state.enabled || !billboards) return false;
|
||||
// Selection changes spacing priority without reprojecting the view.
|
||||
updateBadgeSpacing();
|
||||
return cull();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
workQueue.cancel();
|
||||
operations = null;
|
||||
clearCells();
|
||||
density?.clear();
|
||||
billboards?.clear();
|
||||
if (state.points) state.points.show = false;
|
||||
state.pointById.clear();
|
||||
state.positions.clear();
|
||||
state.renderedRows = [];
|
||||
state.rowById.clear();
|
||||
state.sightingCount = 0;
|
||||
ambientAnchorIds.clear();
|
||||
badgeScreens.clear();
|
||||
crowdedBadgeIds.clear();
|
||||
photoCardIds.clear();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
deactivate();
|
||||
clear();
|
||||
services.sprites?.unregisterSpriteCollection?.(
|
||||
'wildlife-sightings',
|
||||
state.points,
|
||||
);
|
||||
if (state.points) state.viewer?.scene?.primitives?.remove?.(state.points);
|
||||
state.points = null;
|
||||
billboards = null;
|
||||
density?.destroy();
|
||||
density = null;
|
||||
appearances.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
get density() {
|
||||
return density;
|
||||
},
|
||||
get pending() {
|
||||
return workQueue.pending;
|
||||
},
|
||||
init,
|
||||
activate,
|
||||
deactivate,
|
||||
occluder,
|
||||
cull,
|
||||
rebuild,
|
||||
refreshSelection,
|
||||
setPhotoCardVisibility,
|
||||
hasPhotoCard: (id) => photoCardIds.has(id),
|
||||
clear,
|
||||
destroy,
|
||||
requestRender,
|
||||
beginBatch,
|
||||
endBatch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { nearFarScalarValueAtDistance } from '../../data/focusDeemphasis.js';
|
||||
import { wildlifeGridCellId } from './gridViewport.js';
|
||||
import {
|
||||
WILDLIFE_GROUND_READY_MAX_CHECKS,
|
||||
WILDLIFE_SCALE_BY_DISTANCE,
|
||||
} from './policy.js';
|
||||
|
||||
const NOW = Date.parse('2026-11-04T18:00:00Z');
|
||||
const base = {
|
||||
stableId: 'synthetic:1',
|
||||
origin: 'inaturalist',
|
||||
lat: 47.6,
|
||||
lon: -122.3,
|
||||
iconic: 'Aves',
|
||||
observedAt: NOW - 60_000,
|
||||
quality: 'research',
|
||||
count: null,
|
||||
obscured: false,
|
||||
cell: null,
|
||||
};
|
||||
|
||||
function event() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
emit(...args) {
|
||||
for (const listener of [...listeners]) listener(...args);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function harness(rows = [base], extra = {}) {
|
||||
const counts = {
|
||||
collections: 0,
|
||||
created: 0,
|
||||
removed: 0,
|
||||
cleared: 0,
|
||||
writes: [],
|
||||
};
|
||||
const tracked = new Set([
|
||||
'position',
|
||||
'image',
|
||||
'color',
|
||||
'scale',
|
||||
'show',
|
||||
'width',
|
||||
'height',
|
||||
'scaleByDistance',
|
||||
]);
|
||||
class BillboardCollection extends Cesium.BillboardCollection {
|
||||
constructor() {
|
||||
super();
|
||||
counts.collections += 1;
|
||||
}
|
||||
add(options) {
|
||||
counts.created += 1;
|
||||
const point = super.add(options);
|
||||
return new Proxy(point, {
|
||||
get: (target, key) => Reflect.get(target, key, target),
|
||||
set(target, key, value) {
|
||||
if (tracked.has(key)) counts.writes.push(key);
|
||||
return Reflect.set(target, key, value, target);
|
||||
},
|
||||
});
|
||||
}
|
||||
remove(point) {
|
||||
counts.removed += 1;
|
||||
return super.remove(point);
|
||||
}
|
||||
removeAll() {
|
||||
counts.cleared += 1;
|
||||
return super.removeAll();
|
||||
}
|
||||
}
|
||||
class GroundBatch {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.ready = false;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed === true;
|
||||
}
|
||||
}
|
||||
function primitives() {
|
||||
const values = [];
|
||||
return {
|
||||
values,
|
||||
get length() {
|
||||
return values.length;
|
||||
},
|
||||
get: (i) => values[i],
|
||||
add(primitive) {
|
||||
values.push(primitive);
|
||||
return primitive;
|
||||
},
|
||||
remove(primitive) {
|
||||
const i = values.indexOf(primitive);
|
||||
if (i < 0) return false;
|
||||
values.splice(i, 1);
|
||||
primitive.destroy();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
const postRender = event();
|
||||
const terrain = event();
|
||||
const tileProgress = event();
|
||||
const scene = {
|
||||
primitives: primitives(),
|
||||
groundPrimitives: primitives(),
|
||||
postRender,
|
||||
terrainProviderChanged: terrain,
|
||||
globe: {
|
||||
terrainProviderChanged: terrain,
|
||||
tileLoadProgressEvent: tileProgress,
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
enabled: true,
|
||||
viewer: { scene, camera: { positionWC: {} } },
|
||||
rows: new Map(rows.map((row) => [row.stableId, row])),
|
||||
renderedRows: [],
|
||||
rowById: new Map(),
|
||||
pointById: new Map(),
|
||||
positions: new Map(),
|
||||
cells: [],
|
||||
staging: null,
|
||||
taxa: 'all',
|
||||
selectedId: null,
|
||||
query: { visibleBoxes: [{ south: -90, west: -180, north: 90, east: 180 }] },
|
||||
band: 'cards',
|
||||
};
|
||||
const renders = [];
|
||||
let visible = true;
|
||||
const registration = new Set();
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
services: {
|
||||
now: () => NOW,
|
||||
cesium: {
|
||||
...Cesium,
|
||||
BillboardCollection,
|
||||
GroundPrimitive: GroundBatch,
|
||||
GroundPolylinePrimitive: GroundBatch,
|
||||
},
|
||||
occluderFactory: () => ({ isPointVisible: () => visible }),
|
||||
render: { governorRequestRender: (reason) => renders.push(reason) },
|
||||
sprites: {
|
||||
registerSpriteCollection: (_id, value) => registration.add(value),
|
||||
unregisterSpriteCollection: (_id, value) => registration.delete(value),
|
||||
},
|
||||
...extra,
|
||||
},
|
||||
parts: {
|
||||
selection: {
|
||||
clear() {
|
||||
state.selectedId = null;
|
||||
},
|
||||
refreshAmbient() {},
|
||||
publishSelected() {},
|
||||
refreshContexts() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
rendering.init();
|
||||
rendering.activate();
|
||||
return {
|
||||
state,
|
||||
scene,
|
||||
rendering,
|
||||
counts,
|
||||
renders,
|
||||
postRender,
|
||||
terrain,
|
||||
tileProgress,
|
||||
registration,
|
||||
visible(value) {
|
||||
visible = value;
|
||||
},
|
||||
rows(next) {
|
||||
state.rows = new Map(next.map((row) => [row.stableId, row]));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('real Cesium billboard values update in place, and unchanged refreshes write nothing', () => {
|
||||
const second = { ...base, stableId: 'synthetic:2', lon: -120 };
|
||||
const h = harness([base, second]);
|
||||
h.rendering.rebuild();
|
||||
const collection = h.state.points;
|
||||
const incumbent = h.state.pointById.get(base.stableId);
|
||||
assert.equal(h.counts.collections, 1);
|
||||
assert.equal(h.counts.created, 2);
|
||||
assert.equal(incumbent.width * incumbent.scaleByDistance.farValue, 12);
|
||||
assert.deepEqual(
|
||||
incumbent.scaleByDistance,
|
||||
new Cesium.NearFarScalar(...WILDLIFE_SCALE_BY_DISTANCE),
|
||||
);
|
||||
assert.equal(incumbent.color.alpha, 1);
|
||||
h.counts.writes.length = 0;
|
||||
h.renders.length = 0;
|
||||
h.rows([{ ...second }, { ...base }]);
|
||||
assert.equal(h.rendering.rebuild(), false);
|
||||
assert.equal(h.rendering.cull(), false);
|
||||
assert.deepEqual(h.counts.writes, []);
|
||||
assert.deepEqual(h.renders, []);
|
||||
h.rows([{ ...base, lat: base.lat + 0.01 }, second]);
|
||||
assert.equal(h.rendering.rebuild(), true);
|
||||
assert.deepEqual(h.counts.writes, ['position']);
|
||||
assert.equal(h.state.pointById.get(base.stableId), incumbent);
|
||||
assert.equal(h.state.points, collection);
|
||||
h.rows([
|
||||
{ ...base, lat: base.lat + 0.01 },
|
||||
{ ...second, stableId: 'synthetic:3' },
|
||||
]);
|
||||
h.rendering.rebuild();
|
||||
assert.equal(h.counts.created, 3);
|
||||
assert.equal(h.counts.removed, 1);
|
||||
assert.equal(h.counts.cleared, 0);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.registration.size, 0);
|
||||
assert.equal(h.scene.primitives.length, 0);
|
||||
assert.equal(h.state.pointById.size, 0);
|
||||
assert.equal(h.terrain.size, 0);
|
||||
assert.equal(h.tileProgress.size, 0);
|
||||
});
|
||||
|
||||
test('horizon changes write only changed visibility, with one governor request per batch', () => {
|
||||
const h = harness();
|
||||
h.rendering.rebuild();
|
||||
h.counts.writes.length = 0;
|
||||
h.renders.length = 0;
|
||||
h.visible(false);
|
||||
h.rendering.beginBatch();
|
||||
h.rendering.cull();
|
||||
h.rendering.requestRender('labels');
|
||||
h.rendering.endBatch();
|
||||
assert.deepEqual(h.counts.writes, ['show']);
|
||||
assert.equal(h.renders.length, 1);
|
||||
h.rendering.cull();
|
||||
assert.deepEqual(h.counts.writes, ['show']);
|
||||
assert.equal(h.renders.length, 1);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('badge pixels stay between 12 and 22 at near, mid and far heights, including selection', () => {
|
||||
const h = harness();
|
||||
h.rendering.rebuild();
|
||||
const wildlife = h.state.pointById.get(base.stableId);
|
||||
for (const selectedId of [null, base.stableId]) {
|
||||
h.state.selectedId = selectedId;
|
||||
h.rendering.refreshSelection();
|
||||
for (const heightM of [
|
||||
0, 1000, 9000, 120_000, 400_000, 1_500_000, 8_000_000, 10_000_000,
|
||||
]) {
|
||||
const pixels =
|
||||
wildlife.width *
|
||||
wildlife.scale *
|
||||
nearFarScalarValueAtDistance(wildlife.scaleByDistance, heightM);
|
||||
assert.ok(
|
||||
pixels >= 12 && pixels <= 22,
|
||||
`${heightM}m, selected=${!!selectedId}: ${pixels}px`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
wildlife.color,
|
||||
Cesium.Color.WHITE,
|
||||
'keep the baked white glyph white',
|
||||
);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('adjacent 28px bucket edges never overlap badges or erase sighting counts', () => {
|
||||
const rows = [27, 29, 56, 84].map((x, i) => ({
|
||||
...base,
|
||||
stableId: `edge:${i}`,
|
||||
lon: base.lon + i * 0.01,
|
||||
x,
|
||||
}));
|
||||
const h = harness(rows, {
|
||||
project: (_scene, anchor) => {
|
||||
const lon = Cesium.Math.toDegrees(
|
||||
Cesium.Cartographic.fromCartesian(anchor).longitude,
|
||||
);
|
||||
return {
|
||||
x: rows.find((row) => Math.abs(row.lon - lon) < 1e-7).x,
|
||||
y: 100,
|
||||
};
|
||||
},
|
||||
});
|
||||
h.rendering.rebuild();
|
||||
assert.equal(h.state.sightingCount, 4);
|
||||
assert.equal(
|
||||
h.state.pointById.size,
|
||||
4,
|
||||
'all occupied buckets retain their identities',
|
||||
);
|
||||
const visible = rows.filter(
|
||||
(row) => h.state.pointById.get(row.stableId).show,
|
||||
);
|
||||
assert.equal(visible.length, 3);
|
||||
for (let i = 0; i < visible.length; i++)
|
||||
for (let j = i + 1; j < visible.length; j++)
|
||||
assert.ok(Math.abs(visible[i].x - visible[j].x) >= 22);
|
||||
h.counts.writes.length = 0;
|
||||
h.renders.length = 0;
|
||||
h.rendering.rebuild();
|
||||
h.rendering.cull();
|
||||
assert.deepEqual(h.counts.writes, []);
|
||||
assert.deepEqual(h.renders, []);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('only painted photo cards hide their anchored badges, with zero repeated visibility writes', () => {
|
||||
const h = harness();
|
||||
h.rendering.rebuild();
|
||||
const point = h.state.pointById.get(base.stableId);
|
||||
assert.equal(
|
||||
point.show,
|
||||
true,
|
||||
'a requested or loading card does not hide its badge',
|
||||
);
|
||||
h.counts.writes.length = 0;
|
||||
h.renders.length = 0;
|
||||
h.rendering.setPhotoCardVisibility(new Set([`sighting:${base.stableId}`]));
|
||||
assert.equal(point.show, false);
|
||||
assert.deepEqual(h.counts.writes, ['show']);
|
||||
h.rendering.rebuild();
|
||||
h.rendering.cull();
|
||||
h.rendering.setPhotoCardVisibility(new Set([`sighting:${base.stableId}`]));
|
||||
assert.deepEqual(h.counts.writes, ['show']);
|
||||
assert.equal(h.renders.length, 1);
|
||||
h.rendering.setPhotoCardVisibility(new Set());
|
||||
assert.equal(
|
||||
point.show,
|
||||
true,
|
||||
'a card that no longer paints restores its badge',
|
||||
);
|
||||
assert.deepEqual(h.counts.writes, ['show', 'show']);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('sampled fallback buckets allocate only representatives at every height, with zero idle writes', () => {
|
||||
for (const heightM of [1000, 7_000_000]) {
|
||||
const rows = ['a', 'b', 'c'].map((id, index) => ({
|
||||
...base,
|
||||
stableId: `synthetic:${id}`,
|
||||
lon: base.lon + index * 0.01,
|
||||
}));
|
||||
const screens = new Map(
|
||||
rows.map((row) => [row.lon.toFixed(6), { x: 100, y: 100 }]),
|
||||
);
|
||||
const h = harness(rows, {
|
||||
project: (_scene, anchor) => {
|
||||
const lon = Cesium.Math.toDegrees(
|
||||
Cesium.Cartographic.fromCartesian(anchor).longitude,
|
||||
);
|
||||
return screens.get(lon.toFixed(6));
|
||||
},
|
||||
});
|
||||
h.state.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
base.lon,
|
||||
base.lat,
|
||||
heightM,
|
||||
);
|
||||
h.rendering.rebuild();
|
||||
assert.equal(
|
||||
h.state.pointById.size,
|
||||
1,
|
||||
'same bucket reduction near and far',
|
||||
);
|
||||
assert.equal(h.counts.created, 1);
|
||||
screens.set(rows[2].lon.toFixed(6), { x: 200, y: 100 });
|
||||
assert.equal(h.rendering.cull(), false);
|
||||
h.rendering.rebuild(); // moveEnd recomputes buckets, never an idle frame.
|
||||
assert.equal(h.state.pointById.size, 2);
|
||||
assert.equal(h.counts.created, 2);
|
||||
h.counts.writes.length = 0;
|
||||
assert.equal(h.rendering.cull(), false);
|
||||
assert.equal(h.rendering.rebuild(), false);
|
||||
assert.deepEqual(h.counts.writes, []);
|
||||
assert.equal(h.postRender.size, 0);
|
||||
h.rendering.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('UTFGrid creates a dedicated unpickable glow and observations alone create badges', () => {
|
||||
let projections = 0;
|
||||
const h = harness([base], {
|
||||
project() {
|
||||
projections++;
|
||||
return { x: 100, y: 100 };
|
||||
},
|
||||
});
|
||||
h.scene.canvas = { clientWidth: 280, clientHeight: 200 };
|
||||
h.state.gridAvailable = true;
|
||||
h.state.gridCells = [1, 50, 2].map((cellCount, index) => ({
|
||||
id: wildlifeGridCellId(base.lat, base.lon + index * 0.01, 16),
|
||||
lat: base.lat,
|
||||
lon: base.lon + index * 0.01,
|
||||
cellCount,
|
||||
}));
|
||||
h.rendering.rebuild();
|
||||
const glow = h.rendering.density.collection;
|
||||
assert.ok(glow);
|
||||
assert.equal(glow.allowPicking, false);
|
||||
assert.equal(glow.length, 3);
|
||||
assert.equal(h.scene.primitives.length, 2);
|
||||
assert.deepEqual([...h.state.pointById.keys()], [base.stableId]);
|
||||
assert.deepEqual(h.state.renderedRows, [base]);
|
||||
assert.equal(h.state.sightingCount, 53);
|
||||
assert.equal(projections, 1, 'only observations require screen projections');
|
||||
assert.equal(h.postRender.size, 0, 'density installs no frame listener');
|
||||
h.state.gridCells = [...h.state.gridCells].reverse();
|
||||
h.counts.writes.length = 0;
|
||||
assert.equal(h.rendering.rebuild(), false);
|
||||
assert.equal(h.rendering.density.collection, glow);
|
||||
assert.deepEqual(h.counts.writes, []);
|
||||
h.rendering.clear();
|
||||
assert.equal(glow.isDestroyed(), true);
|
||||
assert.equal(h.scene.primitives.length, 1);
|
||||
assert.equal(h.state.points.length, 0);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.scene.primitives.length, 0);
|
||||
});
|
||||
|
||||
test('tile arrival or loss cannot replace a real observation with a grid badge', () => {
|
||||
const h = harness();
|
||||
h.rendering.rebuild();
|
||||
const badge = h.state.pointById.get(base.stableId);
|
||||
h.state.gridAvailable = true;
|
||||
h.state.gridCells = [
|
||||
{
|
||||
id: wildlifeGridCellId(base.lat, base.lon, 2),
|
||||
lat: base.lat,
|
||||
lon: base.lon,
|
||||
cellCount: 10_000,
|
||||
},
|
||||
];
|
||||
h.rendering.rebuild();
|
||||
const glow = h.rendering.density.collection;
|
||||
assert.equal(h.state.sightingCount, 10_000);
|
||||
assert.equal(h.state.pointById.get(base.stableId), badge);
|
||||
h.state.gridAvailable = false;
|
||||
h.rendering.rebuild();
|
||||
assert.equal(h.state.pointById.get(base.stableId), badge);
|
||||
assert.equal(h.state.sightingCount, 1);
|
||||
assert.equal(glow.isDestroyed(), true);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('a selected observation remains rendered outside the viewport and observation window after a zoom', () => {
|
||||
const selected = { ...base, observedAt: NOW - 49 * 3_600_000 };
|
||||
const h = harness([]);
|
||||
h.state.selectedId = selected.stableId;
|
||||
h.state.selectedRow = selected;
|
||||
h.state.query.visibleBoxes = [{ south: 0, west: 0, north: 1, east: 1 }];
|
||||
h.rendering.rebuild();
|
||||
assert.deepEqual(h.state.renderedRows, [selected]);
|
||||
assert.equal(h.state.rowById.get(selected.stableId), selected);
|
||||
assert.equal(h.state.pointById.has(selected.stableId), true);
|
||||
h.state.band = 'regional';
|
||||
h.state.viewer.camera.positionCartographic = { height: 1_500_000 };
|
||||
h.rendering.rebuild();
|
||||
assert.equal(h.state.selectedId, selected.stableId);
|
||||
assert.equal(h.state.rowById.get(selected.stableId), selected);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('privacy ground staging has a bounded readiness listener and complete teardown', () => {
|
||||
const h = harness([
|
||||
{
|
||||
...base,
|
||||
obscured: true,
|
||||
cell: { south: 47.6, west: -122.4, north: 47.8, east: -122.2 },
|
||||
},
|
||||
]);
|
||||
h.rendering.rebuild();
|
||||
assert.equal(h.postRender.size, 1);
|
||||
assert.equal(h.scene.groundPrimitives.length, 2);
|
||||
for (let i = 0; i < WILDLIFE_GROUND_READY_MAX_CHECKS; i++)
|
||||
h.postRender.emit();
|
||||
assert.equal(h.postRender.size, 0);
|
||||
assert.equal(h.state.staging, null);
|
||||
assert.equal(h.scene.groundPrimitives.length, 0);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.postRender.size, 0);
|
||||
});
|
||||
|
||||
test('font readiness swaps images in place without billboard recreation or position work', () => {
|
||||
const listeners = new Set();
|
||||
let ready = false;
|
||||
const badgeSprites = {
|
||||
get: (group) =>
|
||||
`data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg"><circle r="${ready ? 9 : 8}" data-group="${group}"/></svg>`)}`,
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
const rows = [
|
||||
base,
|
||||
{ ...base, stableId: 'synthetic:mammal', iconic: 'Mammalia', lon: -120 },
|
||||
];
|
||||
const h = harness(rows, { badgeSprites });
|
||||
h.rendering.rebuild();
|
||||
const collection = h.state.points;
|
||||
const points = new Map(h.state.pointById);
|
||||
const before = new Map([...points].map(([id, point]) => [id, point.image]));
|
||||
h.counts.writes.length = 0;
|
||||
h.renders.length = 0;
|
||||
ready = true;
|
||||
for (const listener of listeners) listener();
|
||||
assert.equal(h.state.points, collection);
|
||||
assert.equal(h.counts.created, 2);
|
||||
assert.equal(h.counts.removed, 0);
|
||||
assert.equal(h.counts.cleared, 0);
|
||||
assert.deepEqual(h.counts.writes, ['image', 'image']);
|
||||
assert.equal(h.renders.length, 1);
|
||||
for (const [id, point] of points) {
|
||||
assert.equal(h.state.pointById.get(id), point);
|
||||
assert.notEqual(point.image, before.get(id));
|
||||
}
|
||||
h.counts.writes.length = 0;
|
||||
h.rows(rows.map((row) => ({ ...row, lat: row.lat + 0.01 })));
|
||||
h.rendering.rebuild();
|
||||
assert.deepEqual(h.counts.writes, ['position', 'position']);
|
||||
h.rendering.destroy();
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { wildlifeMeasure } from './performance.js';
|
||||
import { compareWildlifeIds } from './model.js';
|
||||
import { isRecentWildlifeSighting } from './records.js';
|
||||
import {
|
||||
ACARTIA_CREDIT,
|
||||
registerDynamicCredit,
|
||||
} from '../../data/dataCredits.js';
|
||||
import {
|
||||
WILDLIFE_OVERLAY_SOURCE_ID,
|
||||
WILDLIFE_SELECTED_SOURCE_ID,
|
||||
WILDLIFE_LABEL_SOURCE_ID,
|
||||
WILDLIFE_LABEL_SOURCE_OPTIONS,
|
||||
WILDLIFE_OVERLAY_SOURCE_OPTIONS,
|
||||
WILDLIFE_SELECTED_SOURCE_OPTIONS,
|
||||
buildWildlifeThumbnailEntry,
|
||||
buildWildlifeSelectedLabel,
|
||||
buildWildlifeLabelEntry,
|
||||
wildlifeCommonName,
|
||||
wildlifeDisplayName,
|
||||
wildlifeLabelScore,
|
||||
selectWildlifeAmbientRows,
|
||||
} from './cards.js';
|
||||
import {
|
||||
WILDLIFE_CARD_LIMIT,
|
||||
WILDLIFE_CARD_SHOW_MAX_METERS,
|
||||
WILDLIFE_CARD_HIDE_MAX_METERS,
|
||||
WILDLIFE_LABEL_NAME_SEPARATION_PX,
|
||||
WILDLIFE_SELECTED_LABEL_FADE_HIGH_METERS,
|
||||
WILDLIFE_LABEL_LIMIT,
|
||||
} from './policy.js';
|
||||
|
||||
const CONTEXT_LIMIT = 30;
|
||||
|
||||
/** Overlay sources, pinned observation selection and external focus. */
|
||||
export function createSelection({ state, services, parts }) {
|
||||
const overlay = services.overlayHost;
|
||||
const sourceIds = [
|
||||
WILDLIFE_OVERLAY_SOURCE_ID,
|
||||
WILDLIFE_LABEL_SOURCE_ID,
|
||||
WILDLIFE_SELECTED_SOURCE_ID,
|
||||
];
|
||||
const listeners = new Set();
|
||||
// Coordinates are refreshed only by the existing camera/data label pass.
|
||||
const labelScreens = new Map();
|
||||
const ambientSourceOptions = {
|
||||
...WILDLIFE_OVERLAY_SOURCE_OPTIONS,
|
||||
onVisibilityChange: (ids) => parts.rendering.setPhotoCardVisibility?.(ids),
|
||||
};
|
||||
|
||||
function renderBatch(fn) {
|
||||
parts.rendering.beginBatch?.();
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
parts.rendering.endBatch?.();
|
||||
}
|
||||
}
|
||||
|
||||
function selectionRow() {
|
||||
if (!state.enabled || !state.selectedId) return null;
|
||||
return state.selectedRow || state.rowById.get(state.selectedId) || null;
|
||||
}
|
||||
|
||||
function dispatch(listener, row, metadata) {
|
||||
try {
|
||||
listener(row, metadata);
|
||||
} catch {
|
||||
// A disconnected UI surface must not prevent map selection or teardown.
|
||||
}
|
||||
}
|
||||
|
||||
function notify({ explicitSelection = false } = {}) {
|
||||
const row = selectionRow();
|
||||
const metadata = { explicitSelection: explicitSelection === true };
|
||||
for (const listener of listeners) dispatch(listener, row, metadata);
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
if (!state.destroyed) listeners.add(listener);
|
||||
dispatch(listener, selectionRow(), { explicitSelection: false });
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
const cardActions = {
|
||||
select: selectAndFocus,
|
||||
hasRow: (id) => state.enabled && state.rowById.has(id),
|
||||
};
|
||||
|
||||
function publishAmbient() {
|
||||
return wildlifeMeasure('overlay-publish', publishAmbientEntries);
|
||||
}
|
||||
|
||||
function publishAmbientEntries() {
|
||||
if (!state.enabled || !overlay) return;
|
||||
overlay.setEntries(
|
||||
WILDLIFE_OVERLAY_SOURCE_ID,
|
||||
state.ambientRows.map((row) => ({
|
||||
...buildWildlifeThumbnailEntry(row, services.now(), {
|
||||
...cardActions,
|
||||
image: parts.photos.get(row),
|
||||
selected: row.stableId === state.selectedId,
|
||||
}),
|
||||
position: state.positions.get(row.stableId),
|
||||
})),
|
||||
ambientSourceOptions,
|
||||
);
|
||||
parts.rendering.requestRender('photos');
|
||||
}
|
||||
|
||||
function publishSelected() {
|
||||
return wildlifeMeasure('overlay-publish', publishSelectedEntries);
|
||||
}
|
||||
|
||||
function publishSelectedEntries() {
|
||||
if (!state.enabled || !overlay) return;
|
||||
const row = selectionRow();
|
||||
// The selected sighting's painted thumbnail already shows its name.
|
||||
const label =
|
||||
row && !parts.rendering.hasPhotoCard?.(row.stableId)
|
||||
? buildWildlifeSelectedLabel(row)
|
||||
: null;
|
||||
overlay.setEntries(
|
||||
WILDLIFE_SELECTED_SOURCE_ID,
|
||||
label
|
||||
? [
|
||||
{
|
||||
...label,
|
||||
position: state.positions.get(row.stableId),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
WILDLIFE_SELECTED_SOURCE_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
function publishLabels() {
|
||||
return wildlifeMeasure('overlay-publish', publishLabelsEntries);
|
||||
}
|
||||
|
||||
function publishLabelsEntries() {
|
||||
if (!state.enabled || !overlay) return;
|
||||
const names = new Map();
|
||||
const remember = (row) => {
|
||||
const screen = labelScreens.get(row.stableId);
|
||||
if (!screen) return;
|
||||
const name = wildlifeDisplayName(row);
|
||||
if (!names.has(name)) names.set(name, []);
|
||||
names.get(name).push(screen);
|
||||
};
|
||||
// Painted photos win regardless of score. Queued or rejected images do not
|
||||
// suppress neighbors. Selection wins over ambient names using the same cache.
|
||||
for (const row of state.ambientRows)
|
||||
if (parts.rendering.hasPhotoCard?.(row.stableId)) remember(row);
|
||||
const selected = selectionRow();
|
||||
if (
|
||||
selected &&
|
||||
(state.viewer.camera?.positionCartographic?.height ?? 0) <
|
||||
WILDLIFE_SELECTED_LABEL_FADE_HIGH_METERS
|
||||
)
|
||||
remember(selected);
|
||||
const entries = [];
|
||||
for (const row of state.labelRows || []) {
|
||||
if (
|
||||
row.stableId === state.selectedId ||
|
||||
parts.rendering.hasPhotoCard?.(row.stableId)
|
||||
)
|
||||
continue;
|
||||
const screen = labelScreens.get(row.stableId);
|
||||
const nearby = names.get(wildlifeDisplayName(row));
|
||||
if (
|
||||
screen &&
|
||||
nearby?.some(
|
||||
(other) =>
|
||||
(screen.x - other.x) ** 2 + (screen.y - other.y) ** 2 <
|
||||
WILDLIFE_LABEL_NAME_SEPARATION_PX ** 2,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const entry = buildWildlifeLabelEntry(row, {
|
||||
...cardActions,
|
||||
nowMs: services.now(),
|
||||
});
|
||||
if (!entry) continue;
|
||||
entries.push({ ...entry, position: state.positions.get(row.stableId) });
|
||||
remember(row);
|
||||
if (entries.length >= WILDLIFE_LABEL_LIMIT) break;
|
||||
}
|
||||
overlay.setEntries(
|
||||
WILDLIFE_LABEL_SOURCE_ID,
|
||||
entries,
|
||||
WILDLIFE_LABEL_SOURCE_OPTIONS,
|
||||
);
|
||||
parts.rendering.requestRender('labels');
|
||||
}
|
||||
|
||||
function refreshLabels() {
|
||||
if (!state.enabled || !overlay) return;
|
||||
const scene = state.viewer.scene;
|
||||
const horizon = parts.rendering.occluder();
|
||||
const width = scene.canvas?.clientWidth || 0;
|
||||
const height = scene.canvas?.clientHeight || 0;
|
||||
// Rank during camera/data passes; the host handles pixel collisions.
|
||||
const nowMs = services.now();
|
||||
const candidates = [];
|
||||
labelScreens.clear();
|
||||
const rows = new Map(state.renderedRows.map((row) => [row.stableId, row]));
|
||||
for (const row of state.ambientRows) rows.set(row.stableId, row);
|
||||
for (const row of rows.values()) {
|
||||
if (!wildlifeCommonName(row)) continue;
|
||||
const position = state.positions.get(row.stableId);
|
||||
if (!position || (horizon && horizon.isPointVisible(position) !== true))
|
||||
continue;
|
||||
const screen = services.project(scene, position);
|
||||
if (
|
||||
!screen ||
|
||||
screen.x < 0 ||
|
||||
screen.y < 0 ||
|
||||
screen.x > width ||
|
||||
screen.y > height
|
||||
)
|
||||
continue;
|
||||
labelScreens.set(row.stableId, { x: screen.x, y: screen.y });
|
||||
candidates.push(row);
|
||||
}
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
wildlifeLabelScore(b, nowMs) - wildlifeLabelScore(a, nowMs) ||
|
||||
compareWildlifeIds(a.stableId, b.stableId),
|
||||
);
|
||||
// Keep ranked candidates so a disappearing photo or cleared selection can
|
||||
// restore suppressed names without another projection pass. Cap after dedup.
|
||||
state.labelRows = candidates;
|
||||
publishLabels();
|
||||
}
|
||||
|
||||
function ambientCandidates(rows) {
|
||||
const scene = state.viewer.scene;
|
||||
const horizon = parts.rendering.occluder();
|
||||
const width = scene.canvas?.clientWidth ?? Infinity;
|
||||
const height = scene.canvas?.clientHeight ?? Infinity;
|
||||
const screens = new Map();
|
||||
const nowMs = services.now();
|
||||
const taxa = state.drawnParams?.taxa ?? state.taxa;
|
||||
const eligible = rows.filter((row) => {
|
||||
if (
|
||||
!row.photo ||
|
||||
row.obscured ||
|
||||
!isRecentWildlifeSighting(row, nowMs, taxa)
|
||||
)
|
||||
return false;
|
||||
const position = state.positions.get(row.stableId);
|
||||
if (!position || (horizon && horizon.isPointVisible(position) !== true))
|
||||
return false;
|
||||
const screen = services.project(scene, position);
|
||||
if (
|
||||
!screen ||
|
||||
screen.x < 0 ||
|
||||
screen.y < 0 ||
|
||||
screen.x > width ||
|
||||
screen.y > height
|
||||
)
|
||||
return false;
|
||||
screens.set(row.stableId, { x: screen.x, y: screen.y });
|
||||
return true;
|
||||
});
|
||||
return { eligible, screens, width, height };
|
||||
}
|
||||
|
||||
/** Snapshot before acquisition replaces its sample. Only currently visible
|
||||
* photos survive; acquisition also checks the observation window/params. */
|
||||
function retainedAmbientRows() {
|
||||
if (!state.enabled) return [];
|
||||
const height =
|
||||
state.viewer?.camera?.positionCartographic?.height ?? Infinity;
|
||||
if (height >= WILDLIFE_CARD_HIDE_MAX_METERS) return [];
|
||||
return ambientCandidates(state.ambientRows).eligible;
|
||||
}
|
||||
|
||||
function refreshAmbient() {
|
||||
if (!state.enabled) return;
|
||||
refreshLabels();
|
||||
const height =
|
||||
state.viewer?.camera?.positionCartographic?.height ?? Infinity;
|
||||
if (state.ambientActive) {
|
||||
if (height >= WILDLIFE_CARD_HIDE_MAX_METERS) state.ambientActive = false;
|
||||
} else if (height <= WILDLIFE_CARD_SHOW_MAX_METERS)
|
||||
state.ambientActive = true;
|
||||
if (!state.ambientActive) {
|
||||
state.ambientRows = [];
|
||||
state.ambientIds = [];
|
||||
parts.photos.setAmbient([]);
|
||||
publishAmbient();
|
||||
return;
|
||||
}
|
||||
// A bucket rebuild may hide an incumbent behind a newly sampled neighbor.
|
||||
// Retain its actual loaded row; rendering gives ambient IDs bucket priority.
|
||||
const byId = new Map(state.renderedRows.map((row) => [row.stableId, row]));
|
||||
for (const row of state.ambientRows) {
|
||||
const current = state.rowById.get(row.stableId);
|
||||
if (current) byId.set(row.stableId, current);
|
||||
}
|
||||
const candidates = ambientCandidates([...byId.values()]);
|
||||
state.ambientRows = selectWildlifeAmbientRows(
|
||||
candidates.eligible,
|
||||
state.ambientIds,
|
||||
services.now(),
|
||||
WILDLIFE_CARD_LIMIT,
|
||||
candidates,
|
||||
);
|
||||
state.ambientIds = state.ambientRows.map((row) => row.stableId);
|
||||
parts.photos.setAmbient(state.ambientRows);
|
||||
publishAmbient();
|
||||
}
|
||||
|
||||
// iNaturalist is static; Acartia enters the attribution popover on first use.
|
||||
function syncCredits() {
|
||||
if (!state.enabled) return;
|
||||
if (state.renderedRows.some((row) => row.origin === 'acartia'))
|
||||
registerDynamicCredit(state.viewer, ACARTIA_CREDIT);
|
||||
}
|
||||
|
||||
function registerContext(row) {
|
||||
if (!services.context?.registerEntityContext) return null;
|
||||
let carrier = state.carriers.get(row.stableId);
|
||||
if (!carrier) {
|
||||
carrier = { show: true };
|
||||
state.carriers.set(row.stableId, carrier);
|
||||
}
|
||||
carrier.__localBaseCartesian = state.positions.get(row.stableId);
|
||||
// A badge yielding to its photo or a neighbor is still a visible sighting.
|
||||
// Context visibility follows the anchor, independently of badge declutter.
|
||||
const horizon = parts.rendering.occluder();
|
||||
carrier.show =
|
||||
!horizon || horizon.isPointVisible(carrier.__localBaseCartesian) === true;
|
||||
const name = row.commonName || row.scientificName || t('Cetacean');
|
||||
// i18n-ignore: provider names and feed-supplied organism names are verbatim
|
||||
const label = `${name} · ${row.origin === 'acartia' ? 'Acartia' : 'iNaturalist'}`;
|
||||
services.context.registerEntityContext(carrier, {
|
||||
id: row.stableId,
|
||||
layerId: 'wildlife-sightings',
|
||||
layerName: t('Wildlife Sightings'),
|
||||
source: row.origin === 'acartia' ? 'Acartia' : 'iNaturalist',
|
||||
label,
|
||||
latitude: row.lat,
|
||||
longitude: row.lon,
|
||||
properties: {
|
||||
quality: row.quality,
|
||||
iconic: row.iconic,
|
||||
obscured: row.obscured,
|
||||
},
|
||||
select: () => selectAndFocus(row.stableId),
|
||||
});
|
||||
return carrier;
|
||||
}
|
||||
|
||||
function refreshContexts() {
|
||||
if (!services.context) return;
|
||||
const rows = state.renderedRows.slice(0, CONTEXT_LIMIT);
|
||||
const selected = selectionRow();
|
||||
if (selected && !rows.includes(selected)) rows.push(selected);
|
||||
const next = new Set(rows.map((row) => row.stableId));
|
||||
try {
|
||||
for (const row of rows) registerContext(row);
|
||||
const store = services.context.getContextStore?.();
|
||||
for (const id of state.contextIds) {
|
||||
if (!next.has(id)) {
|
||||
if (store?.entities?.get(id)?.layerId === 'wildlife-sightings')
|
||||
store.entities.delete(id);
|
||||
state.carriers.delete(id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Optional context surfaces must not prevent data or cards rendering.
|
||||
}
|
||||
state.contextIds = next;
|
||||
}
|
||||
|
||||
function select(id, { publishContext = true } = {}) {
|
||||
return renderBatch(() => selectRecord(id, { publishContext }));
|
||||
}
|
||||
|
||||
function selectRecord(id, { publishContext = true } = {}) {
|
||||
if (!state.enabled || !state.rowById.has(id)) return false;
|
||||
const row = state.rowById.get(id);
|
||||
state.selectedId = id;
|
||||
state.selectedRow = row;
|
||||
parts.rendering.refreshSelection();
|
||||
publishAmbient();
|
||||
publishLabels();
|
||||
publishSelected();
|
||||
try {
|
||||
const carrier = registerContext(row);
|
||||
state.contextIds.add(id);
|
||||
if (publishContext && carrier) {
|
||||
state.publishingContext = true;
|
||||
try {
|
||||
services.context?.selectEntityContext?.(carrier);
|
||||
} finally {
|
||||
state.publishingContext = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
state.publishingContext = false;
|
||||
}
|
||||
notify({ explicitSelection: true });
|
||||
parts.rendering.requestRender('select');
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectAndFocus(id, options = {}) {
|
||||
const row = state.rowById.get(typeof id === 'string' ? id : id?.stableId);
|
||||
if (!state.enabled || !row) return false;
|
||||
if (!select(row.stableId, options)) return false;
|
||||
services.focus?.requestWorldFocus?.({
|
||||
kind: row.obscured ? 'sighting-area' : 'sighting',
|
||||
id: row.stableId,
|
||||
label: row.commonName || row.scientificName || t('SIGHTING'),
|
||||
position: state.positions.get(row.stableId),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
return renderBatch(clearRecord);
|
||||
}
|
||||
|
||||
function clearRecord() {
|
||||
const hadSelection = state.selectedId !== null;
|
||||
state.selectedId = null;
|
||||
state.selectedRow = null;
|
||||
try {
|
||||
services.context?.clearSelectedEntityContextForLayer?.(
|
||||
'wildlife-sightings',
|
||||
);
|
||||
} catch {
|
||||
/* optional context host is already gone */
|
||||
}
|
||||
if (state.enabled) {
|
||||
if (hadSelection) publishAmbient();
|
||||
publishSelected();
|
||||
if (hadSelection) publishLabels();
|
||||
if (hadSelection) parts.rendering.requestRender('select');
|
||||
}
|
||||
notify();
|
||||
return hadSelection;
|
||||
}
|
||||
|
||||
function clearAndRebuild() {
|
||||
return renderBatch(() => {
|
||||
const hadSelection = clear();
|
||||
if (hadSelection) parts.rendering.refreshSelection();
|
||||
return hadSelection;
|
||||
});
|
||||
}
|
||||
|
||||
function install() {
|
||||
const onContext = (event) => {
|
||||
if (!state.enabled || state.publishingContext) return;
|
||||
const record =
|
||||
event.detail || services.context?.getSelectedEntityContext?.();
|
||||
if (record?.layerId === 'wildlife-sightings')
|
||||
selectAndFocus(record.id, { publishContext: false });
|
||||
};
|
||||
services.eventTarget?.addEventListener?.('gev:entity-selected', onContext);
|
||||
state.removeContext = () =>
|
||||
services.eventTarget?.removeEventListener?.(
|
||||
'gev:entity-selected',
|
||||
onContext,
|
||||
);
|
||||
if (typeof services.screenSpaceEventHandlerFactory !== 'function') return;
|
||||
state.clickHandler = services.screenSpaceEventHandlerFactory(state.viewer);
|
||||
state.clickHandler.setInputAction((click) => {
|
||||
if (!state.enabled || services.pointer?.isPointerFree?.() === false)
|
||||
return;
|
||||
const hit =
|
||||
overlay?.hitTest?.(click.position?.x, click.position?.y, {
|
||||
sourceId: WILDLIFE_SELECTED_SOURCE_ID,
|
||||
}) ||
|
||||
overlay?.hitTest?.(click.position?.x, click.position?.y, {
|
||||
sourceId: WILDLIFE_OVERLAY_SOURCE_ID,
|
||||
}) ||
|
||||
overlay?.hitTest?.(click.position?.x, click.position?.y, {
|
||||
sourceId: WILDLIFE_LABEL_SOURCE_ID,
|
||||
});
|
||||
if (hit) {
|
||||
hit.entry?.activate?.();
|
||||
return;
|
||||
}
|
||||
const picked = state.viewer.scene.pick(click.position);
|
||||
const id =
|
||||
services.picking?.resolvePickId?.(picked) ??
|
||||
picked?.id ??
|
||||
picked?.primitive?.id;
|
||||
if (
|
||||
typeof id === 'string' &&
|
||||
id.startsWith('wildlife:') &&
|
||||
selectAndFocus(id.slice(9))
|
||||
)
|
||||
return;
|
||||
if (
|
||||
id &&
|
||||
services.picking?.isOwnedByOtherLayer?.('wildlife-sightings', id)
|
||||
)
|
||||
return;
|
||||
clearAndRebuild();
|
||||
}, services.cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function remove() {
|
||||
state.clickHandler?.destroy();
|
||||
state.clickHandler = null;
|
||||
state.removeContext?.();
|
||||
state.removeContext = null;
|
||||
clear();
|
||||
try {
|
||||
services.context?.removeEntityContextsForLayer?.('wildlife-sightings');
|
||||
} catch {
|
||||
/* optional context host is already gone */
|
||||
}
|
||||
state.carriers.clear();
|
||||
state.contextIds.clear();
|
||||
state.ambientIds = [];
|
||||
state.ambientRows = [];
|
||||
state.ambientActive = false;
|
||||
state.labelRows = [];
|
||||
labelScreens.clear();
|
||||
for (const id of sourceIds) {
|
||||
overlay?.clearSource?.(id);
|
||||
overlay?.setVisible?.(id, false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectAndFocus,
|
||||
clear,
|
||||
clearAndRebuild,
|
||||
subscribe,
|
||||
notify,
|
||||
destroy() {
|
||||
listeners.clear();
|
||||
},
|
||||
install,
|
||||
remove,
|
||||
refreshAmbient,
|
||||
retainedAmbientRows,
|
||||
publishAmbient,
|
||||
publishSelected,
|
||||
photoCardsChanged() {
|
||||
renderBatch(() => {
|
||||
publishLabels();
|
||||
publishSelected();
|
||||
});
|
||||
},
|
||||
refreshContexts,
|
||||
syncCredits,
|
||||
sourceIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createSelection } from './selection.js';
|
||||
|
||||
const row = {
|
||||
stableId: 'inat:123',
|
||||
origin: 'inaturalist',
|
||||
lat: 48.1,
|
||||
lon: 8.2,
|
||||
commonName: 'Synthetic bird',
|
||||
iconic: 'Aves',
|
||||
observedAt: Date.UTC(2026, 10, 4),
|
||||
link: 'https://www.inaturalist.org/observations/123',
|
||||
};
|
||||
|
||||
function fixture(sighting = row) {
|
||||
const position = { x: 300, y: 300, z: 0 };
|
||||
const entries = new Map();
|
||||
const flights = [];
|
||||
const seen = [];
|
||||
const painted = new Set();
|
||||
let hit = null;
|
||||
let projections = 0;
|
||||
let picked = null;
|
||||
let click;
|
||||
const state = {
|
||||
enabled: true,
|
||||
destroyed: false,
|
||||
selectedId: null,
|
||||
selectedRow: null,
|
||||
rowById: new Map([[sighting.stableId, sighting]]),
|
||||
positions: new Map([[sighting.stableId, position]]),
|
||||
renderedRows: [sighting],
|
||||
carriers: new Map(),
|
||||
contextIds: new Set(),
|
||||
ambientIds: [],
|
||||
ambientRows: [],
|
||||
labelRows: [],
|
||||
viewer: {
|
||||
scene: {
|
||||
pick: () => picked,
|
||||
canvas: { clientWidth: 1200, clientHeight: 900 },
|
||||
},
|
||||
camera: { positionCartographic: { height: 30_000 } },
|
||||
},
|
||||
};
|
||||
const selection = createSelection({
|
||||
state,
|
||||
services: {
|
||||
overlayHost: {
|
||||
setEntries: (id, value) => entries.set(id, value),
|
||||
clearSource: (id) => entries.delete(id),
|
||||
setVisible() {},
|
||||
hitTest: (_x, _y, { sourceId }) =>
|
||||
hit?.sourceId === sourceId ? hit : null,
|
||||
},
|
||||
now: () => row.observedAt + 60_000,
|
||||
project: (_scene, anchor) => {
|
||||
projections++;
|
||||
return anchor;
|
||||
},
|
||||
focus: { requestWorldFocus: (request) => flights.push(request) },
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction: (fn) => (click = fn),
|
||||
destroy() {},
|
||||
}),
|
||||
cesium: { ScreenSpaceEventType: { LEFT_CLICK: 0 } },
|
||||
eventTarget: new EventTarget(),
|
||||
},
|
||||
parts: {
|
||||
rendering: {
|
||||
refreshSelection() {},
|
||||
requestRender() {},
|
||||
occluder: () => null,
|
||||
hasPhotoCard: (id) => painted.has(id),
|
||||
},
|
||||
photos: { get: () => ({ width: 75, height: 75 }), setAmbient() {} },
|
||||
},
|
||||
});
|
||||
selection.subscribe((value, metadata) => seen.push({ value, metadata }));
|
||||
return {
|
||||
state,
|
||||
selection,
|
||||
position,
|
||||
flights,
|
||||
entries,
|
||||
seen,
|
||||
get projections() {
|
||||
return projections;
|
||||
},
|
||||
hit(sourceId, entryId) {
|
||||
const entry = entries
|
||||
.get(sourceId)
|
||||
?.find((entry) => entry.id === entryId);
|
||||
hit = entry ? { sourceId, entryId, entry } : null;
|
||||
},
|
||||
paint(ids) {
|
||||
painted.clear();
|
||||
for (const id of ids) painted.add(id);
|
||||
selection.photoCardsChanged();
|
||||
},
|
||||
click(id = `wildlife:${sighting.stableId}`) {
|
||||
picked = id ? { id } : null;
|
||||
click({ position: { x: 10, y: 20 } });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a real sighting click pins its row, opens its label and focuses its true position', () => {
|
||||
const f = fixture();
|
||||
f.selection.install();
|
||||
f.click();
|
||||
assert.equal(f.state.selectedId, row.stableId);
|
||||
assert.equal(f.state.selectedRow, row);
|
||||
assert.equal(f.flights.length, 1);
|
||||
assert.deepEqual(f.flights[0], {
|
||||
kind: 'sighting',
|
||||
id: row.stableId,
|
||||
label: row.commonName,
|
||||
position: f.position,
|
||||
});
|
||||
assert.deepEqual(f.seen.at(-1), {
|
||||
value: row,
|
||||
metadata: { explicitSelection: true },
|
||||
});
|
||||
assert.equal(f.entries.get('wildlife-sightings-selected').length, 1);
|
||||
f.selection.remove();
|
||||
});
|
||||
|
||||
test('selected photo and name cards use the selected lane, select on click and never open links themselves', () => {
|
||||
for (const photo of [
|
||||
null,
|
||||
{
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpg',
|
||||
licence: 'cc-by',
|
||||
attribution: 'Synthetic Photographer',
|
||||
},
|
||||
]) {
|
||||
const sighting = { ...row, photo };
|
||||
const f = fixture(sighting);
|
||||
f.selection.install();
|
||||
f.selection.refreshAmbient();
|
||||
const source = photo ? 'wildlife-sightings' : 'wildlife-sightings-labels';
|
||||
const id = photo
|
||||
? 'sighting:inat:123'
|
||||
: 'wildlife-sightings-label:inat:123';
|
||||
f.hit(source, id);
|
||||
f.click();
|
||||
assert.equal(f.flights.length, 1);
|
||||
if (photo) f.paint([row.stableId]);
|
||||
const selectedSource = photo ? source : 'wildlife-sightings-selected';
|
||||
const card = f.entries.get(selectedSource)[0];
|
||||
assert.equal(card.selected, true);
|
||||
assert.equal(card.paintLane, 'selected');
|
||||
assert.ok(
|
||||
!(card.details || []).some((line) => line.includes('↗')),
|
||||
'the anchored photo card owns the single source link',
|
||||
);
|
||||
assert.match(card.accessibilityLabel, /Synthetic bird/);
|
||||
if (photo) {
|
||||
assert.equal(card.variant, 'thumbnail');
|
||||
assert.ok(card.image);
|
||||
assert.deepEqual(f.entries.get('wildlife-sightings-selected'), []);
|
||||
}
|
||||
f.hit(selectedSource, card.id);
|
||||
f.click('other:aircraft');
|
||||
assert.equal(f.flights.length, photo ? 2 : 1);
|
||||
f.selection.clearAndRebuild();
|
||||
if (photo) {
|
||||
const ambient = f.entries.get(source)[0];
|
||||
assert.equal(ambient.selected, undefined);
|
||||
assert.deepEqual(ambient.details, ['© Synthetic Photographer']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('nearby duplicate display names yield to painted cards and higher-ranked labels using cached screens', () => {
|
||||
const low = {
|
||||
...row,
|
||||
stableId: 'inat:low',
|
||||
quality: 'research',
|
||||
photo: {
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpg',
|
||||
licence: 'cc-by',
|
||||
attribution: 'Synthetic Photographer',
|
||||
},
|
||||
};
|
||||
const high = { ...low, stableId: 'inat:high', favesCount: 50 };
|
||||
const far = { ...row, stableId: 'inat:far' };
|
||||
const different = {
|
||||
...row,
|
||||
stableId: 'inat:different',
|
||||
commonName: 'Other bird',
|
||||
};
|
||||
const f = fixture();
|
||||
f.state.renderedRows = [low, high, far, different];
|
||||
f.state.rowById = new Map(
|
||||
f.state.renderedRows.map((row) => [row.stableId, row]),
|
||||
);
|
||||
f.state.positions = new Map([
|
||||
[low.stableId, { x: 300, y: 300 }],
|
||||
[high.stableId, { x: 379, y: 300 }],
|
||||
[far.stableId, { x: 460, y: 300 }],
|
||||
[different.stableId, { x: 310, y: 300 }],
|
||||
]);
|
||||
const labelIds = () =>
|
||||
f.entries.get('wildlife-sightings-labels').map((entry) => entry.id);
|
||||
f.selection.refreshAmbient();
|
||||
assert.ok(!labelIds().includes('wildlife-sightings-label:inat:low'));
|
||||
assert.ok(labelIds().includes('wildlife-sightings-label:inat:high'));
|
||||
assert.ok(
|
||||
labelIds().includes('wildlife-sightings-label:inat:far'),
|
||||
'81px names survive',
|
||||
);
|
||||
assert.ok(labelIds().includes('wildlife-sightings-label:inat:different'));
|
||||
const projections = f.projections;
|
||||
f.state.ambientRows = [low];
|
||||
f.paint([low.stableId]);
|
||||
assert.ok(
|
||||
!labelIds().includes('wildlife-sightings-label:inat:high'),
|
||||
'a painted lower-score card wins',
|
||||
);
|
||||
assert.equal(
|
||||
f.projections,
|
||||
projections,
|
||||
'paint membership uses the last label pass',
|
||||
);
|
||||
f.paint([]);
|
||||
assert.ok(
|
||||
labelIds().includes('wildlife-sightings-label:inat:high'),
|
||||
'a queued/unpainted card suppresses nothing',
|
||||
);
|
||||
f.selection.selectAndFocus(low.stableId);
|
||||
assert.ok(
|
||||
!labelIds().includes('wildlife-sightings-label:inat:high'),
|
||||
'selected labels outrank ambient names',
|
||||
);
|
||||
assert.equal(
|
||||
f.projections,
|
||||
projections,
|
||||
'selection never projects the population',
|
||||
);
|
||||
f.selection.clearAndRebuild();
|
||||
f.state.positions.set(high.stableId, { x: 600, y: 300 });
|
||||
f.selection.refreshAmbient();
|
||||
assert.ok(
|
||||
labelIds().includes('wildlife-sightings-label:inat:low'),
|
||||
'moveEnd admits separated names',
|
||||
);
|
||||
});
|
||||
|
||||
test('selection remains published while a new sample or camera pass replaces rendered lookup rows', () => {
|
||||
const f = fixture();
|
||||
f.selection.selectAndFocus(row.stableId);
|
||||
f.state.rowById.clear();
|
||||
f.state.renderedRows = [];
|
||||
f.selection.publishSelected();
|
||||
f.selection.notify();
|
||||
assert.equal(f.seen.at(-1).value, row);
|
||||
assert.equal(
|
||||
f.entries.get('wildlife-sightings-selected')[0].position,
|
||||
f.position,
|
||||
);
|
||||
assert.equal(
|
||||
f.flights.length,
|
||||
1,
|
||||
'refresh notifications never start another flight',
|
||||
);
|
||||
f.selection.clearAndRebuild();
|
||||
assert.equal(f.state.selectedId, null);
|
||||
assert.equal(f.state.selectedRow, null);
|
||||
assert.equal(f.seen.at(-1).value, null);
|
||||
assert.deepEqual(f.entries.get('wildlife-sightings-selected'), []);
|
||||
});
|
||||
|
||||
test('an obscured observation focuses only its existing privacy-safe anchor', () => {
|
||||
const obscured = { ...row, obscured: true, lat: 48.1, lon: 8.3 };
|
||||
const f = fixture(obscured);
|
||||
assert.equal(f.selection.selectAndFocus(obscured.stableId), true);
|
||||
assert.equal(f.flights[0].kind, 'sighting-area');
|
||||
assert.equal(f.flights[0].position, f.position);
|
||||
assert.equal(f.state.selectedRow, obscured);
|
||||
});
|
||||
|
||||
test('empty clicks and layer removal release the selected row and its overlay', () => {
|
||||
const f = fixture();
|
||||
f.selection.install();
|
||||
f.click();
|
||||
f.click(null);
|
||||
assert.equal(f.state.selectedRow, null);
|
||||
assert.equal(f.state.selectedId, null);
|
||||
f.click();
|
||||
f.state.enabled = false;
|
||||
f.selection.remove();
|
||||
assert.equal(f.state.selectedRow, null);
|
||||
assert.equal(f.state.selectedId, null);
|
||||
assert.equal(f.seen.at(-1).value, null);
|
||||
assert.equal(f.entries.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
import { mergeSightings } from './records.js';
|
||||
import { wildlifeMeasure } from './performance.js';
|
||||
import {
|
||||
ACARTIA_GUARD_BOX,
|
||||
TAXA_PRESETS,
|
||||
WILDLIFE_RETRY_AFTER_MAX_SEC,
|
||||
} from './policy.js';
|
||||
import { boxIntersects, retryDelayMs } from './viewport.js';
|
||||
import {
|
||||
WILDLIFE_GRID_MAX_ZOOM,
|
||||
WILDLIFE_GRID_TILE_CAP,
|
||||
wildlifeGridCellId,
|
||||
} from './gridViewport.js';
|
||||
|
||||
// Leave room for projected observations while bounding malformed proxy replies.
|
||||
const SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024;
|
||||
// The proxy owns upstream pacing; observation requests precede these workers.
|
||||
export const WILDLIFE_GRID_CONCURRENCY = 4;
|
||||
export const WILDLIFE_GRID_CACHE_MAX = 400;
|
||||
export const WILDLIFE_GRID_TTL_MS = 15 * 60_000;
|
||||
// Match the proxy's one-hour stale allowance. Keep the original data time so
|
||||
// repeated refresh failures never extend a tile's usable life.
|
||||
const GRID_STALE_MAX_MS = 3_600_000;
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
function validTile(tile) {
|
||||
return (
|
||||
!!tile &&
|
||||
Number.isInteger(tile.z) &&
|
||||
tile.z >= 0 &&
|
||||
tile.z <= WILDLIFE_GRID_MAX_ZOOM &&
|
||||
Number.isInteger(tile.x) &&
|
||||
Number.isInteger(tile.y) &&
|
||||
tile.x >= 0 &&
|
||||
tile.y >= 0 &&
|
||||
tile.x < 2 ** tile.z &&
|
||||
tile.y < 2 ** tile.z
|
||||
);
|
||||
}
|
||||
|
||||
function validProjectedGrid(payload) {
|
||||
return (
|
||||
Number.isFinite(payload?.fetchedAt) &&
|
||||
Number.isSafeInteger(payload.totalResults) &&
|
||||
payload.totalResults >= 0 &&
|
||||
(payload.stale === undefined || typeof payload.stale === 'boolean') &&
|
||||
Array.isArray(payload.cells) &&
|
||||
payload.cells.length <= 4096 &&
|
||||
payload.cells.every(
|
||||
(cell) =>
|
||||
cell &&
|
||||
Object.keys(cell).sort().join(',') === 'cellCount,id,lat,lon' &&
|
||||
/^grid:\d+\/\d+\/\d+:\d+:\d+$/.test(cell.id) &&
|
||||
Number.isSafeInteger(cell.cellCount) &&
|
||||
cell.cellCount > 0 &&
|
||||
Number.isFinite(cell.lat) &&
|
||||
Math.abs(cell.lat) <= 90 &&
|
||||
Number.isFinite(cell.lon) &&
|
||||
Math.abs(cell.lon) <= 180 &&
|
||||
wildlifeGridCellId(
|
||||
cell.lat,
|
||||
cell.lon,
|
||||
Number(cell.id.split(':')[1].split('/')[0]),
|
||||
) === cell.id,
|
||||
) &&
|
||||
new Set(payload.cells.map((cell) => cell.id)).size ===
|
||||
payload.cells.length &&
|
||||
payload.cells.reduce((sum, cell) => sum + cell.cellCount, 0) ===
|
||||
payload.totalResults
|
||||
);
|
||||
}
|
||||
|
||||
function retryAfterSeconds(value, nowMs) {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
const text = value.trim();
|
||||
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
||||
const seconds = Number(text);
|
||||
return Number.isFinite(seconds)
|
||||
? Math.min(WILDLIFE_RETRY_AFTER_MAX_SEC, Math.ceil(seconds))
|
||||
: null;
|
||||
}
|
||||
// Reject malformed numeric values rather than letting Date.parse interpret
|
||||
// them as calendar dates. HTTP dates include a named weekday or month.
|
||||
if (!/[a-z]/i.test(text)) return null;
|
||||
const deadline = Date.parse(text);
|
||||
return Number.isFinite(deadline) && Number.isFinite(nowMs)
|
||||
? Math.min(
|
||||
WILDLIFE_RETRY_AFTER_MAX_SEC,
|
||||
Math.max(0, Math.ceil((deadline - nowMs) / 1000)),
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
function validProjectedRows(rows, origin) {
|
||||
return (
|
||||
Array.isArray(rows) &&
|
||||
rows.every(
|
||||
(row) =>
|
||||
row &&
|
||||
typeof row.stableId === 'string' &&
|
||||
row.stableId.startsWith(
|
||||
origin === 'inaturalist' ? 'inat:' : 'acartia:',
|
||||
) &&
|
||||
row.origin === origin &&
|
||||
Number.isFinite(row.lat) &&
|
||||
Math.abs(row.lat) <= 90 &&
|
||||
Number.isFinite(row.lon) &&
|
||||
Math.abs(row.lon) <= 180,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the status a failure reports. The proxy marks upstream failures
|
||||
* with X-Upstream-Status; without it the HTTP status is the signal. */
|
||||
function wildlifeFailureStatus(error) {
|
||||
return (
|
||||
error?.upstreamStatus ||
|
||||
error?.status ||
|
||||
Number(/HTTP (\d{3})/.exec(error?.message || '')?.[1]) ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a failure asks clients to wait before the next request. */
|
||||
export function wildlifeFailureRetryable(error) {
|
||||
const status = wildlifeFailureStatus(error);
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
/** Return a Retry-After in milliseconds, capped at one day, or null when
|
||||
* it is missing, negative or not finite. */
|
||||
export function wildlifeRetryAfterMs(seconds) {
|
||||
return Number.isFinite(seconds) && seconds >= 0
|
||||
? Math.min(WILDLIFE_RETRY_AFTER_MAX_SEC, seconds) * 1000
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Return how long a failure asks the layer to wait, or null for none:
|
||||
* 429 and 5xx wait for their Retry-After, else the default backoff after
|
||||
* `previousMs`; other failures may retry on the next view. */
|
||||
export function wildlifeFailureDelayMs(error, previousMs = 0) {
|
||||
if (!error || !wildlifeFailureRetryable(error)) return null;
|
||||
return wildlifeRetryAfterMs(error.retryAfterSec) ?? retryDelayMs(previousMs);
|
||||
}
|
||||
|
||||
/** Flatten failures, expanding a group into every request that failed. */
|
||||
export function wildlifeFailures(errors) {
|
||||
return [errors]
|
||||
.flat()
|
||||
.filter(Boolean)
|
||||
.flatMap((error) =>
|
||||
Array.isArray(error.failures) && error.failures.length
|
||||
? error.failures
|
||||
: [error],
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the failure with the longest effective delay (the first failure
|
||||
* when none waits), carrying every failure so the caller can recompute. */
|
||||
export function preferredWildlifeFailure(errors, previousMs = 0) {
|
||||
const failures = wildlifeFailures(errors);
|
||||
if (!failures.length) return null;
|
||||
let chosen = failures[0];
|
||||
let longest = wildlifeFailureDelayMs(chosen, previousMs) ?? -1;
|
||||
for (const error of failures.slice(1)) {
|
||||
const delay = wildlifeFailureDelayMs(error, previousMs) ?? -1;
|
||||
if (delay > longest) [chosen, longest] = [error, delay];
|
||||
}
|
||||
if (failures.length === 1) return chosen;
|
||||
// Grid errors are shared between consumers of one tile request; describe
|
||||
// the group with a new error rather than mutating any of them.
|
||||
return Object.assign(new Error(chosen.message, { cause: chosen }), {
|
||||
...(chosen.status ? { status: chosen.status } : {}),
|
||||
...(chosen.upstreamStatus ? { upstreamStatus: chosen.upstreamStatus } : {}),
|
||||
...(Number.isFinite(chosen.retryAfterSec)
|
||||
? { retryAfterSec: chosen.retryAfterSec }
|
||||
: {}),
|
||||
failures,
|
||||
});
|
||||
}
|
||||
|
||||
/** Merge occupied cells incrementally; neighboring UTFGrid hit tiles can
|
||||
* report the same geographic cell, so source ids remain the dedupe key. */
|
||||
export function combineWildlifeGridSnapshots(snapshots, error = null) {
|
||||
const cells = [
|
||||
...new Map(
|
||||
snapshots
|
||||
.flatMap((snapshot) => snapshot.cells)
|
||||
.map((cell) => [cell.id, cell]),
|
||||
).values(),
|
||||
];
|
||||
const totalResults = cells.reduce((sum, cell) => sum + cell.cellCount, 0);
|
||||
if (!Number.isSafeInteger(totalResults))
|
||||
throw new Error('Malformed iNaturalist grid response');
|
||||
// A failure's Retry-After counts only when its status asks for a wait.
|
||||
const retryAfter = [
|
||||
...snapshots.map((snapshot) => snapshot.retryAfterSec),
|
||||
...wildlifeFailures(error)
|
||||
.filter(wildlifeFailureRetryable)
|
||||
.map((failure) => failure.retryAfterSec),
|
||||
].filter(Number.isFinite);
|
||||
const stale = snapshots.some((snapshot) => snapshot.stale === true);
|
||||
return {
|
||||
cells,
|
||||
totalResults,
|
||||
fetchedAt: Math.min(...snapshots.map((snapshot) => snapshot.fetchedAt)),
|
||||
stale,
|
||||
...(stale
|
||||
? {
|
||||
upstreamStatus: Math.max(
|
||||
0,
|
||||
...snapshots.map((snapshot) => snapshot.upstreamStatus || 0),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(error ? { error } : {}),
|
||||
...(retryAfter.length ? { retryAfterSec: Math.max(...retryAfter) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Request both provider proxies when coverage calls for it. API-specific
|
||||
* parameters (Cetacea, photo requirement, observation window) belong to the proxy. */
|
||||
export function createWildlifeSightingsSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
const gridCache = new Map();
|
||||
const gridInFlight = new Map();
|
||||
async function readProxy(url, provider, signal, { grid = false } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(url, { signal });
|
||||
signal?.throwIfAborted();
|
||||
const retryAfterSec = retryAfterSeconds(
|
||||
response.headers?.get?.('Retry-After'),
|
||||
now(),
|
||||
);
|
||||
const upstreamStatus =
|
||||
Number(response.headers?.get?.('X-Upstream-Status')) || 0;
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel?.();
|
||||
signal?.throwIfAborted();
|
||||
const error = new Error(
|
||||
provider === 'iNaturalist' && upstreamStatus === 429
|
||||
? 'iNaturalist rate limited'
|
||||
: `${provider} HTTP ${response.status}`,
|
||||
);
|
||||
error.status = response.status;
|
||||
error.upstreamStatus = upstreamStatus;
|
||||
if (retryAfterSec !== null) error.retryAfterSec = retryAfterSec;
|
||||
throw error;
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = await readResponseJsonCapped(
|
||||
response,
|
||||
SNAPSHOT_MAX_BYTES,
|
||||
signal,
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
throw new Error(`Malformed ${provider} response`, { cause: error });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
(payload.stale === true ||
|
||||
response.headers?.get?.('X-Data-Stale') === 'true')
|
||||
) {
|
||||
payload.stale = true;
|
||||
if (retryAfterSec !== null) payload.retryAfterSec = retryAfterSec;
|
||||
payload.upstreamStatus = upstreamStatus;
|
||||
}
|
||||
if (grid) {
|
||||
if (!wildlifeMeasure('tile-decode', () => validProjectedGrid(payload)))
|
||||
throw new Error('Malformed iNaturalist grid response');
|
||||
return payload;
|
||||
}
|
||||
const origin = provider === 'iNaturalist' ? 'inaturalist' : 'acartia';
|
||||
if (
|
||||
!validProjectedRows(payload?.rows, origin) ||
|
||||
(origin === 'inaturalist' &&
|
||||
(!Number.isSafeInteger(payload.totalResults) ||
|
||||
payload.totalResults < 0))
|
||||
) {
|
||||
throw new Error(`Malformed ${provider} response`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function readGridTile(key, url, metadata, signal) {
|
||||
signal?.throwIfAborted();
|
||||
let pending = gridInFlight.get(key);
|
||||
if (!pending || pending.controller.signal.aborted) {
|
||||
const controller = new AbortController();
|
||||
pending = { controller, consumers: new Set(), promise: null };
|
||||
const entry = pending;
|
||||
pending.promise = readProxy(url, 'iNaturalist', controller.signal, {
|
||||
grid: true,
|
||||
})
|
||||
.then((value) => {
|
||||
gridCache.delete(key);
|
||||
gridCache.set(key, {
|
||||
value,
|
||||
savedAt: value.stale ? Math.min(now(), value.fetchedAt) : now(),
|
||||
...metadata,
|
||||
});
|
||||
while (gridCache.size > WILDLIFE_GRID_CACHE_MAX)
|
||||
gridCache.delete(gridCache.keys().next().value);
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
if (gridInFlight.get(key) === entry) gridInFlight.delete(key);
|
||||
});
|
||||
gridInFlight.set(key, pending);
|
||||
}
|
||||
const consumer = {};
|
||||
pending.consumers.add(consumer);
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (aborted = false) => {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
pending.consumers.delete(consumer);
|
||||
if (aborted && !pending.consumers.size) pending.controller.abort();
|
||||
};
|
||||
const abort = () => {
|
||||
finish(true);
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
pending.promise.then(
|
||||
(value) => {
|
||||
finish();
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
finish();
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Publish cached tiles immediately and refresh their occupied cells. */
|
||||
async function getGridSnapshot({
|
||||
tiles,
|
||||
confirmed = false,
|
||||
taxa = 'all',
|
||||
signal,
|
||||
onTile,
|
||||
} = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (
|
||||
!Array.isArray(tiles) ||
|
||||
!tiles.length ||
|
||||
tiles.length > WILDLIFE_GRID_TILE_CAP ||
|
||||
!tiles.every(validTile)
|
||||
)
|
||||
throw new TypeError('Wildlife grid requires visible tiles');
|
||||
const preset = TAXA_PRESETS.some((item) => item.value === taxa)
|
||||
? taxa
|
||||
: 'all';
|
||||
const unique = [
|
||||
...new Map(
|
||||
tiles.map((tile) => [`${tile.z}/${tile.x}/${tile.y}`, tile]),
|
||||
).values(),
|
||||
].sort((a, b) => a.z - b.z || a.x - b.x || a.y - b.y);
|
||||
const windowKey = Math.floor(
|
||||
(now() - (preset === 'whales' ? 168 : 48) * HOUR_MS) / HOUR_MS,
|
||||
);
|
||||
const snapshots = new Array(unique.length);
|
||||
const failures = new Array(unique.length);
|
||||
const usable = (entry) => {
|
||||
const age = now() - entry.value.fetchedAt;
|
||||
return age >= 0 && age <= GRID_STALE_MAX_MS;
|
||||
};
|
||||
const touch = (key, entry) => {
|
||||
gridCache.delete(key);
|
||||
gridCache.set(key, entry);
|
||||
};
|
||||
function cachedFallback(tile, tileKey) {
|
||||
let exact = null;
|
||||
const adjacent = [];
|
||||
for (const [key, entry] of gridCache) {
|
||||
if (!usable(entry)) continue;
|
||||
if (key.startsWith(tileKey)) {
|
||||
if (!exact || entry.savedAt > exact.savedAt) exact = entry;
|
||||
} else if (
|
||||
entry.preset === preset &&
|
||||
entry.confirmed === confirmed &&
|
||||
Math.abs(entry.tile.z - tile.z) === 1 &&
|
||||
(entry.tile.z < tile.z
|
||||
? entry.tile.x === Math.floor(tile.x / 2) &&
|
||||
entry.tile.y === Math.floor(tile.y / 2)
|
||||
: Math.floor(entry.tile.x / 2) === tile.x &&
|
||||
Math.floor(entry.tile.y / 2) === tile.y)
|
||||
) {
|
||||
adjacent.push(entry);
|
||||
}
|
||||
}
|
||||
if (exact) return { ...exact.value, stale: true };
|
||||
if (!adjacent.length) return null;
|
||||
// Preserve cached geographic centres and counts while exact tiles load.
|
||||
const newest = [
|
||||
...new Map(
|
||||
adjacent
|
||||
.sort((a, b) => a.savedAt - b.savedAt)
|
||||
.map((entry) => [
|
||||
`${entry.tile.z}/${entry.tile.x}/${entry.tile.y}`,
|
||||
entry,
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const parent = newest.find((entry) => entry.tile.z < tile.z);
|
||||
const values = parent
|
||||
? [parent.value]
|
||||
: newest.map((entry) => entry.value);
|
||||
const value = combineWildlifeGridSnapshots(values);
|
||||
value.cells = value.cells.filter((cell) =>
|
||||
wildlifeGridCellId(cell.lat, cell.lon, tile.z)?.startsWith(
|
||||
`grid:${tile.z}/${tile.x}/${tile.y}:`,
|
||||
),
|
||||
);
|
||||
value.totalResults = value.cells.reduce(
|
||||
(sum, cell) => sum + cell.cellCount,
|
||||
0,
|
||||
);
|
||||
return { ...value, stale: true };
|
||||
}
|
||||
function publish(index, value) {
|
||||
signal?.throwIfAborted();
|
||||
snapshots[index] = value;
|
||||
onTile?.(value, { tile: unique[index] });
|
||||
}
|
||||
const requests = [];
|
||||
// Publish all cached coverage before slow misses can occupy the workers.
|
||||
for (let index = 0; index < unique.length; index += 1) {
|
||||
const tile = unique[index];
|
||||
const tileKey = `${tile.z}/${tile.x}/${tile.y}:${preset}:${confirmed ? 'research' : 'all'}:`;
|
||||
const key = `${tileKey}${windowKey}`;
|
||||
const cached = gridCache.get(key);
|
||||
const age = cached ? now() - cached.savedAt : Infinity;
|
||||
if (
|
||||
age >= 0 &&
|
||||
age < WILDLIFE_GRID_TTL_MS &&
|
||||
!cached.value.stale &&
|
||||
usable(cached)
|
||||
) {
|
||||
touch(key, cached);
|
||||
publish(index, cached.value);
|
||||
continue;
|
||||
}
|
||||
const fallback = cachedFallback(tile, tileKey);
|
||||
if (fallback) publish(index, fallback);
|
||||
requests.push({ index, tile, key });
|
||||
}
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < requests.length) {
|
||||
signal?.throwIfAborted();
|
||||
const { index, tile, key } = requests[cursor++];
|
||||
const query = new URLSearchParams({
|
||||
z: String(tile.z),
|
||||
x: String(tile.x),
|
||||
y: String(tile.y),
|
||||
taxa: preset,
|
||||
});
|
||||
if (confirmed) query.set('quality', 'research');
|
||||
try {
|
||||
let value = await readGridTile(
|
||||
key,
|
||||
`/api/inaturalist/grid?${query}`,
|
||||
{ tile, preset, confirmed },
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
publish(index, value);
|
||||
if (value.stale && !value.upstreamStatus) {
|
||||
// A server SWR response paints immediately. Join its existing
|
||||
// refresh so this same view receives the new cells when ready.
|
||||
query.set('refresh', 'wait');
|
||||
value = await readGridTile(
|
||||
key,
|
||||
`/api/inaturalist/grid?${query}`,
|
||||
{ tile, preset, confirmed },
|
||||
signal,
|
||||
);
|
||||
publish(index, value);
|
||||
}
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
failures[index] = error;
|
||||
// Other tiles can already be cached or in flight. Keep their
|
||||
// progressive coverage even if one upstream request fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(WILDLIFE_GRID_CONCURRENCY, requests.length) },
|
||||
worker,
|
||||
),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const failure = preferredWildlifeFailure(failures);
|
||||
if (!snapshots.some(Boolean) && failure) throw failure;
|
||||
return combineWildlifeGridSnapshots(snapshots.filter(Boolean), failure);
|
||||
}
|
||||
|
||||
/** Merge recent provider rows with source availability and cache age. */
|
||||
async function getSnapshotDetail({
|
||||
signal,
|
||||
box = null,
|
||||
boxes = null,
|
||||
scope = 'view',
|
||||
confirmed = false,
|
||||
taxa = 'all',
|
||||
} = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const preset = TAXA_PRESETS.some((item) => item.value === taxa)
|
||||
? taxa
|
||||
: 'all';
|
||||
const requestBoxes =
|
||||
scope === 'global'
|
||||
? [null]
|
||||
: Array.isArray(boxes)
|
||||
? boxes
|
||||
: box
|
||||
? [box]
|
||||
: [];
|
||||
if (!requestBoxes.length)
|
||||
throw new TypeError('Wildlife sightings require a view box');
|
||||
const useAcartia =
|
||||
scope !== 'global' &&
|
||||
['all', 'Mammalia', 'whales'].includes(preset) &&
|
||||
requestBoxes.some((bounds) => boxIntersects(bounds, ACARTIA_GUARD_BOX));
|
||||
|
||||
// Each box settles independently: one failed antimeridian half must not
|
||||
// discard the other half, and neither may discard a good Acartia reply.
|
||||
const inatRequest = Promise.allSettled(
|
||||
requestBoxes.map((bounds) => {
|
||||
const params = new URLSearchParams({
|
||||
scope: scope === 'global' ? 'global' : 'view',
|
||||
taxa: preset,
|
||||
});
|
||||
if (confirmed) params.set('quality', 'research');
|
||||
if (scope !== 'global') {
|
||||
for (const key of ['south', 'west', 'north', 'east'])
|
||||
params.set(key, String(bounds[key]));
|
||||
}
|
||||
return readProxy(
|
||||
`/api/inaturalist/observations?${params}`,
|
||||
'iNaturalist',
|
||||
signal,
|
||||
);
|
||||
}),
|
||||
);
|
||||
const acartiaRequest = useAcartia
|
||||
? readProxy('/api/acartia/sightings', 'Acartia', signal)
|
||||
: Promise.resolve(null);
|
||||
const [inatResult, acartiaResult] = await Promise.allSettled([
|
||||
inatRequest,
|
||||
acartiaRequest,
|
||||
]);
|
||||
signal?.throwIfAborted();
|
||||
const inatSnapshots = [];
|
||||
const failedBoxes = [];
|
||||
const inatErrors = [];
|
||||
inatResult.value.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') inatSnapshots.push(result.value);
|
||||
else {
|
||||
failedBoxes.push(requestBoxes[index]);
|
||||
inatErrors.push(result.reason);
|
||||
}
|
||||
});
|
||||
// Keep every failed box; the layer waits for the longest of them.
|
||||
const inatError = preferredWildlifeFailure(inatErrors);
|
||||
// Fail outright only when nothing succeeded; otherwise draw what arrived
|
||||
// and report the failed source in its per-source status.
|
||||
const acartiaError =
|
||||
useAcartia && acartiaResult.status === 'rejected'
|
||||
? acartiaResult.reason
|
||||
: null;
|
||||
if (
|
||||
!inatSnapshots.length &&
|
||||
(!useAcartia || acartiaResult.status !== 'fulfilled')
|
||||
)
|
||||
throw preferredWildlifeFailure([inatError, acartiaError]);
|
||||
const inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows);
|
||||
const acartiaSnapshot =
|
||||
acartiaResult.status === 'fulfilled' ? acartiaResult.value : null;
|
||||
const acartiaRows = acartiaSnapshot?.rows || [];
|
||||
const rows = mergeSightings(inatRows, acartiaRows, {
|
||||
nowMs: now(),
|
||||
taxa: preset,
|
||||
confirmed,
|
||||
});
|
||||
const inatDates = inatSnapshots
|
||||
.map((snapshot) => snapshot.fetchedAt)
|
||||
.filter(Number.isFinite);
|
||||
const inatFetchedAt = inatDates.length ? Math.min(...inatDates) : null;
|
||||
const inatStale = inatSnapshots.some((snapshot) => snapshot.stale === true);
|
||||
const acartiaStale = acartiaSnapshot?.stale === true;
|
||||
const drawnAcartia = rows.filter((row) => row.origin === 'acartia').length;
|
||||
const dates = [
|
||||
inatFetchedAt,
|
||||
drawnAcartia ? acartiaSnapshot?.fetchedAt : null,
|
||||
].filter(Number.isFinite);
|
||||
const retryAfter = [
|
||||
...inatSnapshots.map((snapshot) => snapshot.retryAfterSec),
|
||||
drawnAcartia ? acartiaSnapshot?.retryAfterSec : null,
|
||||
].filter(Number.isFinite);
|
||||
return {
|
||||
rows,
|
||||
stale: inatStale || (drawnAcartia > 0 && acartiaStale),
|
||||
...(inatStale || acartiaStale
|
||||
? {
|
||||
upstreamStatus: Math.max(
|
||||
0,
|
||||
...inatSnapshots.map((snapshot) => snapshot.upstreamStatus || 0),
|
||||
acartiaSnapshot?.upstreamStatus || 0,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
fetchedAt: dates.length ? Math.min(...dates) : null,
|
||||
totalResults:
|
||||
inatSnapshots.reduce(
|
||||
(total, snapshot) => total + snapshot.totalResults,
|
||||
0,
|
||||
) + drawnAcartia,
|
||||
scope: scope === 'global' ? 'global' : 'view',
|
||||
partial: inatSnapshots.some((snapshot) => snapshot.partial === true),
|
||||
...(retryAfter.length ? { retryAfterSec: Math.max(...retryAfter) } : {}),
|
||||
sources: {
|
||||
inaturalist: {
|
||||
ok: !inatError,
|
||||
stale: inatStale,
|
||||
fetchedAt: inatFetchedAt,
|
||||
count: rows.filter((row) => row.origin === 'inaturalist').length,
|
||||
...(inatError ? { error: inatError, failedBoxes } : {}),
|
||||
},
|
||||
acartia: {
|
||||
ok: !acartiaError,
|
||||
stale: acartiaStale,
|
||||
fetchedAt: Number.isFinite(acartiaSnapshot?.fetchedAt)
|
||||
? acartiaSnapshot.fetchedAt
|
||||
: null,
|
||||
count: drawnAcartia,
|
||||
...(acartiaError ? { error: acartiaError } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
/** Return recent, privacy-projected rows without source status metadata. */
|
||||
async getSnapshot(options = {}) {
|
||||
return (await getSnapshotDetail(options)).rows;
|
||||
},
|
||||
getSnapshotDetail,
|
||||
getGridSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createWildlifeSightingsSource,
|
||||
WILDLIFE_GRID_TTL_MS,
|
||||
} from './source.js';
|
||||
import { wildlifeGridCellId } from './gridViewport.js';
|
||||
|
||||
const now = Date.UTC(2026, 10, 4, 18);
|
||||
const seattle = { south: 47.5, west: -122.5, north: 47.7, east: -122.2 };
|
||||
const outside = { south: 10, west: 10, north: 12, east: 12 };
|
||||
const inat = {
|
||||
stableId: 'inat:123',
|
||||
origin: 'inaturalist',
|
||||
lat: 47.6,
|
||||
lon: -122.3,
|
||||
observedAt: now - 3_600_000,
|
||||
observedOn: '2026-11-04',
|
||||
};
|
||||
const acartia = {
|
||||
stableId: 'acartia:abc',
|
||||
origin: 'acartia',
|
||||
lat: 48.12,
|
||||
lon: -122.54,
|
||||
observedAt: now - 3_600_000,
|
||||
cetacean: true,
|
||||
quality: 'research',
|
||||
provider: { name: 'Provider', entity: null },
|
||||
};
|
||||
const inatEnvelope = (extra = {}) => ({
|
||||
fetchedAt: now,
|
||||
rows: [inat],
|
||||
scope: 'view',
|
||||
totalResults: 1,
|
||||
...extra,
|
||||
});
|
||||
const acartiaEnvelope = (extra = {}) => ({
|
||||
fetchedAt: now,
|
||||
rows: [acartia],
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('construction performs no acquisition', () => {
|
||||
createWildlifeSightingsSource({
|
||||
fetchImpl: () => assert.fail('construction fetched'),
|
||||
});
|
||||
});
|
||||
|
||||
test('source sends proxy-only scope, box, taxa and confirmed parameters for all presets', async () => {
|
||||
for (const taxa of [
|
||||
'all',
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'whales',
|
||||
'Insecta',
|
||||
'Plantae',
|
||||
]) {
|
||||
const urls = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(new URL(url, 'https://example.test'));
|
||||
return Response.json(inatEnvelope());
|
||||
},
|
||||
});
|
||||
await source.getSnapshotDetail({ box: outside, taxa, confirmed: true });
|
||||
assert.equal(urls.length, 1);
|
||||
assert.equal(urls[0].pathname, '/api/inaturalist/observations');
|
||||
assert.deepEqual(Object.fromEntries(urls[0].searchParams), {
|
||||
scope: 'view',
|
||||
taxa,
|
||||
quality: 'research',
|
||||
south: '10',
|
||||
west: '10',
|
||||
north: '12',
|
||||
east: '12',
|
||||
});
|
||||
// taxon_id, d1 (observed date), photos and quality_grade are upstream
|
||||
// choices owned and tested by the server, never browser query controls.
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmed false omits quality, and global sample omits bbox and Acartia', async () => {
|
||||
const urls = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return Response.json(inatEnvelope({ scope: 'global' }));
|
||||
},
|
||||
});
|
||||
const result = await source.getSnapshotDetail({
|
||||
scope: 'global',
|
||||
taxa: 'whales',
|
||||
confirmed: false,
|
||||
box: seattle,
|
||||
});
|
||||
assert.equal(result.scope, 'global');
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(new URL(urls[0], 'https://example.test').searchParams),
|
||||
{ scope: 'global', taxa: 'whales' },
|
||||
);
|
||||
assert.equal(urls.length, 1);
|
||||
});
|
||||
|
||||
test('Acartia loads only inside coverage for the three mammal-inclusive presets', async () => {
|
||||
for (const box of [seattle, outside])
|
||||
for (const taxa of [
|
||||
'all',
|
||||
'Aves',
|
||||
'Mammalia',
|
||||
'whales',
|
||||
'Insecta',
|
||||
'Plantae',
|
||||
]) {
|
||||
const urls = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return Response.json(
|
||||
url.startsWith('/api/acartia') ? acartiaEnvelope() : inatEnvelope(),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = await source.getSnapshotDetail({ box, taxa });
|
||||
const expected =
|
||||
box === seattle && ['all', 'Mammalia', 'whales'].includes(taxa);
|
||||
assert.equal(
|
||||
urls.some((url) => url === '/api/acartia/sightings'),
|
||||
expected,
|
||||
);
|
||||
assert.equal(result.rows.length, expected ? 2 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('Acartia failure preserves iNat rows with an explicit failed source', async () => {
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) =>
|
||||
url.startsWith('/api/acartia')
|
||||
? new Response('', { status: 502 })
|
||||
: Response.json(inatEnvelope()),
|
||||
});
|
||||
const result = await source.getSnapshotDetail({ box: seattle });
|
||||
assert.deepEqual(result.rows, [inat]);
|
||||
assert.equal(result.sources.acartia.ok, false);
|
||||
assert.equal(result.sources.inaturalist.ok, true);
|
||||
});
|
||||
|
||||
test('iNat failure keeps a successful Acartia result and reports iNat degraded', async () => {
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) =>
|
||||
url.startsWith('/api/acartia')
|
||||
? Response.json(acartiaEnvelope())
|
||||
: new Response('', {
|
||||
status: 502,
|
||||
headers: { 'X-Upstream-Status': '503', 'Retry-After': '30' },
|
||||
}),
|
||||
});
|
||||
const result = await source.getSnapshotDetail({ box: seattle });
|
||||
assert.deepEqual(result.rows, [acartia]);
|
||||
assert.equal(result.totalResults, 1);
|
||||
assert.equal(result.fetchedAt, now);
|
||||
assert.equal(result.sources.acartia.ok, true);
|
||||
assert.equal(result.sources.inaturalist.ok, false);
|
||||
assert.equal(result.sources.inaturalist.count, 0);
|
||||
assert.equal(
|
||||
result.sources.inaturalist.error.message,
|
||||
'iNaturalist HTTP 502',
|
||||
);
|
||||
assert.equal(result.sources.inaturalist.error.retryAfterSec, 30);
|
||||
assert.deepEqual(result.sources.inaturalist.failedBoxes, [seattle]);
|
||||
});
|
||||
|
||||
test('observations reject only when every requested source failed', async () => {
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) =>
|
||||
new Response('', { status: url.startsWith('/api/acartia') ? 502 : 503 }),
|
||||
});
|
||||
await assert.rejects(source.getSnapshotDetail({ box: seattle }), {
|
||||
message: 'iNaturalist HTTP 503',
|
||||
});
|
||||
const antimeridian = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async () => new Response('', { status: 503 }),
|
||||
});
|
||||
await assert.rejects(
|
||||
antimeridian.getSnapshotDetail({
|
||||
boxes: [
|
||||
{ south: 10, west: 179, north: 12, east: 180 },
|
||||
{ south: 10, west: -180, north: 12, east: -178 },
|
||||
],
|
||||
}),
|
||||
{ message: 'iNaturalist HTTP 503' },
|
||||
);
|
||||
});
|
||||
|
||||
test('one failed antimeridian half keeps the other half and reports the failed box', async () => {
|
||||
const east = { south: 10, west: 179, north: 12, east: 180 };
|
||||
const west = { south: 10, west: -180, north: 12, east: -178 };
|
||||
const eastRow = { ...inat, stableId: 'inat:east', lat: 11, lon: 179.5 };
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) =>
|
||||
new URL(url, 'https://example.test').searchParams.get('west') === '179'
|
||||
? Response.json(inatEnvelope({ rows: [eastRow], totalResults: 3 }))
|
||||
: new Response('', { status: 503 }),
|
||||
});
|
||||
const result = await source.getSnapshotDetail({ boxes: [east, west] });
|
||||
assert.deepEqual(result.rows, [eastRow]);
|
||||
assert.equal(result.totalResults, 3);
|
||||
assert.equal(result.sources.inaturalist.ok, false);
|
||||
assert.equal(result.sources.inaturalist.count, 1);
|
||||
assert.equal(
|
||||
result.sources.inaturalist.error.message,
|
||||
'iNaturalist HTTP 503',
|
||||
);
|
||||
assert.deepEqual(result.sources.inaturalist.failedBoxes, [west]);
|
||||
});
|
||||
|
||||
test('proxy staleness and original fetch dates pass through with per-source counts', async () => {
|
||||
const fetchedAt = now - 50 * 60_000;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) =>
|
||||
Response.json(
|
||||
url.startsWith('/api/acartia')
|
||||
? acartiaEnvelope()
|
||||
: inatEnvelope({
|
||||
fetchedAt,
|
||||
stale: true,
|
||||
partial: true,
|
||||
totalResults: 400,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const result = await source.getSnapshotDetail({ box: seattle });
|
||||
assert.equal(result.stale, true);
|
||||
assert.equal(result.fetchedAt, fetchedAt);
|
||||
assert.equal(result.partial, true);
|
||||
assert.equal(result.totalResults, 401);
|
||||
assert.deepEqual(result.sources.inaturalist, {
|
||||
ok: true,
|
||||
stale: true,
|
||||
fetchedAt,
|
||||
count: 1,
|
||||
});
|
||||
assert.equal(result.sources.acartia.count, 1);
|
||||
});
|
||||
|
||||
test('antimeridian boxes fetch once each and merge duplicate boundary rows', async () => {
|
||||
const urls = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return Response.json(inatEnvelope());
|
||||
},
|
||||
});
|
||||
const boxes = [
|
||||
{ south: 10, west: 179, north: 12, east: 180 },
|
||||
{ south: 10, west: -180, north: 12, east: -178 },
|
||||
];
|
||||
const result = await source.getSnapshotDetail({ boxes });
|
||||
assert.equal(urls.length, 2);
|
||||
assert.equal(result.rows.length, 1);
|
||||
assert.equal(result.totalResults, 2);
|
||||
});
|
||||
|
||||
test('iNat non-OK responses carry stable status messages and status values', async () => {
|
||||
for (const status of [429, 502]) {
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () =>
|
||||
new Response('', {
|
||||
status,
|
||||
headers: { 'X-Upstream-Status': String(status) },
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot({ box: outside }), (error) => {
|
||||
assert.equal(
|
||||
error.message,
|
||||
status === 429 ? 'iNaturalist rate limited' : 'iNaturalist HTTP 502',
|
||||
);
|
||||
assert.equal(error.status, status);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Retry-After delta seconds and HTTP dates use the injected source clock', async () => {
|
||||
for (const [header, seconds] of [
|
||||
['3600', 3600],
|
||||
[new Date(now + 12 * 3_600_000).toUTCString(), 43_200],
|
||||
[new Date(now - 1000).toUTCString(), 0],
|
||||
['0', 0],
|
||||
['not a deadline', undefined],
|
||||
['-10', undefined],
|
||||
['', undefined],
|
||||
]) {
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async () =>
|
||||
new Response('', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': header },
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot({ box: outside }), (error) => {
|
||||
assert.equal(error.status, 429);
|
||||
assert.equal(error.retryAfterSec, seconds, header);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed envelopes and invalid rows reject instead of becoming empty', async () => {
|
||||
for (const payload of [
|
||||
{},
|
||||
{ rows: {} },
|
||||
{ rows: [], totalResults: null },
|
||||
{ rows: [{}], totalResults: 1 },
|
||||
{ results: [], total_results: 0 },
|
||||
]) {
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () => Response.json(payload),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ box: outside }),
|
||||
/Malformed iNaturalist response/,
|
||||
);
|
||||
}
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () => new Response('{bad'),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ box: outside }),
|
||||
/Malformed iNaturalist response/,
|
||||
);
|
||||
});
|
||||
|
||||
test('well-formed empty projected snapshot is legitimate and getSnapshot returns only rows', async () => {
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () =>
|
||||
Response.json(inatEnvelope({ rows: [], totalResults: 0 })),
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshot({ box: outside }), []);
|
||||
});
|
||||
|
||||
test('abort is checked before acquisition and after a late fetch resolves', async () => {
|
||||
const early = new AbortController();
|
||||
early.abort();
|
||||
const idle = createWildlifeSightingsSource({
|
||||
fetchImpl: () => assert.fail('aborted fetch ran'),
|
||||
});
|
||||
await assert.rejects(
|
||||
idle.getSnapshot({ box: outside, signal: early.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
let resolve;
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
});
|
||||
const late = new AbortController();
|
||||
const request = source.getSnapshot({ box: outside, signal: late.signal });
|
||||
late.abort();
|
||||
resolve(Response.json(inatEnvelope()));
|
||||
await assert.rejects(request, { name: 'AbortError' });
|
||||
});
|
||||
|
||||
const gridCell = {
|
||||
id: 'grid:2/1/1:1:51',
|
||||
cellCount: 6,
|
||||
lat: 65.94,
|
||||
lon: -18.28125,
|
||||
};
|
||||
const gridEnvelope = (extra = {}) => ({
|
||||
cells: [gridCell],
|
||||
totalResults: 6,
|
||||
fetchedAt: now,
|
||||
...extra,
|
||||
});
|
||||
const gridTile = { z: 2, x: 1, y: 1 };
|
||||
|
||||
test('grid source sends visible XYZ/filter parameters only, dedupes tiles and keeps source untouched', async () => {
|
||||
const urls = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url, options) => {
|
||||
urls.push(new URL(url, 'https://example.test'));
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
return Response.json(gridEnvelope());
|
||||
},
|
||||
});
|
||||
const result = await source.getGridSnapshot({
|
||||
tiles: [gridTile, gridTile],
|
||||
confirmed: true,
|
||||
taxa: 'Aves',
|
||||
});
|
||||
assert.equal(urls.length, 1);
|
||||
assert.equal(urls[0].pathname, '/api/inaturalist/grid');
|
||||
assert.deepEqual(Object.fromEntries(urls[0].searchParams), {
|
||||
z: '2',
|
||||
x: '1',
|
||||
y: '1',
|
||||
taxa: 'Aves',
|
||||
quality: 'research',
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
cells: [gridCell],
|
||||
totalResults: 6,
|
||||
fetchedAt: now,
|
||||
stale: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('grid source caches stable tile/filter/hour keys and combines neighbor edge cells once', async () => {
|
||||
let clock = now;
|
||||
let calls = 0;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return Response.json(gridEnvelope());
|
||||
},
|
||||
});
|
||||
const options = { tiles: [gridTile, { z: 2, x: 2, y: 1 }] };
|
||||
const result = await source.getGridSnapshot(options);
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(
|
||||
result.cells.length,
|
||||
1,
|
||||
'same geographic cell can bleed into a neighboring hit tile',
|
||||
);
|
||||
assert.equal(result.totalResults, 6);
|
||||
await source.getGridSnapshot({ tiles: [...options.tiles].reverse() });
|
||||
assert.equal(calls, 2, 'ordering does not change stable keys');
|
||||
await source.getGridSnapshot({ ...options, confirmed: true });
|
||||
assert.equal(calls, 4, 'quality changes the key');
|
||||
clock += WILDLIFE_GRID_TTL_MS + 1;
|
||||
await source.getGridSnapshot(options);
|
||||
assert.equal(calls, 6, 'fifteen minute refresh cap');
|
||||
});
|
||||
|
||||
test('grid source preserves stale metadata and rejects invalid projected schema/privacy fields', async () => {
|
||||
const staleSource = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async () =>
|
||||
Response.json(gridEnvelope({ stale: true, fetchedAt: now - 1000 })),
|
||||
});
|
||||
const stale = await staleSource.getGridSnapshot({ tiles: [gridTile] });
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.fetchedAt, now - 1000);
|
||||
for (const payload of [
|
||||
{},
|
||||
gridEnvelope({ cells: {} }),
|
||||
gridEnvelope({ totalResults: -1 }),
|
||||
gridEnvelope({ totalResults: 5 }),
|
||||
gridEnvelope({ fetchedAt: null }),
|
||||
gridEnvelope({ cells: [{ ...gridCell, uuid: 'must-not-leave-proxy' }] }),
|
||||
gridEnvelope({ cells: [{ ...gridCell, iconic: 'Aves' }] }),
|
||||
gridEnvelope({ cells: [{ ...gridCell, lat: 91 }] }),
|
||||
gridEnvelope({ cells: [{ ...gridCell, id: 'grid:99/0/0:1:1' }] }),
|
||||
gridEnvelope({ cells: [{ ...gridCell, id: 'grid:2/1/1:3:51' }] }),
|
||||
gridEnvelope({ cells: [gridCell, gridCell], totalResults: 12 }),
|
||||
]) {
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () => Response.json(payload),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getGridSnapshot({ tiles: [gridTile] }),
|
||||
/Malformed iNaturalist grid response/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('grid source validates request bounds before fetching and aborts superseded loads', async () => {
|
||||
const idle = createWildlifeSightingsSource({
|
||||
fetchImpl: () => assert.fail('invalid/aborted grid acquired'),
|
||||
});
|
||||
for (const tiles of [
|
||||
undefined,
|
||||
[],
|
||||
[{ z: 2, x: 4, y: 0 }],
|
||||
[{ z: -1, x: 0, y: 0 }],
|
||||
Array(13).fill(gridTile),
|
||||
]) {
|
||||
await assert.rejects(
|
||||
idle.getGridSnapshot({ tiles }),
|
||||
/requires visible tiles/,
|
||||
);
|
||||
}
|
||||
const early = new AbortController();
|
||||
early.abort();
|
||||
await assert.rejects(
|
||||
idle.getGridSnapshot({ tiles: [gridTile], signal: early.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
let resolve;
|
||||
let passedSignal;
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: (_url, options) => {
|
||||
passedSignal = options.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
});
|
||||
const late = new AbortController();
|
||||
const loading = source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
signal: late.signal,
|
||||
});
|
||||
late.abort();
|
||||
assert.equal(passedSignal.aborted, true);
|
||||
resolve(Response.json(gridEnvelope()));
|
||||
await assert.rejects(loading, { name: 'AbortError' });
|
||||
});
|
||||
|
||||
test('grid worker concurrency is bounded and a failed tile preserves progressive siblings', async () => {
|
||||
const waiting = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: (_url, options) =>
|
||||
new Promise((resolve, reject) => {
|
||||
waiting.push({ resolve, signal: options.signal });
|
||||
options.signal.addEventListener(
|
||||
'abort',
|
||||
() => reject(options.signal.reason),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
const tiles = Array.from({ length: 6 }, (_, x) => ({ z: 3, x, y: 0 }));
|
||||
const loading = source.getGridSnapshot({ tiles });
|
||||
assert.equal(waiting.length, 4);
|
||||
waiting[0].resolve(new Response('', { status: 502 }));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
for (let i = 1; i < 4; i++) waiting[i].resolve(Response.json(gridEnvelope()));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
for (let i = 4; i < waiting.length; i++)
|
||||
waiting[i].resolve(Response.json(gridEnvelope()));
|
||||
const result = await loading;
|
||||
assert.equal(result.error.message, 'iNaturalist HTTP 502');
|
||||
assert.deepEqual(result.cells, [gridCell]);
|
||||
assert.ok(waiting.every((item) => !item.signal.aborted));
|
||||
});
|
||||
|
||||
test('failed grid refresh keeps the tile last good cells across its window-hour key, without extending stale life', async () => {
|
||||
let clock = now + 55 * 60_000;
|
||||
let fail = false;
|
||||
let calls = 0;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return fail
|
||||
? new Response('', { status: 502, headers: { 'Retry-After': '40' } })
|
||||
: Response.json(gridEnvelope({ fetchedAt: clock }));
|
||||
},
|
||||
});
|
||||
const good = await source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
taxa: 'whales',
|
||||
});
|
||||
const fetchedAt = clock;
|
||||
fail = true;
|
||||
clock += 300_001;
|
||||
const stale = await source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
taxa: 'whales',
|
||||
});
|
||||
assert.deepEqual(stale.cells, good.cells);
|
||||
assert.equal(stale.totalResults, good.totalResults);
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.fetchedAt, fetchedAt);
|
||||
assert.equal(stale.error.message, 'iNaturalist HTTP 502');
|
||||
assert.equal(stale.retryAfterSec, 40);
|
||||
assert.equal(calls, 2);
|
||||
await assert.rejects(
|
||||
source.getGridSnapshot({ tiles: [gridTile], taxa: 'Aves' }),
|
||||
/iNaturalist HTTP 502/,
|
||||
'a different filter cannot borrow the last tile',
|
||||
);
|
||||
clock = fetchedAt + 3_600_001;
|
||||
await assert.rejects(
|
||||
source.getGridSnapshot({ tiles: [gridTile], taxa: 'whales' }),
|
||||
/iNaturalist HTTP 502/,
|
||||
'failed refresh did not renew original data age',
|
||||
);
|
||||
});
|
||||
|
||||
test('a rejected grid request never rejects a parallel observations acquisition', async () => {
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) =>
|
||||
url.startsWith('/api/inaturalist/grid?')
|
||||
? new Response('', { status: 502 })
|
||||
: Response.json(inatEnvelope()),
|
||||
});
|
||||
const [grid, observations] = await Promise.allSettled([
|
||||
source.getGridSnapshot({ tiles: [gridTile] }),
|
||||
source.getSnapshotDetail({ box: outside }),
|
||||
]);
|
||||
assert.equal(grid.status, 'rejected');
|
||||
assert.equal(grid.reason.status, 502);
|
||||
assert.equal(observations.status, 'fulfilled');
|
||||
assert.deepEqual(observations.value.rows, [inat]);
|
||||
});
|
||||
|
||||
test('one failed cached tile retains its cells while neighboring tiles refresh successfully', async () => {
|
||||
let clock = now;
|
||||
let refreshing = false;
|
||||
const neighbor = {
|
||||
...gridCell,
|
||||
id: wildlifeGridCellId(30, 20, 2),
|
||||
lat: 30,
|
||||
lon: 20,
|
||||
};
|
||||
const signals = [];
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => clock,
|
||||
fetchImpl: async (url, { signal }) => {
|
||||
signals.push(signal);
|
||||
const isFailedTile =
|
||||
new URL(url, 'https://example.test').searchParams.get('x') === '1';
|
||||
if (refreshing && isFailedTile) return new Response('', { status: 502 });
|
||||
const cell = isFailedTile
|
||||
? gridCell
|
||||
: { ...neighbor, cellCount: refreshing ? 10 : 6 };
|
||||
return Response.json({
|
||||
cells: [cell],
|
||||
totalResults: cell.cellCount,
|
||||
fetchedAt: clock,
|
||||
});
|
||||
},
|
||||
});
|
||||
const options = { tiles: [gridTile, { z: 2, x: 2, y: 1 }] };
|
||||
await source.getGridSnapshot(options);
|
||||
refreshing = true;
|
||||
clock += WILDLIFE_GRID_TTL_MS + 1;
|
||||
const result = await source.getGridSnapshot(options);
|
||||
assert.equal(result.cells.length, 2);
|
||||
assert.equal(
|
||||
result.cells.find((cell) => cell.id === gridCell.id).cellCount,
|
||||
6,
|
||||
);
|
||||
assert.equal(
|
||||
result.cells.find((cell) => cell.id === neighbor.id).cellCount,
|
||||
10,
|
||||
);
|
||||
assert.equal(result.totalResults, 16);
|
||||
assert.equal(result.stale, true);
|
||||
assert.equal(result.error.message, 'iNaturalist HTTP 502');
|
||||
assert.ok(signals.every((signal) => !signal.aborted));
|
||||
});
|
||||
|
||||
test('stale successful proxy answers preserve Retry-After for observation and grid retry guidance', async () => {
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: async (url) =>
|
||||
Response.json(
|
||||
url.startsWith('/api/inaturalist/grid?')
|
||||
? gridEnvelope({ stale: true })
|
||||
: inatEnvelope({ stale: true }),
|
||||
{ headers: { 'X-Data-Stale': 'true', 'Retry-After': '75' } },
|
||||
),
|
||||
});
|
||||
const rows = await source.getSnapshotDetail({ box: outside });
|
||||
const grid = await source.getGridSnapshot({ tiles: [gridTile] });
|
||||
assert.equal(rows.stale, true);
|
||||
assert.equal(rows.retryAfterSec, 75);
|
||||
assert.equal(grid.stale, true);
|
||||
assert.equal(grid.retryAfterSec, 75);
|
||||
});
|
||||
|
||||
test('client stale tiles publish before revalidation finishes and retain the original data date', async () => {
|
||||
let clock = now;
|
||||
let refresh;
|
||||
let calls = 0;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => clock,
|
||||
fetchImpl: () =>
|
||||
++calls === 1
|
||||
? Promise.resolve(Response.json(gridEnvelope()))
|
||||
: new Promise((resolve) => {
|
||||
refresh = resolve;
|
||||
}),
|
||||
});
|
||||
await source.getGridSnapshot({ tiles: [gridTile] });
|
||||
clock += WILDLIFE_GRID_TTL_MS + 1;
|
||||
const paints = [];
|
||||
const pending = source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
onTile: (tile) => paints.push(tile),
|
||||
});
|
||||
assert.equal(
|
||||
paints.length,
|
||||
1,
|
||||
'stale cache is synchronous, before the fetch settles',
|
||||
);
|
||||
assert.equal(paints[0].stale, true);
|
||||
assert.equal(paints[0].fetchedAt, now);
|
||||
refresh(
|
||||
Response.json(
|
||||
gridEnvelope({
|
||||
cells: [{ ...gridCell, cellCount: 9 }],
|
||||
totalResults: 9,
|
||||
fetchedAt: clock,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const result = await pending;
|
||||
assert.equal(paints.length, 2);
|
||||
assert.equal(paints[1].stale, undefined);
|
||||
assert.equal(result.totalResults, 9);
|
||||
});
|
||||
|
||||
test('cached coverage publishes before slow misses earlier in XYZ order fill all workers', async () => {
|
||||
const waiting = [];
|
||||
let cold = false;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: (url) => {
|
||||
const x = Number(
|
||||
new URL(url, 'https://example.test').searchParams.get('x'),
|
||||
);
|
||||
const cell = {
|
||||
id: wildlifeGridCellId(83, x * 45 - 157.5, 3),
|
||||
lat: 83,
|
||||
lon: x * 45 - 157.5,
|
||||
cellCount: 6,
|
||||
};
|
||||
const value = { cells: [cell], totalResults: 6, fetchedAt: now };
|
||||
return cold
|
||||
? new Promise((resolve) =>
|
||||
waiting.push(() => resolve(Response.json(value))),
|
||||
)
|
||||
: Promise.resolve(Response.json(value));
|
||||
},
|
||||
});
|
||||
const tiles = Array.from({ length: 6 }, (_, x) => ({ z: 3, x, y: 0 }));
|
||||
await source.getGridSnapshot({ tiles: tiles.slice(4) });
|
||||
cold = true;
|
||||
const paints = [];
|
||||
const pending = source.getGridSnapshot({
|
||||
tiles,
|
||||
onTile: (value, metadata) => paints.push({ value, metadata }),
|
||||
});
|
||||
assert.deepEqual(
|
||||
paints.map(({ metadata }) => metadata.tile.x),
|
||||
[4, 5],
|
||||
'known cells paint synchronously even though the four earlier misses are unresolved',
|
||||
);
|
||||
assert.equal(waiting.length, 4);
|
||||
for (const finish of waiting) finish();
|
||||
const result = await pending;
|
||||
assert.equal(result.cells.length, 6);
|
||||
assert.equal(result.totalResults, 36);
|
||||
});
|
||||
|
||||
test('an adjacent zoom cache fills the new tile immediately while its exact cells load', async () => {
|
||||
let resolve;
|
||||
let calls = 0;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: () =>
|
||||
++calls === 1
|
||||
? Promise.resolve(Response.json(gridEnvelope()))
|
||||
: new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
});
|
||||
await source.getGridSnapshot({ tiles: [gridTile] });
|
||||
const parent = { z: 1, x: 0, y: 0 };
|
||||
const paints = [];
|
||||
const pending = source.getGridSnapshot({
|
||||
tiles: [parent],
|
||||
onTile: (value) => paints.push(value),
|
||||
});
|
||||
assert.equal(paints.length, 1);
|
||||
assert.deepEqual(paints[0].cells, [gridCell]);
|
||||
assert.equal(calls, 2, 'the exact zoom still loads in the background');
|
||||
const cell = {
|
||||
...gridCell,
|
||||
id: wildlifeGridCellId(gridCell.lat, gridCell.lon, 1),
|
||||
};
|
||||
resolve(Response.json(gridEnvelope({ cells: [cell] })));
|
||||
await pending;
|
||||
assert.equal(paints.length, 2);
|
||||
assert.deepEqual(paints[1].cells, [cell]);
|
||||
});
|
||||
|
||||
test('concurrent client views share an in-flight tile without one consumer aborting the other', async () => {
|
||||
let resolve;
|
||||
let calls = 0;
|
||||
let signal;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: (_url, options) => {
|
||||
calls++;
|
||||
signal = options.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = new AbortController();
|
||||
const one = source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
signal: first.signal,
|
||||
});
|
||||
const two = source.getGridSnapshot({ tiles: [gridTile] });
|
||||
first.abort();
|
||||
await assert.rejects(one, { name: 'AbortError' });
|
||||
assert.equal(signal.aborted, false);
|
||||
resolve(Response.json(gridEnvelope()));
|
||||
assert.equal((await two).totalResults, 6);
|
||||
assert.equal(calls, 1);
|
||||
await source.getGridSnapshot({ tiles: [gridTile] });
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('local HTTP pacing errors never become iNaturalist rate-limited errors', async () => {
|
||||
for (const status of [429, 503]) {
|
||||
const source = createWildlifeSightingsSource({
|
||||
fetchImpl: async () =>
|
||||
new Response('', { status, headers: { 'Retry-After': '30' } }),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshotDetail({ box: outside }),
|
||||
(error) => {
|
||||
assert.equal(error.upstreamStatus, 0);
|
||||
assert.doesNotMatch(error.message, /rate limited/i);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a server stale-while-revalidate answer joins its refresh and publishes fresh cells in the same view', async () => {
|
||||
const urls = [];
|
||||
let resolve;
|
||||
const source = createWildlifeSightingsSource({
|
||||
now: () => now,
|
||||
fetchImpl: (url) => {
|
||||
urls.push(url);
|
||||
return urls.length === 1
|
||||
? Promise.resolve(
|
||||
Response.json(
|
||||
gridEnvelope({
|
||||
stale: true,
|
||||
fetchedAt: now - WILDLIFE_GRID_TTL_MS - 1,
|
||||
}),
|
||||
),
|
||||
)
|
||||
: new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
});
|
||||
const paints = [];
|
||||
const pending = source.getGridSnapshot({
|
||||
tiles: [gridTile],
|
||||
onTile: (value) => paints.push(value),
|
||||
});
|
||||
await new Promise((done) => setImmediate(done));
|
||||
assert.equal(paints.length, 1);
|
||||
assert.equal(paints[0].stale, true);
|
||||
assert.equal(
|
||||
new URL(urls[1], 'https://example.test').searchParams.get('refresh'),
|
||||
'wait',
|
||||
);
|
||||
resolve(
|
||||
Response.json(
|
||||
gridEnvelope({
|
||||
cells: [{ ...gridCell, cellCount: 11 }],
|
||||
totalResults: 11,
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal((await pending).totalResults, 11);
|
||||
assert.equal(paints.length, 2);
|
||||
assert.equal(paints[1].stale, undefined);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { ICONIC_TINTS, WILDLIFE_SPRITE_PX } from './policy.js';
|
||||
|
||||
// Missing taxon symbols use related shapes from Material Symbols Outlined.
|
||||
export const WILDLIFE_BADGE_GLYPHS = Object.freeze({
|
||||
bird: 'raven',
|
||||
mammal: 'pets',
|
||||
reptile: 'egg_alt',
|
||||
amphibian: 'water_drop',
|
||||
fish: 'set_meal',
|
||||
insect: 'bug_report',
|
||||
arachnid: 'pest_control',
|
||||
plant: 'eco',
|
||||
fungus: 'umbrella',
|
||||
other: 'category',
|
||||
unknown: 'question_mark',
|
||||
});
|
||||
export const WILDLIFE_SPRITE_GROUPS = Object.freeze(
|
||||
Object.keys(WILDLIFE_BADGE_GLYPHS),
|
||||
);
|
||||
export const WILDLIFE_SPRITE_ICONIC = Object.freeze({
|
||||
bird: 'Aves',
|
||||
mammal: 'Mammalia',
|
||||
reptile: 'Reptilia',
|
||||
amphibian: 'Amphibia',
|
||||
fish: 'Actinopterygii',
|
||||
insect: 'Insecta',
|
||||
arachnid: 'Arachnida',
|
||||
plant: 'Plantae',
|
||||
fungus: 'Fungi',
|
||||
other: 'Animalia',
|
||||
unknown: 'Unknown',
|
||||
});
|
||||
const ICONIC_GROUPS = Object.freeze(
|
||||
Object.fromEntries(
|
||||
Object.entries(WILDLIFE_SPRITE_ICONIC).map(([group, iconic]) => [
|
||||
iconic,
|
||||
group,
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
/** Resolve a taxon or sprite group to one of the shared badge textures. */
|
||||
export function wildlifeSpriteGroup(groupOrIconic) {
|
||||
if (groupOrIconic === 'Mollusca') return 'other';
|
||||
return Object.hasOwn(WILDLIFE_SPRITE_ICONIC, groupOrIconic)
|
||||
? groupOrIconic
|
||||
: Object.hasOwn(ICONIC_GROUPS, groupOrIconic)
|
||||
? ICONIC_GROUPS[groupOrIconic]
|
||||
: 'unknown';
|
||||
}
|
||||
|
||||
export const WILDLIFE_BADGE_OUTLINE_PX = 1.5;
|
||||
const OUTLINE_COLOR = '#040c10';
|
||||
const FONT = '400 14px "Material Symbols Outlined"';
|
||||
const FONT_GLYPHS = Object.values(WILDLIFE_BADGE_GLYPHS).join(' ');
|
||||
const FALLBACKS = new Map(
|
||||
WILDLIFE_SPRITE_GROUPS.map((group) => [
|
||||
group,
|
||||
`data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 20 20"><circle cx="10" cy="10" r="9.1" fill="${ICONIC_TINTS[WILDLIFE_SPRITE_ICONIC[group]]}" stroke="${OUTLINE_COLOR}" stroke-width="${WILDLIFE_BADGE_OUTLINE_PX}"/></svg>`,
|
||||
)}`,
|
||||
]),
|
||||
);
|
||||
|
||||
/** One bounded set of canvas sprites per document, painted only after the
|
||||
* icon font is decoded. Until then the very same billboards use plain badges. */
|
||||
export function createWildlifeSpriteCache({
|
||||
documentRef = globalThis.document,
|
||||
} = {}) {
|
||||
const sprites = new Map();
|
||||
const listeners = new Set();
|
||||
let ready = false;
|
||||
let loading = null;
|
||||
|
||||
function paint(group) {
|
||||
const canvas = documentRef.createElement('canvas');
|
||||
canvas.width = canvas.height = WILDLIFE_SPRITE_PX * 2;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return FALLBACKS.get(group);
|
||||
ctx.scale(2, 2);
|
||||
ctx.beginPath();
|
||||
ctx.arc(10, 10, 9.1, 0, Math.PI * 2);
|
||||
ctx.fillStyle = ICONIC_TINTS[WILDLIFE_SPRITE_ICONIC[group]];
|
||||
ctx.fill();
|
||||
ctx.lineWidth = WILDLIFE_BADGE_OUTLINE_PX;
|
||||
ctx.strokeStyle = OUTLINE_COLOR;
|
||||
ctx.stroke();
|
||||
ctx.font = FONT;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillText(WILDLIFE_BADGE_GLYPHS[group], 10, 10, 16);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (loading) return loading;
|
||||
if (!documentRef?.fonts?.load) return Promise.resolve(false);
|
||||
loading = Promise.resolve()
|
||||
.then(() => documentRef.fonts.load(FONT, FONT_GLYPHS))
|
||||
.then((faces) => {
|
||||
if (!faces?.length) return false;
|
||||
// Paint once per group, never once per observation or refresh.
|
||||
for (const group of WILDLIFE_SPRITE_GROUPS)
|
||||
sprites.set(group, paint(group));
|
||||
ready = true;
|
||||
for (const listener of [...listeners]) listener();
|
||||
return true;
|
||||
})
|
||||
.catch(() => false);
|
||||
return loading;
|
||||
}
|
||||
|
||||
return {
|
||||
get(groupOrIconic) {
|
||||
const group = wildlifeSpriteGroup(groupOrIconic);
|
||||
return (ready ? sprites : FALLBACKS).get(group);
|
||||
},
|
||||
load,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
void load();
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const caches = new WeakMap();
|
||||
let headlessCache;
|
||||
/** Share the bounded sprite cache among layer instances in one document. */
|
||||
export function wildlifeSpriteCache(documentRef = globalThis.document) {
|
||||
if (!documentRef) return (headlessCache ??= createWildlifeSpriteCache());
|
||||
if (!caches.has(documentRef))
|
||||
caches.set(documentRef, createWildlifeSpriteCache({ documentRef }));
|
||||
return caches.get(documentRef);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import sharp from 'sharp';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { ICONIC_TINTS, WILDLIFE_SPRITE_PX } from './policy.js';
|
||||
import {
|
||||
WILDLIFE_SPRITE_GROUPS,
|
||||
WILDLIFE_SPRITE_ICONIC,
|
||||
WILDLIFE_BADGE_GLYPHS,
|
||||
WILDLIFE_BADGE_OUTLINE_PX,
|
||||
createWildlifeSpriteCache,
|
||||
wildlifeSpriteCache,
|
||||
wildlifeSpriteGroup,
|
||||
} from './sprites.js';
|
||||
|
||||
const sprites = wildlifeSpriteCache();
|
||||
|
||||
test('every taxon group has an explicit Material Symbol and tinted circle fallback', () => {
|
||||
assert.equal(Object.isFrozen(WILDLIFE_BADGE_GLYPHS), true);
|
||||
assert.deepEqual(WILDLIFE_BADGE_GLYPHS, {
|
||||
bird: 'raven',
|
||||
mammal: 'pets',
|
||||
reptile: 'egg_alt',
|
||||
amphibian: 'water_drop',
|
||||
fish: 'set_meal',
|
||||
insect: 'bug_report',
|
||||
arachnid: 'pest_control',
|
||||
plant: 'eco',
|
||||
fungus: 'umbrella',
|
||||
other: 'category',
|
||||
unknown: 'question_mark',
|
||||
});
|
||||
const textures = new Set();
|
||||
for (const group of WILDLIFE_SPRITE_GROUPS) {
|
||||
const texture = sprites.get(group);
|
||||
textures.add(texture);
|
||||
assert.equal(wildlifeSpriteGroup(WILDLIFE_SPRITE_ICONIC[group]), group);
|
||||
assert.equal(sprites.get(WILDLIFE_SPRITE_ICONIC[group]), texture);
|
||||
assert.equal(sprites.get(group), texture);
|
||||
const svg = decodeURIComponent(texture.slice(texture.indexOf(',') + 1));
|
||||
assert.match(svg, /<circle /);
|
||||
assert.ok(
|
||||
svg.includes(`fill="${ICONIC_TINTS[WILDLIFE_SPRITE_ICONIC[group]]}"`),
|
||||
);
|
||||
assert.match(svg, /stroke="#040c10"/);
|
||||
assert.match(svg, /stroke-width="1.5"/);
|
||||
assert.doesNotMatch(svg, /<path|<text|<image|href=|<script|<foreignObject/);
|
||||
}
|
||||
assert.equal(textures.size, Object.keys(ICONIC_TINTS).length);
|
||||
});
|
||||
|
||||
test('unknown taxon keys share the slate fallback without allocating textures', () => {
|
||||
for (const value of [
|
||||
undefined,
|
||||
null,
|
||||
'Unknown',
|
||||
'Synthetic kingdom',
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'toString',
|
||||
]) {
|
||||
assert.equal(wildlifeSpriteGroup(value), 'unknown');
|
||||
assert.equal(sprites.get(value), sprites.get('unknown'));
|
||||
}
|
||||
assert.equal(sprites.get('Mollusca'), sprites.get('other'));
|
||||
});
|
||||
|
||||
test('canvas badges wait for the icon font, paint once and notify existing consumers', async () => {
|
||||
let resolveFont;
|
||||
let fontLoads = 0;
|
||||
const canvases = [];
|
||||
const documentRef = {
|
||||
fonts: {
|
||||
load(font, glyphs) {
|
||||
fontLoads += 1;
|
||||
assert.match(font, /Material Symbols Outlined/);
|
||||
for (const glyph of Object.values(WILDLIFE_BADGE_GLYPHS))
|
||||
assert.ok(glyphs.includes(glyph));
|
||||
return new Promise((resolve) => {
|
||||
resolveFont = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
createElement(tag) {
|
||||
assert.equal(tag, 'canvas');
|
||||
const commands = [];
|
||||
const ctx = {
|
||||
scale(...args) {
|
||||
commands.push(['scale', ...args]);
|
||||
},
|
||||
beginPath() {},
|
||||
arc(...args) {
|
||||
commands.push(['arc', ...args]);
|
||||
},
|
||||
fill() {
|
||||
commands.push(['fill', this.fillStyle]);
|
||||
},
|
||||
stroke() {
|
||||
commands.push(['stroke', this.strokeStyle, this.lineWidth]);
|
||||
},
|
||||
fillText(...args) {
|
||||
commands.push(['text', this.fillStyle, this.font, ...args]);
|
||||
},
|
||||
};
|
||||
const canvas = { commands, getContext: () => ctx };
|
||||
canvases.push(canvas);
|
||||
return canvas;
|
||||
},
|
||||
};
|
||||
const cache = createWildlifeSpriteCache({ documentRef });
|
||||
const fallback = cache.get('Aves');
|
||||
let notifications = 0;
|
||||
const unsubscribe = cache.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
const loading = cache.load();
|
||||
assert.equal(cache.load(), loading);
|
||||
await Promise.resolve();
|
||||
assert.equal(fontLoads, 1);
|
||||
assert.equal(canvases.length, 0, 'no glyph is rasterized in a fallback font');
|
||||
assert.equal(cache.get('Aves'), fallback);
|
||||
resolveFont([{}]);
|
||||
assert.equal(await loading, true);
|
||||
assert.equal(notifications, 1);
|
||||
assert.equal(canvases.length, WILDLIFE_SPRITE_GROUPS.length);
|
||||
for (const group of WILDLIFE_SPRITE_GROUPS) {
|
||||
const canvas = cache.get(group);
|
||||
assert.equal(canvas, cache.get(WILDLIFE_SPRITE_ICONIC[group]));
|
||||
assert.equal(canvas.width, WILDLIFE_SPRITE_PX * 2);
|
||||
assert.equal(canvas.height, WILDLIFE_SPRITE_PX * 2);
|
||||
assert.deepEqual(
|
||||
canvas.commands.find(([op]) => op === 'fill'),
|
||||
['fill', ICONIC_TINTS[WILDLIFE_SPRITE_ICONIC[group]]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
canvas.commands.find(([op]) => op === 'stroke'),
|
||||
['stroke', '#040c10', WILDLIFE_BADGE_OUTLINE_PX],
|
||||
);
|
||||
const glyph = canvas.commands.find(([op]) => op === 'text');
|
||||
assert.equal(glyph[1], '#ffffff');
|
||||
assert.match(glyph[2], /Material Symbols Outlined/);
|
||||
assert.equal(glyph[3], WILDLIFE_BADGE_GLYPHS[group]);
|
||||
}
|
||||
await cache.load();
|
||||
assert.equal(canvases.length, WILDLIFE_SPRITE_GROUPS.length);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test('unavailable font keeps plain badges and does not paint a ligature word', async () => {
|
||||
const cache = createWildlifeSpriteCache({
|
||||
documentRef: {
|
||||
fonts: { load: async () => [] },
|
||||
createElement() {
|
||||
assert.fail('no canvas before the icon font is ready');
|
||||
},
|
||||
},
|
||||
});
|
||||
const fallback = cache.get('Aves');
|
||||
assert.equal(await cache.load(), false);
|
||||
assert.equal(cache.get('Aves'), fallback);
|
||||
});
|
||||
|
||||
async function visiblePixels(texture, size) {
|
||||
const svg = texture.includes(';base64,')
|
||||
? Buffer.from(texture.slice(texture.indexOf(',') + 1), 'base64')
|
||||
: Buffer.from(decodeURIComponent(texture.slice(texture.indexOf(',') + 1)));
|
||||
const { data, info } = await sharp(svg)
|
||||
.resize(size, size)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
let pixels = 0;
|
||||
for (let i = 0; i < data.length; i += info.channels)
|
||||
if (Math.max(data[i], data[i + 1], data[i + 2]) > 150 && data[i + 3] > 127)
|
||||
pixels += 1;
|
||||
return pixels;
|
||||
}
|
||||
|
||||
test('compact badge fill remains legible at its near and far footprints', async () => {
|
||||
assert.equal(WILDLIFE_SPRITE_PX, 20);
|
||||
for (const size of [12, 16, WILDLIFE_SPRITE_PX, 22]) {
|
||||
const flightPixels = await visiblePixels(aircraftIcon('airliner'), size);
|
||||
for (const group of WILDLIFE_SPRITE_GROUPS)
|
||||
assert.ok(
|
||||
(await visiblePixels(sprites.get(group), size)) >= flightPixels,
|
||||
`${group} at ${size}px`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
WILDLIFE_MAX_VIEW_DEGREES,
|
||||
WILDLIFE_RETRY_MIN_MS,
|
||||
WILDLIFE_RETRY_MAX_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export { snapWildlifeBox } from './bounds.js';
|
||||
|
||||
const degrees = (radians) => (radians * 180) / Math.PI;
|
||||
const cleanDegree = (value) => Math.round(value * 1e8) / 1e8;
|
||||
|
||||
function validBox(box) {
|
||||
return (
|
||||
!!box &&
|
||||
['south', 'west', 'north', 'east'].every((key) =>
|
||||
Number.isFinite(box[key]),
|
||||
) &&
|
||||
box.south >= -90 &&
|
||||
box.north <= 90 &&
|
||||
box.north >= box.south &&
|
||||
box.west >= -180 &&
|
||||
box.west <= 180 &&
|
||||
box.east >= -180 &&
|
||||
box.east <= 180
|
||||
);
|
||||
}
|
||||
|
||||
function longitudeRanges(box) {
|
||||
return box.west <= box.east
|
||||
? [[box.west, box.east]]
|
||||
: [
|
||||
[box.west, 180],
|
||||
[-180, box.east],
|
||||
];
|
||||
}
|
||||
|
||||
/** Test geographic overlap, including boxes that cross the antimeridian. */
|
||||
export function boxIntersects(a, b) {
|
||||
if (!validBox(a) || !validBox(b) || a.south > b.north || a.north < b.south)
|
||||
return false;
|
||||
return longitudeRanges(a).some(([aw, ae]) =>
|
||||
longitudeRanges(b).some(([bw, be]) => aw <= be && ae >= bw),
|
||||
);
|
||||
}
|
||||
|
||||
/** Split a local camera rectangle into noncrossing observation query boxes. */
|
||||
export function wildlifeViewportBoxes(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
if (typeof camera?.computeViewRectangle !== 'function') return null;
|
||||
const rectangle = camera.computeViewRectangle(
|
||||
viewer?.scene?.globe?.ellipsoid,
|
||||
);
|
||||
if (!rectangle) return null;
|
||||
const box = Object.fromEntries(
|
||||
['south', 'west', 'north', 'east'].map((key) => [
|
||||
key,
|
||||
cleanDegree(degrees(rectangle[key])),
|
||||
]),
|
||||
);
|
||||
if (!validBox(box) || box.north <= box.south) return null;
|
||||
const width =
|
||||
box.east >= box.west ? box.east - box.west : 360 - box.west + box.east;
|
||||
if (
|
||||
width <= 0 ||
|
||||
Math.max(box.north - box.south, width) > WILDLIFE_MAX_VIEW_DEGREES
|
||||
)
|
||||
return null;
|
||||
return longitudeRanges(box)
|
||||
.filter(([west, east]) => east > west)
|
||||
.map(([west, east]) => ({
|
||||
south: box.south,
|
||||
west,
|
||||
north: box.north,
|
||||
east,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 30 seconds, doubling to a four-minute ceiling, for local retry guidance. */
|
||||
export function retryDelayMs(previousMs) {
|
||||
return Number.isFinite(previousMs) && previousMs > 0
|
||||
? Math.min(WILDLIFE_RETRY_MAX_MS, previousMs * 2)
|
||||
: WILDLIFE_RETRY_MIN_MS;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
wildlifeViewportBoxes,
|
||||
snapWildlifeBox,
|
||||
boxIntersects,
|
||||
retryDelayMs,
|
||||
} from './viewport.js';
|
||||
import { ACARTIA_GUARD_BOX } from './policy.js';
|
||||
import { getWildlifeHeightBand } from './model.js';
|
||||
|
||||
const viewer = ({ south, west, north, east }) => ({
|
||||
camera: {
|
||||
computeViewRectangle: () =>
|
||||
Object.fromEntries(
|
||||
Object.entries({ south, west, north, east }).map(([key, value]) => [
|
||||
key,
|
||||
(value * Math.PI) / 180,
|
||||
]),
|
||||
),
|
||||
},
|
||||
scene: { globe: { ellipsoid: {} } },
|
||||
});
|
||||
|
||||
test('local boxes snap outward to 0.1 degrees; wider views to whole degrees', () => {
|
||||
assert.deepEqual(
|
||||
snapWildlifeBox({
|
||||
south: 47.61,
|
||||
west: -122.35,
|
||||
north: 47.79,
|
||||
east: -122.21,
|
||||
}),
|
||||
{ south: 47.6, west: -122.4, north: 47.8, east: -122.2 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapWildlifeBox({ south: 40.1, west: -126.2, north: 46.1, east: -121.1 }),
|
||||
{ south: 40, west: -127, north: 47, east: -121 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapWildlifeBox({ south: 47.6, west: -122.4, north: 47.8, east: -122.2 }),
|
||||
{ south: 47.6, west: -122.4, north: 47.8, east: -122.2 },
|
||||
);
|
||||
assert.equal(
|
||||
snapWildlifeBox({ south: -20, west: -30, north: 21, east: 10 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the final snapped span stays within 40 degrees and snapping is idempotent', () => {
|
||||
for (const box of [
|
||||
{ south: 10.2, west: 0, north: 50.1, east: 1 },
|
||||
{ south: 0, west: 10.2, north: 1, east: 50.1 },
|
||||
])
|
||||
assert.equal(snapWildlifeBox(box), null, JSON.stringify(box));
|
||||
for (const box of [
|
||||
{ south: 47.4, west: -122.3, north: 47.6, east: -122.1 },
|
||||
{ south: 10.01, west: -122.3, north: 14.99, east: -117.31 },
|
||||
{ south: 10.01, west: 0, north: 15, east: 5 },
|
||||
{ south: 10.01, west: 0, north: 15.01, east: 5 },
|
||||
{ south: 10, west: 0, north: 50, east: 1 },
|
||||
]) {
|
||||
const snapped = snapWildlifeBox(box);
|
||||
assert.ok(snapped, JSON.stringify(box));
|
||||
assert.deepEqual(snapWildlifeBox(snapped), snapped);
|
||||
}
|
||||
});
|
||||
|
||||
test('intersections handle guard coverage, point boxes and antimeridian', () => {
|
||||
const seattle = { south: 47.5, west: -122.5, north: 47.7, east: -122.2 };
|
||||
assert.equal(boxIntersects(seattle, ACARTIA_GUARD_BOX), true);
|
||||
assert.equal(
|
||||
boxIntersects({ ...seattle, west: 20, east: 21 }, ACARTIA_GUARD_BOX),
|
||||
false,
|
||||
);
|
||||
const dateline = { south: 10, west: 175, north: 20, east: -175 };
|
||||
assert.equal(
|
||||
boxIntersects(dateline, { south: 11, west: 178, north: 12, east: 179 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
boxIntersects(dateline, { south: 11, west: -1, north: 12, east: 1 }),
|
||||
false,
|
||||
);
|
||||
assert.equal(boxIntersects(null, seattle), false);
|
||||
});
|
||||
|
||||
test('viewport rectangles split at 180 degrees and reject overly large or missing views', () => {
|
||||
assert.deepEqual(
|
||||
wildlifeViewportBoxes(
|
||||
viewer({ south: 10, west: 179, north: 12, east: -178 }),
|
||||
),
|
||||
[
|
||||
{ south: 10, west: 179, north: 12, east: 180 },
|
||||
{ south: 10, west: -180, north: 12, east: -178 },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
wildlifeViewportBoxes(
|
||||
viewer({ south: 47, west: -123, north: 48, east: -122 }),
|
||||
),
|
||||
[{ south: 47, west: -123, north: 48, east: -122 }],
|
||||
);
|
||||
assert.equal(
|
||||
wildlifeViewportBoxes(
|
||||
viewer({ south: -21, west: -10, north: 20, east: 10 }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
wildlifeViewportBoxes(
|
||||
viewer({ south: 0, west: 170, north: 10, east: -140 }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(wildlifeViewportBoxes({}), null);
|
||||
});
|
||||
|
||||
test('height bands retain representations across +/-5% and switch beyond +/-10%', () => {
|
||||
assert.equal(getWildlifeHeightBand(3_000_001), 'global');
|
||||
assert.equal(getWildlifeHeightBand(3_000_000), 'regional');
|
||||
assert.equal(getWildlifeHeightBand(3_150_000, 'regional'), 'regional');
|
||||
assert.equal(getWildlifeHeightBand(2_850_000, 'global'), 'global');
|
||||
assert.equal(getWildlifeHeightBand(3_310_000, 'regional'), 'global');
|
||||
assert.equal(getWildlifeHeightBand(2_690_000, 'global'), 'regional');
|
||||
assert.equal(getWildlifeHeightBand(1_600_000), 'regional');
|
||||
assert.equal(getWildlifeHeightBand(1_000_000), 'cells');
|
||||
assert.equal(getWildlifeHeightBand(30_000), 'cards');
|
||||
assert.equal(getWildlifeHeightBand(30_000, 'global'), 'cards');
|
||||
});
|
||||
|
||||
test('retry guidance doubles from 30 seconds to a four-minute ceiling', () => {
|
||||
assert.deepEqual(
|
||||
[undefined, 30_000, 60_000, 120_000, 240_000].map(retryDelayMs),
|
||||
[30_000, 60_000, 120_000, 240_000, 240_000],
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { WILDLIFE_BADGE_GLYPHS } from './layers/wildlifeSightings/sprites.js';
|
||||
|
||||
const SRC_ROOT = fileURLToPath(new URL('.', import.meta.url));
|
||||
const REPO_ROOT = path.resolve(SRC_ROOT, '..');
|
||||
@@ -11,6 +12,9 @@ const INDEX_HTML = path.join(REPO_ROOT, 'index.html');
|
||||
/** The glyph written as element text: `<span class="material-symbols-outlined">radar</span>`. */
|
||||
const SPAN_TEXT =
|
||||
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*</g;
|
||||
/** A glyph chosen in a Vue template: `<span class="material-symbols-outlined">{{ a ? 'x' : 'y' }}</span>`. */
|
||||
const SPAN_MUSTACHE =
|
||||
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*\{\{([^}]{0,400})\}\}\s*</g;
|
||||
/** A whole `textContent =` statement, across lines, so a multi-line ternary is read once. */
|
||||
const TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs;
|
||||
const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g;
|
||||
@@ -56,6 +60,8 @@ function sourceFiles(directory = SRC_ROOT) {
|
||||
*/
|
||||
function referencedGlyphs() {
|
||||
const found = new Map();
|
||||
for (const glyph of Object.values(WILDLIFE_BADGE_GLYPHS))
|
||||
found.set(glyph, 'src/layers/wildlifeSightings/sprites.js');
|
||||
for (const file of sourceFiles()) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
const relative = path.relative(REPO_ROOT, file).split(path.sep).join('/');
|
||||
@@ -64,7 +70,24 @@ function referencedGlyphs() {
|
||||
};
|
||||
for (const match of source.matchAll(SPAN_TEXT)) add(match[1]);
|
||||
for (const match of source.matchAll(ICON_PROPERTY)) add(match[1]);
|
||||
for (const match of source.matchAll(TEXT_ASSIGNMENT)) {
|
||||
// A template glyph bound to a computed (`{{ entryIcon }}`) is whatever
|
||||
// that computed returns.
|
||||
for (const [, expression] of source.matchAll(SPAN_MUSTACHE)) {
|
||||
const name = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(expression)?.[1];
|
||||
if (!name) continue;
|
||||
const body = new RegExp(
|
||||
`\\b${name}\\s*=\\s*computed\\(\\(\\)\\s*=>\\s*\\{([\\s\\S]{0,600}?)\\n\\}\\);`,
|
||||
).exec(source)?.[1];
|
||||
for (const literal of body?.matchAll(
|
||||
/\breturn\s+['"`]([a-z0-9_]{2,})['"`]/g,
|
||||
) ?? [])
|
||||
add(literal[1]);
|
||||
}
|
||||
const expressions = [
|
||||
...source.matchAll(TEXT_ASSIGNMENT),
|
||||
...source.matchAll(SPAN_MUSTACHE),
|
||||
];
|
||||
for (const match of expressions) {
|
||||
const statement = match[1].replaceAll('?.', '.');
|
||||
const assigned = statement.includes('?')
|
||||
? statement.slice(statement.indexOf('?') + 1)
|
||||
|
||||
@@ -23,6 +23,8 @@ const COCKPIT_REFUSALS = Object.freeze({
|
||||
target: msg('Exit cockpit to fly to a target'),
|
||||
vessel: msg('Exit cockpit to fly to a vessel'),
|
||||
fire: msg('Exit cockpit to fly to a fire'),
|
||||
sighting: msg('Exit cockpit to fly to a sighting'),
|
||||
'sighting-area': msg('Exit cockpit to fly to a sighting area'),
|
||||
route: msg('Exit cockpit to fly to a route'),
|
||||
file: msg('Exit cockpit to fly to a file'),
|
||||
drawing: msg('Exit cockpit to fly to a drawing'),
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildWildlifeSelectedLabel,
|
||||
buildWildlifeThumbnailEntry,
|
||||
} from '../layers/wildlifeSightings/cards.js';
|
||||
import {
|
||||
normalizeOverlayEntry,
|
||||
paintLaneForOverlayEntry,
|
||||
} from './worldOverlay.js';
|
||||
import {
|
||||
clearWorldOverlayTextMeasureCache,
|
||||
measureOverlayEntry,
|
||||
paintOverlayEntry,
|
||||
} from './worldOverlayDraw.js';
|
||||
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
|
||||
|
||||
function paintingContext(glyphWidth = 6) {
|
||||
const text = [];
|
||||
const fills = [];
|
||||
const strokes = [];
|
||||
const images = [];
|
||||
return {
|
||||
text,
|
||||
fills,
|
||||
strokes,
|
||||
images,
|
||||
measureText: (text) => ({ width: Array.from(text).length * glyphWidth }),
|
||||
save() {},
|
||||
restore() {},
|
||||
beginPath() {},
|
||||
roundRect() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
fillRect() {},
|
||||
fill() {
|
||||
fills.push(this.fillStyle);
|
||||
},
|
||||
stroke() {
|
||||
strokes.push(this.strokeStyle);
|
||||
},
|
||||
fillText(value) {
|
||||
text.push(value);
|
||||
},
|
||||
drawImage(...args) {
|
||||
images.push(args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('wildlife thumbnail titles paint a word-boundary ellipsis after normalization and width fitting', () => {
|
||||
for (const [commonName, glyphWidth, expected] of [
|
||||
["Bullock's Oriole", 6, "BULLOCK'S…"],
|
||||
['Great Blue Heron', 11, 'GREAT…'],
|
||||
['Superlongunbrokentaxon', 6, 'SUPERLONGUNBRO…'],
|
||||
]) {
|
||||
clearWorldOverlayTextMeasureCache();
|
||||
const entry = normalizeOverlayEntry('wildlife-sightings', {
|
||||
...buildWildlifeThumbnailEntry(
|
||||
{
|
||||
stableId: 'inat:1',
|
||||
commonName,
|
||||
observedAt: 0,
|
||||
photo: {
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
|
||||
licence: 'cc-by',
|
||||
attribution: 'Synthetic Photographer',
|
||||
},
|
||||
},
|
||||
0,
|
||||
{ image: { width: 75, height: 75 } },
|
||||
),
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
});
|
||||
const ctx = paintingContext(glyphWidth);
|
||||
entry._overlayLayout = measureOverlayEntry(ctx, entry);
|
||||
paintOverlayEntry(ctx, entry, {
|
||||
rect: {
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: entry._overlayLayout.w,
|
||||
h: entry._overlayLayout.h,
|
||||
},
|
||||
});
|
||||
assert.equal(ctx.text[0], expected);
|
||||
assert.ok(ctx.measureText(ctx.text[0]).width <= 96);
|
||||
assert.ok(entry.accessibilityLabel.includes(commonName));
|
||||
}
|
||||
});
|
||||
|
||||
test('selected thumbnails retain their image in the host selected chrome and lane, without a link line', () => {
|
||||
const entry = normalizeOverlayEntry('wildlife-sightings', {
|
||||
...buildWildlifeThumbnailEntry(
|
||||
{
|
||||
stableId: 'inat:1',
|
||||
commonName: 'Synthetic bird',
|
||||
observedAt: 0,
|
||||
uri: 'https://www.inaturalist.org/observations/1',
|
||||
photo: {
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
|
||||
licence: 'cc-by',
|
||||
attribution: 'Synthetic Photographer',
|
||||
},
|
||||
},
|
||||
0,
|
||||
{ selected: true, image: { width: 75, height: 75 } },
|
||||
),
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
});
|
||||
const ctx = paintingContext();
|
||||
entry._overlayLayout = measureOverlayEntry(ctx, entry);
|
||||
paintOverlayEntry(ctx, entry, {
|
||||
rect: {
|
||||
x: 10,
|
||||
y: 20,
|
||||
w: entry._overlayLayout.w,
|
||||
h: entry._overlayLayout.h,
|
||||
},
|
||||
});
|
||||
assert.equal(entry.selected, true);
|
||||
assert.equal(
|
||||
paintLaneForOverlayEntry(entry),
|
||||
paintLaneForOverlayEntry({ selected: true }),
|
||||
);
|
||||
assert.equal(entry._overlayLayout.thumbH, 72);
|
||||
assert.equal(ctx.images.length, 1);
|
||||
assert.ok(ctx.fills.includes(WORLD_OVERLAY_STYLE.selectedBackground));
|
||||
assert.ok(ctx.strokes.includes(WORLD_OVERLAY_STYLE.selectedBorder));
|
||||
assert.ok(!ctx.text.some((line) => String(line).includes('↗')));
|
||||
});
|
||||
|
||||
test('selected wildlife names retain a bounded title with the host selected metrics', () => {
|
||||
for (const origin of ['inaturalist', 'acartia']) {
|
||||
clearWorldOverlayTextMeasureCache();
|
||||
const row = {
|
||||
stableId: `${origin}:1`,
|
||||
origin,
|
||||
commonName:
|
||||
'Synthetic bird with a very long common name that cannot widen the globe label',
|
||||
scientificName: 'Avis synthetica',
|
||||
iconic: 'Aves',
|
||||
photo: {
|
||||
square:
|
||||
'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
|
||||
attribution:
|
||||
'Synthetic Photographer, full attribution belongs in the dialog',
|
||||
licence: 'cc-by',
|
||||
},
|
||||
};
|
||||
const entry = normalizeOverlayEntry('wildlife-sightings-selected', {
|
||||
...buildWildlifeSelectedLabel(row),
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
});
|
||||
const fonts = [];
|
||||
const ctx = {
|
||||
font: '',
|
||||
measureText(text) {
|
||||
fonts.push(this.font);
|
||||
return { width: [...text].length * 6 };
|
||||
},
|
||||
};
|
||||
const layout = measureOverlayEntry(ctx, entry);
|
||||
assert.equal(entry.variant, 'label');
|
||||
assert.equal(
|
||||
entry.selected,
|
||||
true,
|
||||
'selected names use the host selected painter',
|
||||
);
|
||||
assert.equal(entry.protected, true);
|
||||
assert.equal(entry.paintLane, 'selected');
|
||||
assert.equal(
|
||||
paintLaneForOverlayEntry(entry),
|
||||
paintLaneForOverlayEntry({ selected: true }),
|
||||
);
|
||||
assert.equal(entry.pinned, false);
|
||||
assert.equal(entry.interactive, false);
|
||||
assert.equal(entry.placement, 'above');
|
||||
assert.deepEqual(entry.details, []);
|
||||
assert.equal(entry.image, null);
|
||||
assert.equal(layout.h, 31, 'shared selected title and padding');
|
||||
assert.ok(layout.w <= 264, `bounded name, got ${layout.w}px`);
|
||||
assert.deepEqual(fonts, [WORLD_OVERLAY_STYLE.fontSelected]);
|
||||
}
|
||||
});
|
||||
+131
-72
@@ -1,3 +1,5 @@
|
||||
import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js';
|
||||
export { WORLD_OVERLAY_OCCLUDER_SELECTORS } from './worldOverlayChrome.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
measureOverlayEntry,
|
||||
paintOverlayEntry,
|
||||
placementVariants,
|
||||
truncateOverlayTitle,
|
||||
} from './worldOverlayDraw.js';
|
||||
import { WORLD_OVERLAY_STYLE } from './worldOverlayTokens.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
@@ -72,57 +75,6 @@ const PAINT_LANE_INDEX = new Map(
|
||||
WORLD_OVERLAY_PAINT_LANES.map((lane, index) => [lane, index]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Unified UI exclusion inventory. Selectors may match multiple elements; the
|
||||
* service caches their rectangles and observes layout/visibility changes.
|
||||
*
|
||||
* These rectangles are a PLACEMENT policy, never a paint mask. Every element
|
||||
* listed here composites ABOVE the overlay host — map chrome sits at z90–z1000
|
||||
* and the cockpit HUD at z145, against this host's z5/z6 — so the browser
|
||||
* already keeps overlay pixels off it and no canvas needs clipping. What the
|
||||
* inventory buys is readability: a card placed under solid chrome is simply
|
||||
* lost, so the solver PREFERS placements clear of it. When nothing is clear the
|
||||
* entry still renders and the chrome covers it, exactly as it did before the
|
||||
* host existed.
|
||||
*
|
||||
* The list therefore holds only chrome dense enough to swallow a card. The
|
||||
* cockpit's thin translucent line art — rims, arcs, rails, tapes, toplines,
|
||||
* readouts — is deliberately ABSENT under the owner's AR-HUD ruling:
|
||||
* world-space overlay content renders beneath the cockpit's screen-space HUD,
|
||||
* which paints over it by z-order. Those elements are also enormous (the
|
||||
* altitude rim is keyhole-tall, the topline viewport-wide), and excluding them
|
||||
* suppressed essentially the entire cockpit view. Only the two solid,
|
||||
* backdrop-filled cockpit windows survive as exclusions.
|
||||
*/
|
||||
export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
|
||||
'#title-bar',
|
||||
'#style-indicator',
|
||||
'#top-center-actions',
|
||||
'#traffic-sync-chip',
|
||||
'#cctv-sync-chip',
|
||||
'#left-panel-stack',
|
||||
'#right-context-rail',
|
||||
'.weather-summary',
|
||||
'#pp-toggles',
|
||||
'#command-dock',
|
||||
'#gev-voice-control',
|
||||
'#cesium-credits',
|
||||
'.hud-top-left',
|
||||
'.hud-top-right',
|
||||
'.hud-bottom-left',
|
||||
'.hud-bottom-right',
|
||||
'.hud-bottom-bar',
|
||||
'#space-mission-panel',
|
||||
'#space-mission-panel-host',
|
||||
'#military-awareness-panel',
|
||||
'#bhote-koshi-event-panel',
|
||||
// Cockpit: solid backdrop-filled windows only (both bounded to
|
||||
// `min(340px, 28vw)` wide and `min(42vh, 410px)` tall, both `hidden` until
|
||||
// toggled). Every other cockpit selector was removed — see the block comment.
|
||||
'#cockpit-context',
|
||||
'#cockpit-signal-stream',
|
||||
]);
|
||||
|
||||
/** @typedef {{x:number,y:number,w:number,h:number}} OverlayRect */
|
||||
/**
|
||||
* @typedef {object} WorldOverlayEntry
|
||||
@@ -143,6 +95,8 @@ export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
|
||||
* @property {number} [maxVisible=Infinity] Source ceiling within a shared domain.
|
||||
* @property {boolean} [moving=false] Enables bounded interval re-solves.
|
||||
* @property {number} [solveIntervalMs=125]
|
||||
* @property {function(Set<string>):void} [onVisibilityChange] Painted entry ids,
|
||||
* reported only on membership changes. The borrowed Set is read-only.
|
||||
*/
|
||||
|
||||
/** @type {Cesium.Viewer|null} */
|
||||
@@ -470,6 +424,11 @@ export function normalizeOverlayEntry(sourceId, entry) {
|
||||
collisionGroup: String(
|
||||
entry.collisionGroup || defaultCollisionGroup(variant),
|
||||
),
|
||||
// Optional lower-priority content must fit around the actual painted
|
||||
// members of another domain, including its sticky positions and exit fades.
|
||||
yieldToCollisionGroup: entry.yieldToCollisionGroup
|
||||
? String(entry.yieldToCollisionGroup)
|
||||
: null,
|
||||
// Explicit field pick rather than `{ ...entry, variant }`: the lane
|
||||
// resolver reads only these four, and a full spread re-invokes every
|
||||
// accessor the source put on its entry (a throwing one would abort
|
||||
@@ -592,12 +551,15 @@ export function normalizeOverlayEntry(sourceId, entry) {
|
||||
metadata: entry.metadata ?? null,
|
||||
thumbnailWidth: entry.thumbnailWidth,
|
||||
thumbnailHeight: entry.thumbnailHeight,
|
||||
thumbnailFit: entry.thumbnailFit === 'contain' ? 'contain' : null,
|
||||
thumbnailFixedWidth: entry.thumbnailFixedWidth === true,
|
||||
thumbnailPadX: entry.thumbnailPadX,
|
||||
thumbnailPadTop: entry.thumbnailPadTop,
|
||||
thumbnailPadBottom: entry.thumbnailPadBottom,
|
||||
thumbnailTitleGap: entry.thumbnailTitleGap,
|
||||
thumbnailTitleHeight: entry.thumbnailTitleHeight,
|
||||
thumbnailTitleChars: entry.thumbnailTitleChars,
|
||||
thumbnailTitleWordBoundary: entry.thumbnailTitleWordBoundary === true,
|
||||
thumbnailBackground: entry.thumbnailBackground,
|
||||
thumbnailTitleColor: entry.thumbnailTitleColor,
|
||||
thumbnailTitleFont: entry.thumbnailTitleFont,
|
||||
@@ -611,13 +573,21 @@ export function normalizeOverlayEntry(sourceId, entry) {
|
||||
_overlayTrackDisplayText: '',
|
||||
_overlayThumbnailTitle:
|
||||
variant === 'thumbnail'
|
||||
? String(entry.title ?? '')
|
||||
.toUpperCase()
|
||||
.slice(
|
||||
0,
|
||||
? entry.thumbnailTitleWordBoundary === true
|
||||
? truncateOverlayTitle(
|
||||
String(entry.title ?? '').toUpperCase(),
|
||||
Math.max(0, Math.floor(Number(entry.thumbnailTitleChars) || 0)) ||
|
||||
undefined,
|
||||
Infinity,
|
||||
)
|
||||
: String(entry.title ?? '')
|
||||
.toUpperCase()
|
||||
.slice(
|
||||
0,
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(Number(entry.thumbnailTitleChars) || 0),
|
||||
) || undefined,
|
||||
)
|
||||
: '',
|
||||
};
|
||||
normalized._overlayKey = entryKey(source, id);
|
||||
@@ -714,6 +684,12 @@ function normalizeSourceOptions(options = {}, previous = {}) {
|
||||
DEFAULT_MOVING_SOLVE_MS,
|
||||
) || 0,
|
||||
),
|
||||
onVisibilityChange:
|
||||
options.onVisibilityChange === undefined
|
||||
? previous.onVisibilityChange || null
|
||||
: typeof options.onVisibilityChange === 'function'
|
||||
? options.onVisibilityChange
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -844,6 +820,7 @@ function getOrCreateSource(sourceId, options = {}) {
|
||||
cohortLists: [],
|
||||
cohortCount: 0,
|
||||
demandByDomain: new Map(),
|
||||
visibleIds: new Set(),
|
||||
options: normalizeSourceOptions(options),
|
||||
};
|
||||
_sources.set(id, source);
|
||||
@@ -1157,18 +1134,17 @@ export function isOverlayPointVisible(
|
||||
*/
|
||||
function overlayRectIntersectsAny(rect, exclusions, count = exclusions.length) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const other = exclusions[i];
|
||||
if (
|
||||
rect.x < other.x + other.w &&
|
||||
rect.x + rect.w > other.x &&
|
||||
rect.y < other.y + other.h &&
|
||||
rect.y + rect.h > other.y
|
||||
)
|
||||
return true;
|
||||
if (overlayRectsOverlap(rect, exclusions[i])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function overlayRectsOverlap(a, b) {
|
||||
return (
|
||||
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `overlayRectIntersectsAny`, but only against exclusions that composite
|
||||
* BELOW the host — the ones a placement may never overlap at any cost.
|
||||
@@ -1180,14 +1156,7 @@ function overlayRectIntersectsAnyHard(
|
||||
) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const other = exclusions[i];
|
||||
if (!other.hard) continue;
|
||||
if (
|
||||
rect.x < other.x + other.w &&
|
||||
rect.x + rect.w > other.x &&
|
||||
rect.y < other.y + other.h &&
|
||||
rect.y + rect.h > other.y
|
||||
)
|
||||
return true;
|
||||
if (other.hard && overlayRectsOverlap(rect, other)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -2253,6 +2222,7 @@ function addPaintItem(record, placement, temporalAlpha, selected) {
|
||||
item.lane = paintLaneForOverlayEntry(record.entry);
|
||||
item.zIndex = record.entry.zIndex;
|
||||
item.key = record.key;
|
||||
item.collisionHidden = false;
|
||||
_paintQueue[_paintCount++] = item;
|
||||
}
|
||||
|
||||
@@ -2454,6 +2424,7 @@ function paintCustomLane(lane) {
|
||||
}
|
||||
|
||||
function paintEntryItem(item, keyhole, sonar) {
|
||||
if (item.collisionHidden) return;
|
||||
const { record, placement } = item;
|
||||
const entry = record.entry;
|
||||
let keyholeAlpha = 1;
|
||||
@@ -2517,6 +2488,90 @@ function paintEntryItem(item, keyhole, sonar) {
|
||||
_paintedBySource[entry.source]++;
|
||||
}
|
||||
|
||||
function compareYieldingPaintItems(a, b) {
|
||||
const aYields = !!a.record.entry.yieldToCollisionGroup;
|
||||
const bYields = !!b.record.entry.yieldToCollisionGroup;
|
||||
return (
|
||||
Number(aYields) - Number(bYields) ||
|
||||
b.record.entry.priority - a.record.entry.priority ||
|
||||
compareStableKeys(a.key, b.key)
|
||||
);
|
||||
}
|
||||
|
||||
/** Recheck yielding content every painted frame, even between camera solves.
|
||||
* The arbiter's sticky/exit placements are intentionally retained for cards;
|
||||
* labels can only use the remaining rectangles. All buffers are reused. */
|
||||
function declutterYieldingEntries() {
|
||||
let hasYielding = false;
|
||||
for (let i = 0; i < _paintCount; i++)
|
||||
if (_paintQueue[i].record.entry.yieldToCollisionGroup) {
|
||||
hasYielding = true;
|
||||
break;
|
||||
}
|
||||
if (!hasYielding) return;
|
||||
sortPooledRange(_paintQueue, _paintCount, compareYieldingPaintItems);
|
||||
for (let i = 0; i < _paintCount; i++) {
|
||||
const item = _paintQueue[i];
|
||||
const entry = item.record.entry;
|
||||
if (!entry.yieldToCollisionGroup) continue;
|
||||
const placements = item.record.placements;
|
||||
let accepted = null;
|
||||
// Try the arbiter's chosen corner first, then its other valid placements.
|
||||
for (let p = -1; p < placements.length; p++) {
|
||||
const placement = p < 0 ? item.placement : placements[p];
|
||||
if (p >= 0 && placement === item.placement) continue;
|
||||
let blocked = false;
|
||||
for (let j = 0; j < i; j++) {
|
||||
const other = _paintQueue[j];
|
||||
if (other.collisionHidden) continue;
|
||||
const group = other.record.entry.collisionGroup;
|
||||
if (
|
||||
(group === entry.collisionGroup ||
|
||||
group === entry.yieldToCollisionGroup) &&
|
||||
overlayRectsOverlap(placement.rect, other.placement.rect)
|
||||
) {
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!blocked) {
|
||||
accepted = placement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (accepted) item.placement = accepted;
|
||||
else item.collisionHidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function syncPaintVisibility(painted = true) {
|
||||
for (let s = 0; s < _sourceList.length; s++) {
|
||||
const source = _sourceList[s];
|
||||
const callback = source.options.onVisibilityChange;
|
||||
if (!callback) continue;
|
||||
const count = painted ? _paintedBySource[source.id] || 0 : 0;
|
||||
let changed = count !== source.visibleIds.size;
|
||||
if (!changed && painted) {
|
||||
for (const id of source.visibleIds) {
|
||||
if (
|
||||
_paintRectByKey.get(entryKey(source.id, id))?.stamp !== _frameStamp
|
||||
) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!changed) continue;
|
||||
source.visibleIds.clear();
|
||||
if (painted)
|
||||
for (let i = 0; i < _paintRectCount; i++) {
|
||||
const rect = _paintRectPool[i];
|
||||
if (rect.sourceId === source.id) source.visibleIds.add(rect.entryId);
|
||||
}
|
||||
callback(source.visibleIds);
|
||||
}
|
||||
}
|
||||
|
||||
function paintFrame(keyhole) {
|
||||
const started = nowMs();
|
||||
const sonar = isCyberSonarActive()
|
||||
@@ -2531,6 +2586,7 @@ function paintFrame(keyhole) {
|
||||
clearCanvas(false, true);
|
||||
_paintRectCount = 0;
|
||||
_hitRectCount = 0;
|
||||
declutterYieldingEntries();
|
||||
sortPooledRange(_paintQueue, _paintCount, comparePaintItems);
|
||||
// The shared canvas is not clipped either. Cards already avoid solid chrome
|
||||
// by placement, and every occluder composites above this host in the DOM, so
|
||||
@@ -2553,6 +2609,7 @@ function paintFrame(keyhole) {
|
||||
_diagnostics.paintRectPoolSize = _paintRectPool.length;
|
||||
_diagnostics.paintMs = nowMs() - started;
|
||||
syncAccessibleActions();
|
||||
syncPaintVisibility();
|
||||
_canvasNeedsClear =
|
||||
_paintRectCount > 0 || activeCustomPaintLaneCount(PAINT_TARGET_SHARED) > 0;
|
||||
}
|
||||
@@ -2609,6 +2666,7 @@ function drawWorldOverlay() {
|
||||
_hitRectCount = 0;
|
||||
syncAccessibleActions();
|
||||
}
|
||||
syncPaintVisibility(false);
|
||||
return;
|
||||
}
|
||||
if (_resizeDirty) ensureCanvasSize();
|
||||
@@ -2743,6 +2801,7 @@ export function destroyWorldOverlay() {
|
||||
) {
|
||||
delete window.__gevWorldOverlay;
|
||||
}
|
||||
syncPaintVisibility(false);
|
||||
_sources.clear();
|
||||
_sourceList.length = 0;
|
||||
for (let i = 0; i < _customPaintLaneList.length; i++) {
|
||||
|
||||
@@ -12,6 +12,15 @@ import {
|
||||
} from '../celestialRing.js';
|
||||
import { createCctvThumbnailOverlayEntry, createFrameSlot } from '../data/cctvCards.js';
|
||||
import { combinedOverlayAlpha } from './worldOverlayDraw.js';
|
||||
import {
|
||||
buildWildlifeLabelEntry,
|
||||
buildWildlifeThumbnailEntry,
|
||||
buildWildlifeSelectedLabel,
|
||||
WILDLIFE_OVERLAY_SOURCE_ID,
|
||||
WILDLIFE_OVERLAY_SOURCE_OPTIONS,
|
||||
WILDLIFE_LABEL_SOURCE_ID,
|
||||
WILDLIFE_LABEL_SOURCE_OPTIONS,
|
||||
} from '../layers/wildlifeSightings/cards.js';
|
||||
import {
|
||||
AMBIENT_CARD_COLLISION_CAPACITY,
|
||||
WORLD_OVERLAY_OCCLUDER_SELECTORS,
|
||||
@@ -1596,6 +1605,171 @@ test('bounded per-source cohorts feed the domain arbiter without universal selec
|
||||
env.cleanup();
|
||||
});
|
||||
|
||||
test('wildlife common names use the host ambient collision domain and never paint overlapping labels', (t) => {
|
||||
const env = installMockEnvironment({ width: 1000, height: 600, dpr: 1 });
|
||||
t.after(() => env.cleanup());
|
||||
initWorldOverlay(env.viewer);
|
||||
const entries = [
|
||||
['hawk', 'Red-tailed Hawk', position()],
|
||||
['maple', 'Red Maple', position()],
|
||||
['butterfly', 'Monarch', new Cesium.Cartesian3(-0.6, 0.6, 0)],
|
||||
['unknown', null, position()],
|
||||
].map(([stableId, commonName, anchor]) => {
|
||||
const entry = buildWildlifeLabelEntry({ stableId, commonName, iconic: 'Aves' });
|
||||
return entry && { ...entry, position: anchor, horizonCull: false, edgeFade: 'none' };
|
||||
}).filter(Boolean);
|
||||
assert.equal(entries.length, 3, 'unidentified icons never enter the overlay host');
|
||||
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, entries, WILDLIFE_LABEL_SOURCE_OPTIONS);
|
||||
env.postRender.raise();
|
||||
env.advanceTime(200);
|
||||
env.postRender.raise();
|
||||
const rects = entries.map((entry) => getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, entry.id)).filter(Boolean);
|
||||
assert.ok(rects.length >= 2 && rects.length <= 3, 'the host places separated names and relocates or drops coincident names');
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
assert.equal(rects[i].entry.collisionGroup, 'ambient-label');
|
||||
assert.equal(rects[i].entry.protected, false);
|
||||
for (let j = i + 1; j < rects.length; j++) {
|
||||
const a = rects[i], b = rects[j];
|
||||
assert.ok(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const CITY_NOW = Date.parse('2026-11-04T18:00:00Z');
|
||||
function citySighting(id, overrides = {}) {
|
||||
return {
|
||||
stableId: id, commonName: `Synthetic city species ${id}`, iconic: 'Aves',
|
||||
observedAt: CITY_NOW - 60_000, quality: 'research', favesCount: 1,
|
||||
photo: {
|
||||
square: 'https://inaturalist-open-data.s3.amazonaws.com/photos/1/square.jpg',
|
||||
licence: 'cc-by', attribution: 'Synthetic Photographer',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
function cityLabel(row, x, y, width = 1000, height = 600) {
|
||||
return {
|
||||
...buildWildlifeLabelEntry(row, { nowMs: CITY_NOW }),
|
||||
position: positionAtScreen(x, y, width, height), horizonCull: false, edgeFade: 'none',
|
||||
};
|
||||
}
|
||||
function cityCard(row, x, y, width = 1000, height = 600, image = { width: 75, height: 75 }) {
|
||||
return {
|
||||
...buildWildlifeThumbnailEntry(row, CITY_NOW, { image }),
|
||||
position: positionAtScreen(x, y, width, height), horizonCull: false, edgeFade: 'none',
|
||||
maxDistance: Infinity,
|
||||
};
|
||||
}
|
||||
function assertDisjointPaint(entries) {
|
||||
const rects = entries.map(([source, entry]) => getOverlayPaintRect(source, entry.id)).filter(Boolean);
|
||||
for (let i = 0; i < rects.length; i++)
|
||||
for (let j = i + 1; j < rects.length; j++) {
|
||||
const a = rects[i], b = rects[j];
|
||||
assert.ok(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y,
|
||||
`${a.entryId} overlaps ${b.entryId}`);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
test('close-range dense city labels and photo cards never overlap, including frames between camera solves', (t) => {
|
||||
const env = installMockEnvironment({ width: 1000, height: 600, dpr: 1 });
|
||||
t.after(() => env.cleanup());
|
||||
env.viewer.camera.positionCartographic.height = 9000;
|
||||
initWorldOverlay(env.viewer);
|
||||
const labels = Array.from({ length: 150 }, (_, i) =>
|
||||
cityLabel(citySighting(`city:${i}`, { favesCount: 150 - i }), 65 + (i % 30) * 28, 150 + Math.floor(i / 30) * 62));
|
||||
const cards = Array.from({ length: 8 }, (_, i) =>
|
||||
cityCard(citySighting(`photo:${i}`), 140 + (i % 4) * 210, 180 + Math.floor(i / 4) * 240));
|
||||
const all = [...labels.map((e) => [WILDLIFE_LABEL_SOURCE_ID, e]), ...cards.map((e) => [WILDLIFE_OVERLAY_SOURCE_ID, e])];
|
||||
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, labels, WILDLIFE_LABEL_SOURCE_OPTIONS);
|
||||
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, cards, WILDLIFE_OVERLAY_SOURCE_OPTIONS);
|
||||
for (const ms of [0, 75, 125]) {
|
||||
env.advanceTime(ms);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint(all);
|
||||
}
|
||||
assert.equal(cards.filter((e) => getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, e.id)).length, 8);
|
||||
const placedLabels = labels.filter((e) => getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, e.id));
|
||||
assert.ok(placedLabels.length > 5 && placedLabels.length < labels.length, 'label count is capped by the remaining space');
|
||||
// A pan/zoom reprojects sticky placements on every frame before moveEnd.
|
||||
env.viewer.camera.frustum.projectionMatrix[0] = 0.7;
|
||||
env.viewer.camera.frustum.projectionMatrix[5] = 0.8;
|
||||
env.advanceTime(16);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint(all);
|
||||
env.moveEnd.raise();
|
||||
env.advanceTime(75);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint(all);
|
||||
});
|
||||
|
||||
test('labels yield immediately to photo cards, then compete by ambientScore without disturbing the card', (t) => {
|
||||
const env = installMockEnvironment({ width: 400, height: 350, dpr: 1 });
|
||||
t.after(() => env.cleanup());
|
||||
initWorldOverlay(env.viewer);
|
||||
const low = cityLabel(citySighting('low'), 200, 210, 400, 350);
|
||||
const high = cityLabel(citySighting('high', { favesCount: 1000 }), 200, 210, 400, 350);
|
||||
const card = cityCard(citySighting('card', { quality: 'needs_id' }), 200, 210, 400, 350);
|
||||
const all = [[WILDLIFE_LABEL_SOURCE_ID, low], [WILDLIFE_LABEL_SOURCE_ID, high], [WILDLIFE_OVERLAY_SOURCE_ID, card]];
|
||||
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, [low, high], WILDLIFE_LABEL_SOURCE_OPTIONS);
|
||||
env.postRender.raise();
|
||||
env.advanceTime(200);
|
||||
env.postRender.raise();
|
||||
assert.equal(assertDisjointPaint(all).length, 2, 'both label slots are initially free');
|
||||
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [card], WILDLIFE_OVERLAY_SOURCE_OPTIONS);
|
||||
env.postRender.raise();
|
||||
for (const ms of [1, 74, 125]) {
|
||||
env.advanceTime(ms);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint(all);
|
||||
assert.ok(getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id));
|
||||
assert.ok(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, high.id));
|
||||
assert.equal(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, low.id), null);
|
||||
}
|
||||
const before = { ...getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id) };
|
||||
const promoted = cityLabel(citySighting('low', { favesCount: 1_000_000 }), 200, 210, 400, 350);
|
||||
setOverlayEntries(WILDLIFE_LABEL_SOURCE_ID, [promoted, high], WILDLIFE_LABEL_SOURCE_OPTIONS);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint(all);
|
||||
assert.ok(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, low.id));
|
||||
assert.equal(getOverlayPaintRect(WILDLIFE_LABEL_SOURCE_ID, high.id), null);
|
||||
const after = getOverlayPaintRect(WILDLIFE_OVERLAY_SOURCE_ID, card.id);
|
||||
assert.deepEqual([after.x, after.y, after.w, after.h], [before.x, before.y, before.w, before.h], 'the card retains its sticky placement');
|
||||
const selected = { ...buildWildlifeSelectedLabel(citySighting('selected')), position: card.position, horizonCull: false, edgeFade: 'none' };
|
||||
setOverlayEntries('wildlife-sightings-selected', [selected]);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint([...all, ['wildlife-sightings-selected', selected]]);
|
||||
// Exit fades still occupy space; labels must wait until the card is gone.
|
||||
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [], WILDLIFE_OVERLAY_SOURCE_OPTIONS);
|
||||
for (const ms of [1, 149, 151]) {
|
||||
env.advanceTime(ms);
|
||||
env.postRender.raise();
|
||||
assertDisjointPaint([...all, ['wildlife-sightings-selected', selected]]);
|
||||
}
|
||||
});
|
||||
|
||||
test('photo visibility reports only painted membership changes, ignoring loading cards and idle frames', (t) => {
|
||||
const env = installMockEnvironment({ width: 400, height: 350, dpr: 1 });
|
||||
t.after(() => env.cleanup());
|
||||
initWorldOverlay(env.viewer);
|
||||
const changes = [];
|
||||
const options = { ...WILDLIFE_OVERLAY_SOURCE_OPTIONS, onVisibilityChange: (ids) => changes.push([...ids]) };
|
||||
const r = citySighting('anchor');
|
||||
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350, null)], options);
|
||||
env.postRender.raise();
|
||||
assert.deepEqual(changes, []);
|
||||
setOverlayEntries(WILDLIFE_OVERLAY_SOURCE_ID, [cityCard(r, 200, 210, 400, 350)], options);
|
||||
env.postRender.raise();
|
||||
env.advanceTime(200);
|
||||
env.postRender.raise();
|
||||
assert.deepEqual(changes, [['sighting:anchor']]);
|
||||
for (let i = 0; i < 10; i++) env.postRender.raise();
|
||||
assert.equal(changes.length, 1, 'unchanged visibility emits nothing');
|
||||
setOverlaySourceVisible(WILDLIFE_OVERLAY_SOURCE_ID, false);
|
||||
env.postRender.raise();
|
||||
assert.deepEqual(changes, [['sighting:anchor'], []]);
|
||||
});
|
||||
|
||||
test('FIRMS and vessels enlarge the ambient-card lane under an explicit aggregate budget', () => {
|
||||
const env = installMockEnvironment({ width: 1600, height: 900, dpr: 1 });
|
||||
initWorldOverlay(env.viewer);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Solid chrome that card placement should avoid. It composites above the
|
||||
// overlay; the host needs no paint mask. Cockpit line art is intentionally
|
||||
// excluded so its large bounds cannot suppress world-space content.
|
||||
export const WORLD_OVERLAY_OCCLUDER_SELECTORS = Object.freeze([
|
||||
'#title-bar',
|
||||
'#style-indicator',
|
||||
'#top-center-actions',
|
||||
'#traffic-sync-chip',
|
||||
'#cctv-sync-chip',
|
||||
'#left-panel-stack',
|
||||
'#right-context-rail',
|
||||
'.weather-summary',
|
||||
'#pp-toggles',
|
||||
'#command-dock',
|
||||
'#gev-voice-control',
|
||||
'#cesium-credits',
|
||||
'.hud-top-left',
|
||||
'.hud-top-right',
|
||||
'.hud-bottom-left',
|
||||
'.hud-bottom-right',
|
||||
'.hud-bottom-bar',
|
||||
'#space-mission-panel',
|
||||
'#space-mission-panel-host',
|
||||
'#military-awareness-panel',
|
||||
'#bhote-koshi-event-panel',
|
||||
// Only the cockpit's solid windows obstruct readable cards.
|
||||
'#cockpit-context',
|
||||
'#cockpit-signal-stream',
|
||||
]);
|
||||
@@ -312,9 +312,10 @@ function trackDisplayText(entry) {
|
||||
* @returns {{w:number,h:number,padX:number,padY:number,titleH:number,lineH:number,thumbW:number,thumbH:number}}
|
||||
*/
|
||||
export function measureOverlayEntry(ctx, entry, out = {}) {
|
||||
const variant = entry?.selected
|
||||
? 'selected'
|
||||
: String(entry?.variant || 'label');
|
||||
const variant =
|
||||
entry?.selected && entry?.variant !== 'thumbnail'
|
||||
? 'selected'
|
||||
: String(entry?.variant || 'label');
|
||||
const details = Array.isArray(entry?.details) ? entry.details : [];
|
||||
const selected = variant === 'selected';
|
||||
const tracked = variant === 'tracked';
|
||||
@@ -391,7 +392,13 @@ export function measureOverlayEntry(ctx, entry, out = {}) {
|
||||
out.thumbW = Math.max(1, Number(entry?.thumbnailWidth) || 96);
|
||||
out.thumbH = Math.max(1, Number(entry?.thumbnailHeight) || 54);
|
||||
out.w = out.thumbW + out.padX * 2;
|
||||
out.h = out.padY + out.thumbH + out.titleGap + out.titleH + out.padBottom;
|
||||
out.h =
|
||||
out.padY +
|
||||
out.thumbH +
|
||||
out.titleGap +
|
||||
out.titleH +
|
||||
details.length * out.lineH +
|
||||
out.padBottom;
|
||||
} else {
|
||||
out.w = Math.ceil(Math.max(titleWidth, detailWidth)) + out.padX * 2;
|
||||
out.h = out.padY * 2 + out.titleH + details.length * out.lineH;
|
||||
@@ -912,6 +919,38 @@ export function paintCard(
|
||||
return placement.rect;
|
||||
}
|
||||
|
||||
/** Bound a title by code points, preferring a whole word before the ellipsis. */
|
||||
export function truncateOverlayTitle(value, maxChars) {
|
||||
const text = String(value ?? '');
|
||||
if (text.length <= maxChars) return text;
|
||||
const glyphs = Array.from(text);
|
||||
if (glyphs.length <= maxChars) return text;
|
||||
const length = Math.max(0, maxChars - 1);
|
||||
const prefix = glyphs.slice(0, length).join('');
|
||||
const atBoundary = /\s$/u.test(prefix) || /\s/u.test(glyphs[length] || '');
|
||||
const words = atBoundary
|
||||
? prefix.trimEnd()
|
||||
: prefix.replace(/\s+\S*$/u, '').trimEnd();
|
||||
return `${words || prefix.trimEnd()}…`;
|
||||
}
|
||||
|
||||
function fitThumbnailText(ctx, value, font, maxWidth, wordBoundary = false) {
|
||||
const text = String(value ?? '');
|
||||
if (measureWorldOverlayText(ctx, text, font) <= maxWidth) return text;
|
||||
const glyphs = Array.from(text);
|
||||
let low = 0;
|
||||
let high = glyphs.length;
|
||||
while (low < high) {
|
||||
const mid = Math.ceil((low + high) / 2);
|
||||
const candidate = `${glyphs.slice(0, mid).join('')}…`;
|
||||
if (measureWorldOverlayText(ctx, candidate, font) <= maxWidth) low = mid;
|
||||
else high = mid - 1;
|
||||
}
|
||||
return wordBoundary
|
||||
? truncateOverlayTitle(text, low + 1)
|
||||
: `${glyphs.slice(0, low).join('')}…`;
|
||||
}
|
||||
|
||||
/** Paint a thumbnail card. The image slot remains source-owned. */
|
||||
export function paintThumbnail(
|
||||
ctx,
|
||||
@@ -928,6 +967,7 @@ export function paintThumbnail(
|
||||
const thumbW = layout.thumbW || Number(entry.thumbnailWidth) || 96;
|
||||
const thumbH = layout.thumbH || Number(entry.thumbnailHeight) || 54;
|
||||
const titleH = Number.isFinite(layout.titleH) ? layout.titleH : 13;
|
||||
const details = Array.isArray(entry.details) ? entry.details : [];
|
||||
const image = entry.image?.frame ?? entry.image;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
@@ -942,16 +982,38 @@ export function paintThumbnail(
|
||||
);
|
||||
drawAnchorDot(ctx, entry, placement);
|
||||
ctx.globalAlpha = alpha * Math.max(0, Math.min(1, Number(contentAlpha) || 0));
|
||||
ctx.beginPath();
|
||||
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
|
||||
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
|
||||
ctx.fill();
|
||||
if (entry.selected) {
|
||||
drawCardChrome(ctx, entry, placement, true, false);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
roundedRectPath(ctx, x, y, w, h, Number(entry.thumbnailRadius) || 4);
|
||||
ctx.fillStyle = entry.thumbnailBackground || WORLD_OVERLAY_STYLE.background;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
const imageX = x + padX;
|
||||
const imageY = y + padY;
|
||||
if (image && typeof ctx.drawImage === 'function') {
|
||||
try {
|
||||
ctx.drawImage(image, imageX, imageY, thumbW, thumbH);
|
||||
const sourceW = Number(
|
||||
image.naturalWidth || image.videoWidth || image.width,
|
||||
);
|
||||
const sourceH = Number(
|
||||
image.naturalHeight || image.videoHeight || image.height,
|
||||
);
|
||||
const scale =
|
||||
entry.thumbnailFit === 'contain' && sourceW > 0 && sourceH > 0
|
||||
? Math.min(thumbW / sourceW, thumbH / sourceH)
|
||||
: null;
|
||||
const width = scale == null ? thumbW : sourceW * scale;
|
||||
const height = scale == null ? thumbH : sourceH * scale;
|
||||
ctx.drawImage(
|
||||
image,
|
||||
imageX + (thumbW - width) / 2,
|
||||
imageY + (thumbH - height) / 2,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
} catch {
|
||||
// A source may replace/close a live image slot between frames. Chrome
|
||||
// and text remain valid; the next source update supplies the next frame.
|
||||
@@ -978,12 +1040,41 @@ export function paintThumbnail(
|
||||
ctx.fillStyle = entry.thumbnailTitleColor || WORLD_OVERLAY_STYLE.title;
|
||||
ctx.font = entry.thumbnailTitleFont || WORLD_OVERLAY_STYLE.fontTitle;
|
||||
if (title && titleH > 0) {
|
||||
const label =
|
||||
titleChars > 0
|
||||
? entry.thumbnailTitleWordBoundary
|
||||
? truncateOverlayTitle(title, titleChars)
|
||||
: title.slice(0, titleChars)
|
||||
: title;
|
||||
ctx.fillText(
|
||||
titleChars > 0 ? title.slice(0, titleChars) : title,
|
||||
entry.thumbnailFixedWidth === true
|
||||
? fitThumbnailText(
|
||||
ctx,
|
||||
label,
|
||||
ctx.font,
|
||||
thumbW,
|
||||
entry.thumbnailTitleWordBoundary,
|
||||
)
|
||||
: label,
|
||||
imageX,
|
||||
imageY + thumbH + titleH - 3,
|
||||
);
|
||||
}
|
||||
if (details.length) {
|
||||
ctx.fillStyle = WORLD_OVERLAY_STYLE.detail;
|
||||
ctx.font = WORLD_OVERLAY_STYLE.fontDetail;
|
||||
const lineH = Number.isFinite(layout.lineH) ? layout.lineH : 13;
|
||||
const titleGap = Number.isFinite(layout.titleGap) ? layout.titleGap : 0;
|
||||
for (let i = 0; i < details.length; i++) {
|
||||
ctx.fillText(
|
||||
entry.thumbnailFixedWidth === true
|
||||
? fitThumbnailText(ctx, details[i], ctx.font, thumbW)
|
||||
: details[i],
|
||||
imageX,
|
||||
imageY + thumbH + titleGap + titleH + (i + 1) * lineH - 3,
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
return placement.rect;
|
||||
}
|
||||
@@ -1125,8 +1216,6 @@ export function paintOverlayEntry(
|
||||
return paintTacticalCard(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'tracked')
|
||||
return paintTracked(ctx, entry, placement, alpha);
|
||||
if (entry.selected || entry.variant === 'selected')
|
||||
return paintSelected(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'thumbnail') {
|
||||
return paintThumbnail(
|
||||
ctx,
|
||||
@@ -1137,6 +1226,8 @@ export function paintOverlayEntry(
|
||||
contentAlpha,
|
||||
);
|
||||
}
|
||||
if (entry.selected || entry.variant === 'selected')
|
||||
return paintSelected(ctx, entry, placement, alpha);
|
||||
if (entry.variant === 'card') {
|
||||
return paintCard(
|
||||
ctx,
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
placementVariants,
|
||||
roundedRectPath,
|
||||
} from './worldOverlayDraw.js';
|
||||
import { createCctvThumbnailOverlayEntry, createFrameSlot } from '../data/cctvCards.js';
|
||||
import {
|
||||
createCctvThumbnailOverlayEntry,
|
||||
createFrameSlot,
|
||||
} from '../data/cctvCards.js';
|
||||
import { normalizeOverlayEntry } from './worldOverlay.js';
|
||||
import {
|
||||
CARD_PLATE_ALPHA,
|
||||
DETECTION_PLATE_BAND,
|
||||
@@ -35,7 +39,9 @@ import {
|
||||
} from './worldOverlayTokens.js';
|
||||
|
||||
function alphaOf(rgba) {
|
||||
const match = /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([0-9.]+)\s*\)/.exec(String(rgba));
|
||||
const match = /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([0-9.]+)\s*\)/.exec(
|
||||
String(rgba),
|
||||
);
|
||||
assert.ok(match, `expected an rgba() colour, got ${rgba}`);
|
||||
return Number(match[1]);
|
||||
}
|
||||
@@ -48,28 +54,64 @@ function mockContext() {
|
||||
calls,
|
||||
font: '',
|
||||
globalAlpha: 1,
|
||||
get strokeStyle() { return strokeStyle; },
|
||||
set strokeStyle(value) { strokeStyle = value; calls.push(['strokeStyle', value]); },
|
||||
get lineWidth() { return lineWidth; },
|
||||
set lineWidth(value) { lineWidth = value; calls.push(['lineWidth', value]); },
|
||||
get strokeStyle() {
|
||||
return strokeStyle;
|
||||
},
|
||||
set strokeStyle(value) {
|
||||
strokeStyle = value;
|
||||
calls.push(['strokeStyle', value]);
|
||||
},
|
||||
get lineWidth() {
|
||||
return lineWidth;
|
||||
},
|
||||
set lineWidth(value) {
|
||||
lineWidth = value;
|
||||
calls.push(['lineWidth', value]);
|
||||
},
|
||||
measureCount: 0,
|
||||
measureText(text) {
|
||||
this.measureCount++;
|
||||
return { width: String(text).length * 6 };
|
||||
},
|
||||
save() { calls.push(['save']); },
|
||||
restore() { calls.push(['restore']); },
|
||||
beginPath() { calls.push(['beginPath']); },
|
||||
roundRect(...args) { calls.push(['roundRect', ...args]); },
|
||||
moveTo(...args) { calls.push(['moveTo', ...args]); },
|
||||
lineTo(...args) { calls.push(['lineTo', ...args]); },
|
||||
arcTo(...args) { calls.push(['arcTo', ...args]); },
|
||||
closePath() { calls.push(['closePath']); },
|
||||
fill() { calls.push(['fill']); },
|
||||
stroke() { calls.push(['stroke']); },
|
||||
fillRect(...args) { calls.push(['fillRect', ...args]); },
|
||||
fillText(...args) { calls.push(['fillText', ...args]); },
|
||||
drawImage(...args) { calls.push(['drawImage', ...args]); },
|
||||
save() {
|
||||
calls.push(['save']);
|
||||
},
|
||||
restore() {
|
||||
calls.push(['restore']);
|
||||
},
|
||||
beginPath() {
|
||||
calls.push(['beginPath']);
|
||||
},
|
||||
roundRect(...args) {
|
||||
calls.push(['roundRect', ...args]);
|
||||
},
|
||||
moveTo(...args) {
|
||||
calls.push(['moveTo', ...args]);
|
||||
},
|
||||
lineTo(...args) {
|
||||
calls.push(['lineTo', ...args]);
|
||||
},
|
||||
arcTo(...args) {
|
||||
calls.push(['arcTo', ...args]);
|
||||
},
|
||||
closePath() {
|
||||
calls.push(['closePath']);
|
||||
},
|
||||
fill() {
|
||||
calls.push(['fill']);
|
||||
},
|
||||
stroke() {
|
||||
calls.push(['stroke']);
|
||||
},
|
||||
fillRect(...args) {
|
||||
calls.push(['fillRect', ...args]);
|
||||
},
|
||||
fillText(...args) {
|
||||
calls.push(['fillText', ...args]);
|
||||
},
|
||||
drawImage(...args) {
|
||||
calls.push(['drawImage', ...args]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,14 +127,17 @@ test('every detection theme carries a callout plate inside the ambient band', ()
|
||||
const plate = alphaOf(theme.calloutPlate);
|
||||
const space = alphaOf(theme.calloutPlateSpace);
|
||||
assert.ok(
|
||||
plate >= DETECTION_PLATE_BAND.min * CARD_PLATE_ALPHA
|
||||
&& plate <= DETECTION_PLATE_BAND.max * CARD_PLATE_ALPHA,
|
||||
plate >= DETECTION_PLATE_BAND.min * CARD_PLATE_ALPHA &&
|
||||
plate <= DETECTION_PLATE_BAND.max * CARD_PLATE_ALPHA,
|
||||
`${name} callout plate ${plate} outside the ambient band`,
|
||||
);
|
||||
// The owner's spec: satellites read over the lit Earth disc, so the space
|
||||
// tier pops slightly harder — but never as heavy as the tracked card.
|
||||
assert.ok(space > plate, `${name} space plate must exceed the base plate`);
|
||||
assert.ok(space < CARD_PLATE_ALPHA, `${name} space plate must stay under the card`);
|
||||
assert.ok(
|
||||
space < CARD_PLATE_ALPHA,
|
||||
`${name} space plate must stay under the card`,
|
||||
);
|
||||
// The scanline wash is a separate token and must not have been repurposed.
|
||||
assert.notEqual(theme.calloutPlate, theme.labelBg);
|
||||
}
|
||||
@@ -100,47 +145,78 @@ test('every detection theme carries a callout plate inside the ambient band', ()
|
||||
|
||||
test('an ambient detection callout paints its backing plate under its text', () => {
|
||||
const ctx = mockContext();
|
||||
paintDetectionCallout(ctx, {
|
||||
x: 20, y: 30, w: 80, h: 18,
|
||||
primaryX: 27, microX: 70, baseline: 42,
|
||||
leadFromX: 60, leadFromY: 60, leadToX: 60, leadToY: 48,
|
||||
plate: 'rgba(2, 18, 26, 0.52)',
|
||||
accent: '#22e0ff',
|
||||
label: 'rgba(200, 250, 255, 0.97)',
|
||||
primary: 'JA23NF',
|
||||
micro: 'FL017',
|
||||
font: '10px JetBrains Mono, monospace',
|
||||
microFont: '9px JetBrains Mono, monospace',
|
||||
}, 1);
|
||||
paintDetectionCallout(
|
||||
ctx,
|
||||
{
|
||||
x: 20,
|
||||
y: 30,
|
||||
w: 80,
|
||||
h: 18,
|
||||
primaryX: 27,
|
||||
microX: 70,
|
||||
baseline: 42,
|
||||
leadFromX: 60,
|
||||
leadFromY: 60,
|
||||
leadToX: 60,
|
||||
leadToY: 48,
|
||||
plate: 'rgba(2, 18, 26, 0.52)',
|
||||
accent: '#22e0ff',
|
||||
label: 'rgba(200, 250, 255, 0.97)',
|
||||
primary: 'JA23NF',
|
||||
micro: 'FL017',
|
||||
font: '10px JetBrains Mono, monospace',
|
||||
microFont: '9px JetBrains Mono, monospace',
|
||||
},
|
||||
1,
|
||||
);
|
||||
|
||||
const names = ctx.calls.map(([name]) => name);
|
||||
const firstFill = names.indexOf('fill');
|
||||
const firstText = names.indexOf('fillText');
|
||||
assert.ok(firstFill >= 0, 'the callout must fill a backing plate');
|
||||
assert.ok(firstText > firstFill, 'the plate must be painted before the callsign');
|
||||
assert.ok(
|
||||
firstText > firstFill,
|
||||
'the plate must be painted before the callsign',
|
||||
);
|
||||
// Plate geometry: a rounded rect covering the whole measured label box.
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'roundRect'),
|
||||
['roundRect', 20, 30, 80, 18, 3],
|
||||
);
|
||||
assert.ok(ctx.calls.some(([name, text]) => name === 'fillText' && text === 'JA23NF'));
|
||||
assert.ok(ctx.calls.some(([name, text]) => name === 'fillText' && text === 'FL017'));
|
||||
assert.ok(
|
||||
ctx.calls.some(([name, text]) => name === 'fillText' && text === 'JA23NF'),
|
||||
);
|
||||
assert.ok(
|
||||
ctx.calls.some(([name, text]) => name === 'fillText' && text === 'FL017'),
|
||||
);
|
||||
});
|
||||
|
||||
test('a callout with no micro-field paints one text run and still gets a plate', () => {
|
||||
const ctx = mockContext();
|
||||
paintDetectionCallout(ctx, {
|
||||
x: 0, y: 0, w: 40, h: 18,
|
||||
primaryX: 7, microX: 30, baseline: 12,
|
||||
leadFromX: 20, leadFromY: 30, leadToX: 20, leadToY: 18,
|
||||
plate: 'rgba(2, 18, 26, 0.52)',
|
||||
accent: '#22e0ff',
|
||||
label: '#fff',
|
||||
primary: 'N12345',
|
||||
micro: '',
|
||||
font: '10px mono',
|
||||
microFont: '9px mono',
|
||||
}, 0.5);
|
||||
paintDetectionCallout(
|
||||
ctx,
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 40,
|
||||
h: 18,
|
||||
primaryX: 7,
|
||||
microX: 30,
|
||||
baseline: 12,
|
||||
leadFromX: 20,
|
||||
leadFromY: 30,
|
||||
leadToX: 20,
|
||||
leadToY: 18,
|
||||
plate: 'rgba(2, 18, 26, 0.52)',
|
||||
accent: '#22e0ff',
|
||||
label: '#fff',
|
||||
primary: 'N12345',
|
||||
micro: '',
|
||||
font: '10px mono',
|
||||
microFont: '9px mono',
|
||||
},
|
||||
0.5,
|
||||
);
|
||||
|
||||
assert.ok(ctx.calls.some(([name]) => name === 'fill'));
|
||||
assert.equal(ctx.calls.filter(([name]) => name === 'fillText').length, 1);
|
||||
@@ -167,7 +243,7 @@ test('distance scale matches the legacy infrastructure NearFarScalar curve', ()
|
||||
assert.equal(distanceScale(1_000_000, null), 1);
|
||||
});
|
||||
|
||||
test('altitude scale preserves CCTV\'s exact smoothstep and linear waypoints', () => {
|
||||
test("altitude scale preserves CCTV's exact smoothstep and linear waypoints", () => {
|
||||
const curve = {
|
||||
fullEnd: 1800,
|
||||
midEnd: 6000,
|
||||
@@ -185,13 +261,16 @@ test('altitude scale preserves CCTV\'s exact smoothstep and linear waypoints', (
|
||||
});
|
||||
|
||||
test('the five-channel alpha chain is multiplicative and clamps inputs', () => {
|
||||
assert.equal(combinedOverlayAlpha({
|
||||
sourceAlpha: 0.5,
|
||||
temporalFade: 0.8,
|
||||
distanceFade: 0.5,
|
||||
altitudeFade: 0.5,
|
||||
keyholeEdgeFade: 0.25,
|
||||
}), 0.025);
|
||||
assert.equal(
|
||||
combinedOverlayAlpha({
|
||||
sourceAlpha: 0.5,
|
||||
temporalFade: 0.8,
|
||||
distanceFade: 0.5,
|
||||
altitudeFade: 0.5,
|
||||
keyholeEdgeFade: 0.25,
|
||||
}),
|
||||
0.025,
|
||||
);
|
||||
assert.equal(combinedOverlayAlpha({ sourceAlpha: 2, temporalFade: -1 }), 0);
|
||||
});
|
||||
|
||||
@@ -205,7 +284,10 @@ test('placement variants flip below near the top and stay viewport-clamped', ()
|
||||
viewportHeight: 100,
|
||||
gap: 10,
|
||||
});
|
||||
assert.deepEqual(nearTop.map((item) => item.corner), ['below', 'above', 'right', 'left']);
|
||||
assert.deepEqual(
|
||||
nearTop.map((item) => item.corner),
|
||||
['below', 'above', 'right', 'left'],
|
||||
);
|
||||
for (const placement of nearTop) {
|
||||
assert.ok(placement.rect.x >= 4);
|
||||
assert.ok(placement.rect.y >= 4);
|
||||
@@ -213,14 +295,20 @@ test('placement variants flip below near the top and stay viewport-clamped', ()
|
||||
assert.ok(placement.rect.y + placement.rect.h <= 96);
|
||||
}
|
||||
const reused = nearTop.slice();
|
||||
assert.equal(placementVariants({
|
||||
anchorX: 100,
|
||||
anchorY: 80,
|
||||
width: 40,
|
||||
height: 20,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
}, reused), reused);
|
||||
assert.equal(
|
||||
placementVariants(
|
||||
{
|
||||
anchorX: 100,
|
||||
anchorY: 80,
|
||||
width: 40,
|
||||
height: 20,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 100,
|
||||
},
|
||||
reused,
|
||||
),
|
||||
reused,
|
||||
);
|
||||
assert.equal(reused[0].corner, 'above');
|
||||
});
|
||||
|
||||
@@ -236,10 +324,16 @@ test('tactical cards retain vertical-only placement and sprite-edge leaders', ()
|
||||
leaderOffset: 14,
|
||||
verticalOnly: true,
|
||||
});
|
||||
assert.deepEqual(placements.map(({ corner }) => corner), ['above', 'below']);
|
||||
assert.deepEqual(
|
||||
placements.map(({ corner }) => corner),
|
||||
['above', 'below'],
|
||||
);
|
||||
assert.equal(placements[0].leadFromY, 80);
|
||||
assert.equal(placements[0].leaderOffset, -14);
|
||||
assert.equal(placements[0].leadToY, placements[0].rect.y + placements[0].rect.h);
|
||||
assert.equal(
|
||||
placements[0].leadToY,
|
||||
placements[0].rect.y + placements[0].rect.h,
|
||||
);
|
||||
});
|
||||
|
||||
test('edge-clamped CCTV placements keep leaders vertical unless the anchor is outside the card', () => {
|
||||
@@ -254,7 +348,11 @@ test('edge-clamped CCTV placements keep leaders vertical unless the anchor is ou
|
||||
leaderOffset: 16,
|
||||
verticalOnly: true,
|
||||
})[0];
|
||||
assert.equal(edgeClamped.rect.x, 4, 'card is horizontally clamped at the viewport edge');
|
||||
assert.equal(
|
||||
edgeClamped.rect.x,
|
||||
4,
|
||||
'card is horizontally clamped at the viewport edge',
|
||||
);
|
||||
assert.equal(edgeClamped.leadFromX, 20);
|
||||
assert.equal(edgeClamped.leadToX, 20, 'leader stays strictly vertical at sx');
|
||||
|
||||
@@ -277,8 +375,11 @@ test('edge-clamped CCTV placements keep leaders vertical unless the anchor is ou
|
||||
// camera". Vertical always, even when the card has been clamped sideways off
|
||||
// its anchor.
|
||||
assert.equal(offRectAnchor.leadFromX, 2);
|
||||
assert.equal(offRectAnchor.leadToX, 2,
|
||||
'an off-rect anchor keeps the leader vertical rather than slanting it to the card');
|
||||
assert.equal(
|
||||
offRectAnchor.leadToX,
|
||||
2,
|
||||
'an off-rect anchor keeps the leader vertical rather than slanting it to the card',
|
||||
);
|
||||
});
|
||||
|
||||
test('rounded rectangles use native support and retain a fallback path', () => {
|
||||
@@ -334,7 +435,8 @@ test('text measurement cache is font-aware and font hooks follow host lifetime',
|
||||
test('text measurement cache caps LRU retention at 1024 entries', () => {
|
||||
clearWorldOverlayTextMeasureCache();
|
||||
const ctx = mockContext();
|
||||
for (let i = 0; i < 1100; i++) measureWorldOverlayText(ctx, `label-${i}`, '10px mono');
|
||||
for (let i = 0; i < 1100; i++)
|
||||
measureWorldOverlayText(ctx, `label-${i}`, '10px mono');
|
||||
assert.equal(getWorldOverlayTextMeasureCacheSize(), 1024);
|
||||
const measured = ctx.measureCount;
|
||||
measureWorldOverlayText(ctx, 'label-0', '10px mono');
|
||||
@@ -355,10 +457,28 @@ test('variant measurement and all six painters remain renderer-local', () => {
|
||||
thumbnailWidth: 96,
|
||||
thumbnailHeight: 54,
|
||||
};
|
||||
const variants = ['label', 'track', 'card', 'thumbnail', 'selected', 'tracked'];
|
||||
const painters = [paintLabel, paintTrack, paintCard, paintThumbnail, paintSelected, paintTracked];
|
||||
const variants = [
|
||||
'label',
|
||||
'track',
|
||||
'card',
|
||||
'thumbnail',
|
||||
'selected',
|
||||
'tracked',
|
||||
];
|
||||
const painters = [
|
||||
paintLabel,
|
||||
paintTrack,
|
||||
paintCard,
|
||||
paintThumbnail,
|
||||
paintSelected,
|
||||
paintTracked,
|
||||
];
|
||||
for (let i = 0; i < variants.length; i++) {
|
||||
const variantEntry = { ...entry, variant: variants[i], selected: variants[i] === 'selected' };
|
||||
const variantEntry = {
|
||||
...entry,
|
||||
variant: variants[i],
|
||||
selected: variants[i] === 'selected',
|
||||
};
|
||||
const layout = measureOverlayEntry(ctx, variantEntry, {});
|
||||
variantEntry._overlayLayout = layout;
|
||||
const placement = placementVariants({
|
||||
@@ -369,18 +489,27 @@ test('variant measurement and all six painters remain renderer-local', () => {
|
||||
viewportWidth: 400,
|
||||
viewportHeight: 300,
|
||||
})[0];
|
||||
assert.equal(painters[i](ctx, variantEntry, placement, 0.5), placement.rect);
|
||||
assert.equal(
|
||||
painters[i](ctx, variantEntry, placement, 0.5),
|
||||
placement.rect,
|
||||
);
|
||||
}
|
||||
assert.ok(ctx.calls.some(([name]) => name === 'drawImage'));
|
||||
assert.ok(ctx.calls.filter(([name]) => name === 'fillText').length >= variants.length);
|
||||
const trackLayout = measureOverlayEntry(ctx, { ...entry, variant: 'track' }, {});
|
||||
assert.ok(
|
||||
ctx.calls.filter(([name]) => name === 'fillText').length >= variants.length,
|
||||
);
|
||||
const trackLayout = measureOverlayEntry(
|
||||
ctx,
|
||||
{ ...entry, variant: 'track' },
|
||||
{},
|
||||
);
|
||||
assert.ok(trackLayout.w >= 'CAMERA 12 · LIVE'.length * 6);
|
||||
});
|
||||
|
||||
test('thumbnail painter preserves the shipped CCTV 104x77 geometry and drawing coordinates', () => {
|
||||
const ctx = mockContext();
|
||||
const frameSlot = createFrameSlot();
|
||||
frameSlot.frame = { width: 192, height: 108 };
|
||||
frameSlot.frame = { width: 192, height: 144 };
|
||||
frameSlot.stamp = 123;
|
||||
const entry = createCctvThumbnailOverlayEntry({
|
||||
id: 'cam-a',
|
||||
@@ -411,7 +540,10 @@ test('thumbnail painter preserves the shipped CCTV 104x77 geometry and drawing c
|
||||
['strokeStyle', 'rgba(107, 232, 255, 0.6)'],
|
||||
'CCTV leader uses the source cyan token rather than the generic leader fallback',
|
||||
);
|
||||
assert.deepEqual(ctx.calls.find(([name]) => name === 'moveTo'), ['moveTo', 200, 184]);
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'moveTo'),
|
||||
['moveTo', 200, 184],
|
||||
);
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'drawImage'),
|
||||
['drawImage', frameSlot.frame, 152, 105, 96, 54],
|
||||
@@ -422,6 +554,69 @@ test('thumbnail painter preserves the shipped CCTV 104x77 geometry and drawing c
|
||||
);
|
||||
});
|
||||
|
||||
test('photo thumbnails letterbox square images and visibly paint attribution and disclaimers', () => {
|
||||
const ctx = mockContext();
|
||||
const entry = {
|
||||
...createCctvThumbnailOverlayEntry({
|
||||
id: 'photo',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
title: 'Synthetic bird',
|
||||
frameSlot: { frame: { width: 75, height: 75 } },
|
||||
}),
|
||||
thumbnailHeight: 72,
|
||||
thumbnailFit: 'contain',
|
||||
details: ['Photo: Synthetic photographer · CC BY', 'Not a census'],
|
||||
};
|
||||
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
|
||||
const { w, h, lineH } = entry._overlayLayout;
|
||||
assert.equal(w, 104, 'canvas photos keep the compact image width');
|
||||
assert.equal(h, 4 + 72 + 2 + 13 + 2 * lineH + 4);
|
||||
const placement = { rect: { x: 10, y: 20, w, h } };
|
||||
paintThumbnail(ctx, entry, placement);
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'drawImage'),
|
||||
['drawImage', entry.image.frame, 26, 24, 72, 72],
|
||||
);
|
||||
for (const detail of entry.details)
|
||||
assert.ok(
|
||||
ctx.calls.some(([name, text]) => name === 'fillText' && text === detail),
|
||||
detail,
|
||||
);
|
||||
});
|
||||
|
||||
test('fixed-width photo thumbnails truncate the title and creator inside the 96px image width', () => {
|
||||
const ctx = mockContext();
|
||||
const entry = normalizeOverlayEntry('compact-photos', {
|
||||
...createCctvThumbnailOverlayEntry({
|
||||
id: 'compact-photo',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
title: 'A long synthetic organism name',
|
||||
frameSlot: { frame: { width: 75, height: 75 } },
|
||||
}),
|
||||
thumbnailHeight: 72,
|
||||
thumbnailFixedWidth: true,
|
||||
details: ['© A very long synthetic photographer name'],
|
||||
});
|
||||
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
|
||||
const { w, h, lineH } = entry._overlayLayout;
|
||||
assert.equal(w, 104, '96px image plus the shared 4px padding on each side');
|
||||
assert.equal(h, 4 + 72 + 2 + 13 + lineH + 4, 'exactly one creator line');
|
||||
const placement = { rect: { x: 10, y: 20, w, h } };
|
||||
paintThumbnail(ctx, entry, placement);
|
||||
const text = ctx.calls.filter(([name]) => name === 'fillText');
|
||||
assert.equal(text.length, 2);
|
||||
assert.equal(
|
||||
text[0][1],
|
||||
'A LONG SYNTHETI',
|
||||
'the shared title character limit remains active',
|
||||
);
|
||||
assert.equal(text[1][1], '© A very long s…');
|
||||
for (const [, label, x] of text) {
|
||||
assert.equal(x, 14);
|
||||
assert.ok(ctx.measureText(label).width <= 96, label);
|
||||
}
|
||||
});
|
||||
|
||||
test('tracked painter preserves centered multi-line readout metrics', () => {
|
||||
const ctx = mockContext();
|
||||
const entry = {
|
||||
@@ -482,15 +677,24 @@ test('shared tactical painter preserves FIRMS card metrics and top-rule treatmen
|
||||
verticalOnly: true,
|
||||
})[0];
|
||||
assert.equal(paintTacticalCard(ctx, entry, placement, 0.5), placement.rect);
|
||||
assert.deepEqual(ctx.calls.find(([name]) => name === 'moveTo'), ['moveTo', 200, 146]);
|
||||
assert.equal(ctx.calls.filter(([name]) => name === 'stroke').length, 2, 'leader + selected border');
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'moveTo'),
|
||||
['moveTo', 200, 146],
|
||||
);
|
||||
assert.equal(
|
||||
ctx.calls.filter(([name]) => name === 'stroke').length,
|
||||
2,
|
||||
'leader + selected border',
|
||||
);
|
||||
assert.equal(ctx.calls.filter(([name]) => name === 'fillText').length, 3);
|
||||
assert.equal(ctx.calls.at(-1)[0], 'restore');
|
||||
});
|
||||
|
||||
test('selected elbow leader reveals from the glyph before its card fades in', () => {
|
||||
const animation = {
|
||||
leaderAnimationMs: 1000, leaderAnimationStartedAt: 100, leaderDrawRatio: 0.7,
|
||||
leaderAnimationMs: 1000,
|
||||
leaderAnimationStartedAt: 100,
|
||||
leaderDrawRatio: 0.7,
|
||||
};
|
||||
assert.equal(leaderRevealProgress(animation, 100), 0);
|
||||
assert.ok(leaderRevealProgress(animation, 450) > 0.8);
|
||||
@@ -500,20 +704,44 @@ test('selected elbow leader reveals from the glyph before its card fades in', ()
|
||||
assert.equal(tacticalCardRevealAlpha(animation, 1100), 1);
|
||||
const ctx = mockContext();
|
||||
const entry = {
|
||||
variant: 'selected', selected: true, cardStyle: 'tactical', title: 'ALPR-2516',
|
||||
details: ['OSM MAPPED'], accent: '#ff6474', leaderStyle: 'elbow',
|
||||
leaderAnimationMs: 1, leaderAnimationStartedAt: 0,
|
||||
variant: 'selected',
|
||||
selected: true,
|
||||
cardStyle: 'tactical',
|
||||
title: 'ALPR-2516',
|
||||
details: ['OSM MAPPED'],
|
||||
accent: '#ff6474',
|
||||
leaderStyle: 'elbow',
|
||||
leaderAnimationMs: 1,
|
||||
leaderAnimationStartedAt: 0,
|
||||
};
|
||||
entry._overlayLayout = measureOverlayEntry(ctx, entry, {});
|
||||
const placement = placementVariants({
|
||||
anchorX: 240, anchorY: 180, width: entry._overlayLayout.w, height: entry._overlayLayout.h,
|
||||
viewportWidth: 500, viewportHeight: 300, gap: 20, leaderOffset: 30, verticalOnly: true,
|
||||
anchorX: 240,
|
||||
anchorY: 180,
|
||||
width: entry._overlayLayout.w,
|
||||
height: entry._overlayLayout.h,
|
||||
viewportWidth: 500,
|
||||
viewportHeight: 300,
|
||||
gap: 20,
|
||||
leaderOffset: 30,
|
||||
verticalOnly: true,
|
||||
})[0];
|
||||
paintTacticalCard(ctx, entry, placement, 1);
|
||||
assert.deepEqual(ctx.calls.find(([name]) => name === 'moveTo'), ['moveTo', 210, 180]);
|
||||
assert.deepEqual(
|
||||
ctx.calls.find(([name]) => name === 'moveTo'),
|
||||
['moveTo', 210, 180],
|
||||
);
|
||||
const lines = ctx.calls.filter(([name]) => name === 'lineTo');
|
||||
assert.ok(lines.length >= 2, 'leader draws horizontally from the glyph, then vertically to the card');
|
||||
assert.ok(ctx.calls.some(([name, value]) => name === 'strokeStyle' && value === 'rgba(255, 100, 116, 0.95)'));
|
||||
assert.ok(
|
||||
lines.length >= 2,
|
||||
'leader draws horizontally from the glyph, then vertically to the card',
|
||||
);
|
||||
assert.ok(
|
||||
ctx.calls.some(
|
||||
([name, value]) =>
|
||||
name === 'strokeStyle' && value === 'rgba(255, 100, 116, 0.95)',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('track display text is cached across measure and paint and invalidates on content change', () => {
|
||||
@@ -558,16 +786,33 @@ function alphaProbe() {
|
||||
const ctx = mockContext();
|
||||
const alphas = { fill: [], text: [], stroke: [] };
|
||||
const inner = { fill: ctx.fill, fillText: ctx.fillText, stroke: ctx.stroke };
|
||||
ctx.fill = function fill(...args) { alphas.fill.push(this.globalAlpha); return inner.fill.apply(this, args); };
|
||||
ctx.fillText = function fillText(...args) { alphas.text.push(this.globalAlpha); return inner.fillText.apply(this, args); };
|
||||
ctx.stroke = function stroke(...args) { alphas.stroke.push(this.globalAlpha); return inner.stroke.apply(this, args); };
|
||||
ctx.fill = function fill(...args) {
|
||||
alphas.fill.push(this.globalAlpha);
|
||||
return inner.fill.apply(this, args);
|
||||
};
|
||||
ctx.fillText = function fillText(...args) {
|
||||
alphas.text.push(this.globalAlpha);
|
||||
return inner.fillText.apply(this, args);
|
||||
};
|
||||
ctx.stroke = function stroke(...args) {
|
||||
alphas.stroke.push(this.globalAlpha);
|
||||
return inner.stroke.apply(this, args);
|
||||
};
|
||||
return { ctx, alphas };
|
||||
}
|
||||
|
||||
const CALLOUT_FIXTURE = Object.freeze({
|
||||
x: 20, y: 30, w: 80, h: 18,
|
||||
primaryX: 27, microX: 70, baseline: 42,
|
||||
leadFromX: 60, leadFromY: 60, leadToX: 60, leadToY: 48,
|
||||
x: 20,
|
||||
y: 30,
|
||||
w: 80,
|
||||
h: 18,
|
||||
primaryX: 27,
|
||||
microX: 70,
|
||||
baseline: 42,
|
||||
leadFromX: 60,
|
||||
leadFromY: 60,
|
||||
leadToX: 60,
|
||||
leadToY: 48,
|
||||
plate: 'rgba(2, 18, 26, 0.52)',
|
||||
accent: '#22e0ff',
|
||||
label: 'rgba(200, 250, 255, 0.97)',
|
||||
@@ -585,20 +830,36 @@ test('a sky-backed callout feathers its PLATE and nothing else', () => {
|
||||
const ground = alphaProbe();
|
||||
paintDetectionCallout(ground.ctx, { ...CALLOUT_FIXTURE, plateScale: 1 }, 0.8);
|
||||
const sky = alphaProbe();
|
||||
paintDetectionCallout(sky.ctx, { ...CALLOUT_FIXTURE, plateScale: SKY_PLATE_SCALE }, 0.8);
|
||||
paintDetectionCallout(
|
||||
sky.ctx,
|
||||
{ ...CALLOUT_FIXTURE, plateScale: SKY_PLATE_SCALE },
|
||||
0.8,
|
||||
);
|
||||
|
||||
// The plate is the FIRST fill; the tier accent bar is the second.
|
||||
assert.ok(Math.abs(ground.alphas.fill[0] - 0.8) < 1e-12, 'ground keeps the full plate');
|
||||
assert.ok(
|
||||
Math.abs(ground.alphas.fill[0] - 0.8) < 1e-12,
|
||||
'ground keeps the full plate',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(sky.alphas.fill[0] - 0.8 * SKY_PLATE_SCALE) < 1e-12,
|
||||
'sky scales the plate by the token, not by some other number',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sky.alphas.fill.slice(1), ground.alphas.fill.slice(1),
|
||||
sky.alphas.fill.slice(1),
|
||||
ground.alphas.fill.slice(1),
|
||||
'the accent bar must not inherit the plate feather',
|
||||
);
|
||||
assert.deepEqual(sky.alphas.text, ground.alphas.text, 'text opacity is untouched');
|
||||
assert.deepEqual(sky.alphas.stroke, ground.alphas.stroke, 'the leader is untouched');
|
||||
assert.deepEqual(
|
||||
sky.alphas.text,
|
||||
ground.alphas.text,
|
||||
'text opacity is untouched',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sky.alphas.stroke,
|
||||
ground.alphas.stroke,
|
||||
'the leader is untouched',
|
||||
);
|
||||
// Same fill token in both: the theme's plate hue is scaled, never swapped.
|
||||
assert.equal(
|
||||
sky.ctx.calls.find(([name]) => name === 'fillStyle')?.[1],
|
||||
@@ -622,7 +883,12 @@ test('the sky plate scale is a whisper, not a second plate', () => {
|
||||
assert.ok(SKY_PLATE_SCALE > 0 && SKY_PLATE_SCALE <= 0.35);
|
||||
// Against the lightest shipped plate this must resolve to near-invisible.
|
||||
const lightest = Math.min(
|
||||
...Object.values(DETECTION_THEME_MAP).map((theme) => alphaOf(theme.calloutPlate)),
|
||||
...Object.values(DETECTION_THEME_MAP).map((theme) =>
|
||||
alphaOf(theme.calloutPlate),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
lightest * SKY_PLATE_SCALE < 0.12,
|
||||
'the feathered plate must read as bare text',
|
||||
);
|
||||
assert.ok(lightest * SKY_PLATE_SCALE < 0.12, 'the feathered plate must read as bare text');
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
discoverPerfTestFiles,
|
||||
perfBudgetTestOptions,
|
||||
perfTestArgs,
|
||||
runPerfTests,
|
||||
} from '../scripts/run-perf-tests.mjs';
|
||||
|
||||
test('timing assertions are skipped with an actionable message unless explicitly enabled', () => {
|
||||
for (const env of [
|
||||
{},
|
||||
{ GEV_PERF_BUDGETS: '0' },
|
||||
{ GEV_PERF_BUDGETS: 'true' },
|
||||
])
|
||||
assert.match(
|
||||
perfBudgetTestOptions(env).skip,
|
||||
/Timing budgets are opt-in; run npm run test:perf/,
|
||||
);
|
||||
assert.deepEqual(perfBudgetTestOptions({ GEV_PERF_BUDGETS: '1' }), {
|
||||
skip: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('test:perf discovers every benchmark and enables timing budgets in a serial process', (t) => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-perf-runner-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const expected = [
|
||||
'server/providers/example/provider.perf.test.mjs',
|
||||
'src/layers/anotherLayer/model.perf.test.mjs',
|
||||
'src/layers/wildlifeSightings/model.perf.test.mjs',
|
||||
];
|
||||
for (const file of [
|
||||
...expected,
|
||||
'src/layers/anotherLayer/functional.test.mjs',
|
||||
]) {
|
||||
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
|
||||
writeFileSync(path.join(root, file), '');
|
||||
}
|
||||
assert.deepEqual(discoverPerfTestFiles(root), expected);
|
||||
const calls = [];
|
||||
const run = (...args) => {
|
||||
calls.push(args);
|
||||
return { status: 7 };
|
||||
};
|
||||
assert.equal(
|
||||
runPerfTests({
|
||||
root,
|
||||
env: { GEV_PERF_BUDGETS: '0', SENTINEL: 'kept' },
|
||||
run,
|
||||
}),
|
||||
7,
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
const [command, args, options] = calls[0];
|
||||
assert.equal(command, process.execPath);
|
||||
assert.deepEqual(args, perfTestArgs(expected));
|
||||
assert.ok(args.includes('--test-concurrency=1'));
|
||||
assert.ok(args.includes('--test-isolation=none'));
|
||||
assert.equal(options.cwd, root);
|
||||
assert.equal(options.env.GEV_PERF_BUDGETS, '1');
|
||||
assert.equal(options.env.SENTINEL, 'kept');
|
||||
assert.throws(
|
||||
() =>
|
||||
runPerfTests({ root, run: () => ({ error: new Error('spawn failed') }) }),
|
||||
/spawn failed/,
|
||||
);
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
assert.equal(pkg.scripts['test:perf'], 'node scripts/run-perf-tests.mjs');
|
||||
});
|
||||
@@ -232,10 +232,17 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
camera.description,
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.',
|
||||
);
|
||||
// Tilt to an angle adds an absolute pitchDeg with its -45° default, and
|
||||
// names tilt steps among the direction's uses; check them, then restore
|
||||
// the legacy fields for the pin.
|
||||
assert.equal(
|
||||
camera.parameters.properties.direction.description,
|
||||
'Required for pan/tilt/rotate; orbit/rotate_globe default right.',
|
||||
'Pan/rotate/tilt steps; orbit/rotate_globe default right.',
|
||||
);
|
||||
assert.equal(camera.parameters.properties.pitchDeg.type, 'number');
|
||||
assert.match(camera.parameters.properties.pitchDeg.description, /^Absolute;/);
|
||||
assert.match(camera.parameters.properties.pitchDeg.description, /-45 default/);
|
||||
delete camera.parameters.properties.pitchDeg;
|
||||
assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.');
|
||||
camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.';
|
||||
camera.parameters.properties.motion.enum =
|
||||
|
||||
@@ -297,6 +297,7 @@ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({
|
||||
sinceDays: 0,
|
||||
}),
|
||||
radio: Object.freeze({ filter: 'all', volume: 0.8 }),
|
||||
'wildlife-sightings': Object.freeze({ confirmed: false, taxa: 'all' }),
|
||||
});
|
||||
|
||||
/** The frozen meaning of an omitted option; unlisted options are always written. */
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
/** Bounded per-key and aggregate request admission. */
|
||||
const RATE_LIMITER_MAX_KEYS = 2000;
|
||||
|
||||
export function makeRateLimiter({ windowMs, max, globalMax }) {
|
||||
export function makeRateLimiter({
|
||||
windowMs,
|
||||
max,
|
||||
globalMax,
|
||||
now = () => Date.now(),
|
||||
}) {
|
||||
const hits = new Map(); // key -> number[] (timestamps within window)
|
||||
let globalTimes = []; // all hits in window, for the global backstop
|
||||
return function allow(key) {
|
||||
const now = Date.now();
|
||||
globalTimes = globalTimes.filter((t) => now - t < windowMs);
|
||||
const at = now();
|
||||
globalTimes = globalTimes.filter((t) => at - t < windowMs);
|
||||
if (globalMax && globalTimes.length >= globalMax) return false; // global backstop
|
||||
const recent = (hits.get(key) || []).filter((t) => now - t < windowMs);
|
||||
const recent = (hits.get(key) || []).filter((t) => at - t < windowMs);
|
||||
if (recent.length >= max) {
|
||||
hits.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(now);
|
||||
recent.push(at);
|
||||
hits.set(key, recent);
|
||||
globalTimes.push(now);
|
||||
globalTimes.push(at);
|
||||
// Hard key cap so a key-rotating caller can't grow the map without bound.
|
||||
if (hits.size > RATE_LIMITER_MAX_KEYS) {
|
||||
const oldest = hits.keys().next().value;
|
||||
@@ -23,7 +28,7 @@ export function makeRateLimiter({ windowMs, max, globalMax }) {
|
||||
}
|
||||
if (hits.size > 256) {
|
||||
for (const [k, v] of hits) {
|
||||
if (!v.length || now - v[v.length - 1] > windowMs) hits.delete(k);
|
||||
if (!v.length || at - v[v.length - 1] > windowMs) hits.delete(k);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user