Merge branch 'main' into fix/node26-allocation-gates

This commit is contained in:
German Khokhlov authored and GitHub committed 2026-11-09 20:19:29 +04:00
commit 015e3ff7fc
738 files changed
+136229 -3138

No files matched your search

+62 -4
View File
@@ -406,6 +406,8 @@ export function createAnnotationEngine({
* @param {boolean} [opts.clearPrevious]
* @param {boolean} [opts.persist] Keep until cleared (default true).
* @param {boolean} [opts.flyTo] Frame the first resolved annotation.
* @param {AbortSignal} [opts.signal] Caller lifetime for this mutation.
* @param {() => boolean} [opts.isCurrent] Whether the caller still owns the board mutation.
* @returns {Promise<{ok, drawn, failed, ids, results}>}
*/
async function annotate(requests, opts = {}) {
@@ -426,9 +428,15 @@ export function createAnnotationEngine({
const persist = opts.persist !== false;
// Per-call cancellation: bumped/aborted by any later clear() or destroy().
const controller = new AbortController();
const abortFromCaller = () => controller.abort(opts.signal?.reason);
opts.signal?.addEventListener?.('abort', abortFromCaller, { once: true });
if (opts.signal?.aborted) abortFromCaller();
retainController(controller);
const myGen = generation;
const superseded = () => myGen !== generation || controller.signal.aborted;
const superseded = () =>
myGen !== generation ||
controller.signal.aborted ||
(typeof opts.isCurrent === 'function' && !opts.isCurrent());
const results = [];
const ids = [];
@@ -847,6 +855,7 @@ export function createAnnotationEngine({
firstAnchor = annotations.get(ids[0]) || null;
}
} finally {
opts.signal?.removeEventListener?.('abort', abortFromCaller);
releaseController(controller);
}
@@ -1520,6 +1529,18 @@ export function createAnnotationEngine({
// Every part with its holes ([outer, ...holes][]), when the outline has
// more than the main ring (Hawaii's islands, Berlin inside Brandenburg).
polygons: resolved.polygons || null,
// A voice area handle's id: counting reads the handle's full geometry,
// never this drawing (see voice/areaActions.js).
areaId:
typeof spec?.areaId === 'string' ? spec.areaId.slice(0, 120) : null,
// Who made the outline: the draw tool, a voice area handle, or a voice
// annotation resolved from a name.
origin:
spec?.manual === true
? spec.origin === 'area'
? 'area'
: 'drawn'
: 'voice',
footprintKind: resolved.footprintKind || null, // 'building' | 'area'
outlineCredit: resolved.outlineCredit || null,
buildingHeight: resolved.buildingHeight || null, // meters, for extruded volume
@@ -1809,6 +1830,12 @@ export function createAnnotationEngine({
},
fadeOutAll,
count: () => annotations.size,
/**
* The board generation: advanced by every clear. A caller that resolves
* something before drawing captures it first and draws only while it is
* unchanged, so a clear during a slow lookup is never undone.
*/
generation: () => generation,
list: () => Array.from(annotations.values()),
/**
@@ -2200,13 +2227,40 @@ function isManualSpec(spec, type) {
if (!spec || spec.manual !== true) return false;
if (type === 'route')
return Array.isArray(spec.path) && spec.path.length >= 2;
if (type === 'area') return Array.isArray(spec.ring) && spec.ring.length >= 3;
if (type === 'area')
return (
(Array.isArray(spec.ring) && spec.ring.length >= 3) ||
(spec.origin === 'area' && Array.isArray(spec.polygons))
);
return (
Number.isFinite(Number(spec.latitude)) &&
Number.isFinite(Number(spec.longitude))
);
}
/**
* Parts of a voice area handle, `[[outer, ...holes], …]` of [lon, lat] pairs.
* Longitudes may run to ±540 (a part unwrapped across the antimeridian draws
* as one shape). Null when no part has a usable outer ring.
*/
function areaPolygons(list) {
const ringOf = (ring) =>
(Array.isArray(ring) ? ring : []).filter(
(p) =>
Array.isArray(p) &&
Number.isFinite(p[0]) &&
Number.isFinite(p[1]) &&
Math.abs(p[0]) <= 540 &&
Math.abs(p[1]) <= 90,
);
const polygons = (Array.isArray(list) ? list : [])
.slice(0, 64)
.map((rings) => (Array.isArray(rings) ? rings.map(ringOf) : []))
.filter((rings) => rings[0]?.length >= 3)
.map(([outer, ...holes]) => [outer, ...holes.filter((h) => h.length >= 3)]);
return polygons.length ? polygons : null;
}
/** [lon, lat] pairs or {lon, lat} objects → [lon, lat] pairs, invalid entries dropped. */
function manualPairs(list) {
return (Array.isArray(list) ? list : [])
@@ -2253,7 +2307,9 @@ function resolveManualSpec(spec, type, viewer) {
};
}
if (type === 'area') {
const ring = manualPairs(spec.ring);
const polygons =
spec.origin === 'area' ? areaPolygons(spec.polygons) : null;
const ring = polygons ? polygons[0][0] : manualPairs(spec.ring);
if (ring.length < 3)
throw new Error('a drawn area needs at least 3 points');
// Averaged through ringCentroid, which unwraps longitudes first: a ring
@@ -2270,9 +2326,11 @@ function resolveManualSpec(spec, type, viewer) {
lat,
height: sampleGroundHeight(viewer, lon, lat),
ring,
...(polygons ? { polygons } : {}),
footprintKind: 'area',
buildingHeight: null,
synthesized: false,
// An approximate area (a buffer around a landmark) draws dashed.
synthesized: spec.approximate === true,
source: 'manual',
};
}
+290 -80
View File
@@ -43,11 +43,21 @@ function scriptedResolver(outcomes) {
// Instant fake wait that records the requested backoff delays.
function fakeWait() {
const delays = [];
const waitFn = async (ms) => { delays.push(ms); };
const waitFn = async (ms) => {
delays.push(ms);
};
return { waitFn, delays };
}
const FP = { ring: [[0, 0], [0, 1], [1, 1], [0, 0]], footprintKind: 'area' };
const FP = {
ring: [
[0, 0],
[0, 1],
[1, 1],
[0, 0],
],
footprintKind: 'area',
};
function installAnimationFrameStubs(t) {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
@@ -55,9 +65,11 @@ function installAnimationFrameStubs(t) {
globalThis.requestAnimationFrame = () => 1;
globalThis.cancelAnimationFrame = () => {};
t.after(() => {
if (originalRequestAnimationFrame === undefined) delete globalThis.requestAnimationFrame;
if (originalRequestAnimationFrame === undefined)
delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRequestAnimationFrame;
if (originalCancelAnimationFrame === undefined) delete globalThis.cancelAnimationFrame;
if (originalCancelAnimationFrame === undefined)
delete globalThis.cancelAnimationFrame;
else globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
});
}
@@ -67,9 +79,15 @@ function fakeRenderer() {
return {
calls,
renderer: {
add() { calls.add += 1; },
update() { calls.update += 1; },
remove() { calls.remove += 1; },
add() {
calls.add += 1;
},
update() {
calls.update += 1;
},
remove() {
calls.remove += 1;
},
sync() {},
},
};
@@ -83,14 +101,19 @@ function httpFailure(status, retryAfter = null) {
return {
ok: false,
status,
headers: { get: (name) => (name.toLowerCase() === 'retry-after' ? retryAfter : null) },
headers: {
get: (name) => (name.toLowerCase() === 'retry-after' ? retryAfter : null),
},
};
}
test('retry: first-try footprint returns immediately, no retry, no waiting', async () => {
const { resolve, calls } = scriptedResolver([FP]);
const { waitFn, delays } = fakeWait();
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
const fp = await resolveOutlineWithRetry(resolve, {
delaysMs: [8000, 25000],
waitFn,
});
assert.equal(fp, FP);
assert.equal(calls.count, 1);
assert.deepEqual(delays, []);
@@ -99,7 +122,10 @@ test('retry: first-try footprint returns immediately, no retry, no waiting', asy
test('retry: a DEFINITIVE miss (null) is never retried — the honest point stands', async () => {
const { resolve, calls } = scriptedResolver([null, FP]);
const { waitFn, delays } = fakeWait();
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
const fp = await resolveOutlineWithRetry(resolve, {
delaysMs: [8000, 25000],
waitFn,
});
assert.equal(fp, null);
assert.equal(calls.count, 1);
assert.deepEqual(delays, []);
@@ -108,7 +134,10 @@ test('retry: a DEFINITIVE miss (null) is never retried — the honest point stan
test('retry: a TRANSIENT miss (undefined) re-runs after the first backoff and can succeed', async () => {
const { resolve, calls } = scriptedResolver([undefined, FP]);
const { waitFn, delays } = fakeWait();
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
const fp = await resolveOutlineWithRetry(resolve, {
delaysMs: [8000, 25000],
waitFn,
});
assert.equal(fp, FP);
assert.equal(calls.count, 2);
assert.deepEqual(delays, [8000]); // waited once, succeeded on attempt 2
@@ -117,16 +146,27 @@ test('retry: a TRANSIENT miss (undefined) re-runs after the first backoff and ca
test('retry: a transient retry can still conclude with a definitive miss (no third run)', async () => {
const { resolve, calls } = scriptedResolver([undefined, null]);
const { waitFn, delays } = fakeWait();
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
const fp = await resolveOutlineWithRetry(resolve, {
delaysMs: [8000, 25000],
waitFn,
});
assert.equal(fp, null);
assert.equal(calls.count, 2);
assert.deepEqual(delays, [8000]);
});
test('retry: persistent transients exhaust the backoff schedule and give up as transient', async () => {
const { resolve, calls } = scriptedResolver([undefined, undefined, undefined, FP]);
const { resolve, calls } = scriptedResolver([
undefined,
undefined,
undefined,
FP,
]);
const { waitFn, delays } = fakeWait();
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
const fp = await resolveOutlineWithRetry(resolve, {
delaysMs: [8000, 25000],
waitFn,
});
assert.equal(fp, undefined); // never found within budget — mark honestly stays a point
assert.equal(calls.count, 3); // initial + one per backoff entry
assert.deepEqual(delays, [8000, 25000]);
@@ -138,7 +178,10 @@ test('retry: a stale board (clear/supersede during backoff) stops retrying immed
let stale = false;
const fp = await resolveOutlineWithRetry(resolve, {
delaysMs: [8000, 25000],
waitFn: async (ms) => { await waitFn(ms); stale = true; }, // goes stale mid-wait
waitFn: async (ms) => {
await waitFn(ms);
stale = true;
}, // goes stale mid-wait
isStale: () => stale,
});
assert.equal(fp, undefined);
@@ -149,7 +192,10 @@ test('retry: a stale board (clear/supersede during backoff) stops retrying immed
test('retry: a thrown resolver is a definitive miss (no retry), matching the old catch→point path', async () => {
const { resolve, calls } = scriptedResolver([new Error('boom'), FP]);
const { waitFn, delays } = fakeWait();
const fp = await resolveOutlineWithRetry(resolve, { delaysMs: [8000, 25000], waitFn });
const fp = await resolveOutlineWithRetry(resolve, {
delaysMs: [8000, 25000],
waitFn,
});
assert.equal(fp, null);
assert.equal(calls.count, 1);
assert.deepEqual(delays, []);
@@ -171,43 +217,65 @@ test('outline queue: an 8-spec batch keeps at most two FIFO upgrades in flight',
label: target,
source: 'fake',
ring: null,
resolveOutline: () => new Promise((resolve) => {
started.push(target);
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
releases.set(target, () => {
inFlight -= 1;
resolve({
ring: [[index, index], [index + 0.1, index], [index, index + 0.1], [index, index]],
footprintKind: 'area',
lat: index,
lon: index,
height: 0,
resolveOutline: () =>
new Promise((resolve) => {
started.push(target);
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
releases.set(target, () => {
inFlight -= 1;
resolve({
ring: [
[index, index],
[index + 0.1, index],
[index, index + 0.1],
[index, index],
],
footprintKind: 'area',
lat: index,
lon: index,
height: 0,
});
});
});
}),
}),
};
};
const engine = createAnnotationEngine({ viewer: {}, renderer, resolveTarget });
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget,
});
let completed = 0;
const allCompleted = new Promise((resolve) => engine.onOutlineEvent(() => {
completed += 1;
if (completed === 8) resolve();
}));
const allCompleted = new Promise((resolve) =>
engine.onOutlineEvent(() => {
completed += 1;
if (completed === 8) resolve();
}),
);
const result = await engine.annotate(Array.from({ length: 8 }, (_, i) => ({
type: 'area',
target: `queue-${i}`,
footprint: true,
})));
const result = await engine.annotate(
Array.from({ length: 8 }, (_, i) => ({
type: 'area',
target: `queue-${i}`,
footprint: true,
})),
);
assert.equal(result.drawn, 8);
assert.equal(calls.add, 8, 'all point renders land before queued outlines finish');
assert.equal(
calls.add,
8,
'all point renders land before queued outlines finish',
);
assert.deepEqual(started, ['queue-0', 'queue-1']);
for (let next = 2; next < 8; next += 1) {
releases.get(`queue-${next - 2}`)();
await flushMicrotasks();
assert.equal(started[next], `queue-${next}`, 'queued upgrades start in ask order');
assert.equal(
started[next],
`queue-${next}`,
'queued upgrades start in ask order',
);
}
releases.get('queue-6')();
releases.get('queue-7')();
@@ -292,7 +360,10 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
const originalWindow = globalThis.window;
const originalFetch = globalThis.fetch;
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 10_000 });
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
globalThis.window = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
};
const requestTimes = [];
globalThis.fetch = async (url) => {
// The one-time capability probe is not an outline request.
@@ -323,11 +394,16 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
waits.push(ms);
return new Promise((resolve) => setTimeout(resolve, ms));
},
})
.finally(() => { settled = true; });
}).finally(() => {
settled = true;
});
while (waits.length === 0) await flushMicrotasks();
assert.equal(requestTimes.length, 1);
assert.deepEqual(waits, [8000], 'the 8s ladder floor is longer than Retry-After: 5');
assert.deepEqual(
waits,
[8000],
'the 8s ladder floor is longer than Retry-After: 5',
);
t.mock.timers.tick(7999);
await flushMicrotasks();
@@ -342,8 +418,15 @@ test('retry: HTTP 429 Retry-After 5s gets one ladder-spaced retry; a second 429
}
assert.equal(requestTimes.length, 2);
assert.ok(requestTimes[1] - requestTimes[0] >= 5000, 'Retry-After is a hard minimum');
assert.equal(settled, true, 'a second 429 must not schedule the 25s ladder replay');
assert.ok(
requestTimes[1] - requestTimes[0] >= 5000,
'Retry-After is a hard minimum',
);
assert.equal(
settled,
true,
'a second 429 must not schedule the 25s ladder replay',
);
assert.equal(await pending, undefined);
});
@@ -351,7 +434,10 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
const originalWindow = globalThis.window;
const originalFetch = globalThis.fetch;
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
globalThis.window = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
};
const requestTimes = [];
globalThis.fetch = async (url) => {
if (String(url).endsWith('/status'))
@@ -382,8 +468,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
waits.push(ms);
return new Promise((resolve) => setTimeout(resolve, ms));
},
})
.finally(() => { settled = true; });
}).finally(() => {
settled = true;
});
while (waits.length === 0) await flushMicrotasks();
assert.equal(requestTimes.length, 1);
assert.deepEqual(waits, [8000]);
@@ -400,7 +487,10 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
await flushMicrotasks();
assert.equal(requestTimes.length, 3);
assert.deepEqual(requestTimes.map((at) => at - requestTimes[0]), [0, 8000, 33_000]);
assert.deepEqual(
requestTimes.map((at) => at - requestTimes[0]),
[0, 8000, 33_000],
);
assert.equal(settled, true);
assert.equal(await pending, undefined);
});
@@ -415,7 +505,10 @@ test('targetKey: trailing locality qualifiers are stripped so state names dedupe
});
test('targetKey: lowercases, trims, and keeps comma-free names intact', () => {
assert.equal(normalizeTargetKey(' Texas State Capitol '), 'texas state capitol');
assert.equal(
normalizeTargetKey(' Texas State Capitol '),
'texas state capitol',
);
assert.equal(normalizeTargetKey('Lady Bird Lake'), 'lady bird lake');
});
@@ -1031,7 +1124,9 @@ test('duplicate-replacement: a renderer throw leaves no phantom mark and no leak
resolveTarget: async () => ({ ...FIXED_POINT }),
});
const first = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
const first = await engine.annotate([
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
]);
assert.equal(first.drawn, 1);
assert.equal(engine.list().length, 1);
assert.ok(
@@ -1046,12 +1141,25 @@ test('duplicate-replacement: a renderer throw leaves no phantom mark and no leak
// branch for a point; see findDuplicate.)
failAddOnce();
const second = await engine.annotate([
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
{
type: 'point',
target: 'Texas Capitol',
label: 'Capitol',
color: 'amber',
},
]);
assert.equal(second.drawn, 0, 'the failed swap is reported as a failure, not a draw');
assert.equal(
second.drawn,
0,
'the failed swap is reported as a failure, not a draw',
);
assert.equal(second.failed, 1);
assert.equal(engine.list().length, 0, 'no phantom annotation survives the failed swap');
assert.equal(
engine.list().length,
0,
'no phantom annotation survives the failed swap',
);
assert.ok(
!getRenderGovernorDiagnostics().holds.includes('annotations'),
'the annotations hold must be released — a leak defeats the idle governor permanently',
@@ -1071,10 +1179,16 @@ test('fresh path: a renderer throw is rolled back the same way', async (t) => {
});
failAddOnce();
const result = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
const result = await engine.annotate([
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
]);
assert.equal(result.drawn, 0);
assert.equal(engine.list().length, 0, 'no phantom annotation from a failed first add');
assert.equal(
engine.list().length,
0,
'no phantom annotation from a failed first add',
);
assert.ok(
!getRenderGovernorDiagnostics().holds.includes('annotations'),
'a never-rendered mark must not hold continuous render',
@@ -1096,14 +1210,21 @@ function partialStateRendererHarness() {
let failNextAdd = false;
return {
live,
failAddOnce() { failNextAdd = true; },
failAddOnce() {
failNextAdd = true;
},
renderer: {
add(anno) {
live.set(anno.id, anno); // partial renderer state exists NOW
if (failNextAdd) { failNextAdd = false; throw new Error('WebGL context lost'); }
if (failNextAdd) {
failNextAdd = false;
throw new Error('WebGL context lost');
}
},
update() {},
remove(anno) { live.delete(anno.id); },
remove(anno) {
live.delete(anno.id);
},
sync() {},
},
};
@@ -1122,10 +1243,16 @@ test('fresh path: a throw AFTER partial renderer state leaves nothing on the boa
});
failAddOnce();
const failed = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
const failed = await engine.annotate([
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
]);
assert.equal(failed.drawn, 0);
assert.equal(engine.list().length, 0, 'no phantom annotation');
assert.equal(live.size, 0, 'the rollback must release what the renderer had already created');
assert.equal(
live.size,
0,
'the rollback must release what the renderer had already created',
);
assert.ok(
!getRenderGovernorDiagnostics().holds.includes('annotations'),
'a never-rendered mark must not hold continuous render',
@@ -1133,9 +1260,15 @@ test('fresh path: a throw AFTER partial renderer state leaves nothing on the boa
// The real cost of an orphan: re-annotating the same place stacks a second
// mark on top of the one nothing owns.
const retry = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
const retry = await engine.annotate([
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
]);
assert.equal(retry.drawn, 1);
assert.equal(live.size, 1, 'a re-annotate must draw ONE mark, not stack over an orphan');
assert.equal(
live.size,
1,
'a re-annotate must draw ONE mark, not stack over an orphan',
);
});
test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing on the board', async (t) => {
@@ -1150,7 +1283,9 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
resolveTarget: async () => ({ ...FIXED_POINT }),
});
const first = await engine.annotate([{ type: 'point', target: 'Texas Capitol', label: 'Capitol' }]);
const first = await engine.annotate([
{ type: 'point', target: 'Texas Capitol', label: 'Capitol' },
]);
assert.equal(first.drawn, 1);
assert.equal(live.size, 1);
@@ -1158,10 +1293,19 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
// creates its state and then throws.
failAddOnce();
const swap = await engine.annotate([
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
{
type: 'point',
target: 'Texas Capitol',
label: 'Capitol',
color: 'amber',
},
]);
assert.equal(swap.drawn, 0);
assert.equal(engine.list().length, 0, 'no phantom annotation survives the failed swap');
assert.equal(
engine.list().length,
0,
'no phantom annotation survives the failed swap',
);
assert.equal(live.size, 0, 'and no half-swapped mark survives on the board');
assert.ok(
!getRenderGovernorDiagnostics().holds.includes('annotations'),
@@ -1169,23 +1313,38 @@ test('duplicate-replacement: a throw AFTER partial renderer state leaves nothing
);
const retry = await engine.annotate([
{ type: 'point', target: 'Texas Capitol', label: 'Capitol', color: 'amber' },
{
type: 'point',
target: 'Texas Capitol',
label: 'Capitol',
color: 'amber',
},
]);
assert.equal(retry.drawn, 1);
assert.equal(live.size, 1, 'the recoloured mark redraws once, with no orphan underneath');
assert.equal(
live.size,
1,
'the recoloured mark redraws once, with no orphan underneath',
);
});
test('destroy releases the renderer and prevents subsequent annotation work', async (t) => {
installAnimationFrameStubs(t);
const { renderer, calls } = fakeRenderer();
let destroyed = 0;
renderer.destroy = () => { destroyed++; };
renderer.destroy = () => {
destroyed++;
};
const engine = createAnnotationEngine({ viewer: {}, renderer });
const drawn = await engine.annotate([{ type: 'pin', longitude: -97.74, latitude: 30.27 }]);
const drawn = await engine.annotate([
{ type: 'pin', longitude: -97.74, latitude: 30.27 },
]);
assert.equal(drawn.drawn, 1);
engine.destroy();
const additions = calls.add;
const result = await engine.annotate([{ type: 'pin', target: 'Must not resolve' }]);
const result = await engine.annotate([
{ type: 'pin', target: 'Must not resolve' },
]);
assert.equal(result.error, 'destroyed');
assert.equal(calls.add, additions);
assert.equal(engine.count(), 0);
@@ -1201,10 +1360,13 @@ test('a late annotation resolver cannot redraw after destruction', async (t) =>
let release;
let signal;
const engine = createAnnotationEngine({
viewer: {}, renderer,
viewer: {},
renderer,
resolveTarget: (options) => {
signal = options.signal;
return new Promise((resolve) => { release = resolve; });
return new Promise((resolve) => {
release = resolve;
});
},
});
const pending = engine.annotate([{ type: 'pin', target: 'Pending place' }]);
@@ -1216,6 +1378,44 @@ test('a late annotation resolver cannot redraw after destruction', async (t) =>
assert.equal(engine.count(), 0);
});
test('caller cancellation prevents a late annotation and camera mutation', async (t) => {
installAnimationFrameStubs(t);
const { renderer, calls } = fakeRenderer();
let release;
let resolverSignal;
let flights = 0;
const engine = createAnnotationEngine({
viewer: {
camera: {
flyToBoundingSphere() {
flights += 1;
},
},
},
renderer,
resolveTarget: ({ signal }) => {
resolverSignal = signal;
return new Promise((resolve) => {
release = resolve;
});
},
});
const controller = new AbortController();
const pending = engine.annotate([{ type: 'pin', target: 'Pending place' }], {
flyTo: true,
signal: controller.signal,
isCurrent: () => !controller.signal.aborted,
});
controller.abort();
assert.equal(resolverSignal.aborted, true);
release({ lon: -97.74, lat: 30.27, label: 'Pending place' });
const result = await pending;
assert.equal(result.aborted, true);
assert.equal(calls.add, 0);
assert.equal(flights, 0);
assert.equal(engine.count(), 0);
});
test('only an explicit navigation request permits resolving distant annotation targets', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
@@ -1223,12 +1423,22 @@ test('only an explicit navigation request permits resolving distant annotation t
const { renderer } = throwingRendererHarness();
renderer.destroy = () => {};
const received = [];
const engine = createAnnotationEngine({ viewer: {}, renderer,
resolveTarget: async (options) => { received.push(options); return null; },
const engine = createAnnotationEngine({
viewer: {},
renderer,
resolveTarget: async (options) => {
received.push(options);
return null;
},
});
await engine.annotate([{ type: 'point', target: 'Remote landmark' }]);
await engine.annotate([{ type: 'point', target: 'Remote landmark' }], { flyTo: true });
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
await engine.annotate([{ type: 'point', target: 'Remote landmark' }], {
flyTo: true,
});
assert.deepEqual(
received.map((options) => options.allowDistant),
[false, true],
);
engine.destroy();
});
+144 -1
View File
@@ -43,24 +43,167 @@ export function drawMarkFallbackName(mark) {
: shape;
}
/** The kind of shape a row action is offered for: `area` or `line`. */
const ACTION_SHAPES = Object.freeze({ area: 'area', route: 'line' });
const lonLat = (point) =>
Array.isArray(point)
? [Number(point[0]), Number(point[1])]
: [Number(point?.lon), Number(point?.lat)];
const finitePairs = (points) =>
(Array.isArray(points) ? points : [])
.map(lonLat)
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
/** A longitude in [-180, 180]; an unwrapped outline can carry ±540. */
const wrapLon = (lon) =>
lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180;
/** Whether consecutive positions step across the antimeridian once wrapped. */
const crossesAntimeridian = (points) =>
points.some(
(point, index) =>
index > 0 && Math.abs(point[0] - points[index - 1][0]) > 180,
);
/** Positions wrapped into range, or null when the sequence crosses ±180°. */
const inRange = (points) => {
if (points.some(([, lat]) => Math.abs(lat) > 90)) return null;
const wrapped = points.map(([lon, lat]) => [wrapLon(lon), lat]);
return crossesAntimeridian(wrapped) ? null : wrapped;
};
const closed = (ring) => {
if (ring.length < 3) return ring;
const [first, last] = [ring[0], ring[ring.length - 1]];
return first[0] === last[0] && first[1] === last[1] ? ring : [...ring, first];
};
/**
* A drawn shape's geometry as GeoJSON in `[lon, lat]` degrees, longitudes in
* [-180, 180]: an area is a closed `Polygon` (a `MultiPolygon` when an area
* handle has several parts), a line a `LineString`. A pin, a shape too small
* to be one, or one that crosses the antimeridian has none, so it is offered
* no action rather than a shape that wraps the wrong way round the world.
*/
export function drawMarkGeometry(mark) {
if (mark?.type === 'route') {
const path = inRange(finitePairs(mark.path));
return path?.length >= 2 ? { type: 'LineString', coordinates: path } : null;
}
if (mark?.type !== 'area') return null;
const ringOf = (points) => {
const ring = inRange(closed(finitePairs(points)));
return ring?.length >= 4 ? ring : null;
};
if (Array.isArray(mark.polygons) && mark.polygons.length) {
const polygons = [];
for (const rings of mark.polygons) {
const parts = (Array.isArray(rings) ? rings : []).map(ringOf);
// A part that cannot be given whole is no part: never a hole dropped.
if (!parts.length || parts.some((ring) => !ring)) return null;
polygons.push(parts);
}
return polygons.length > 1
? { type: 'MultiPolygon', coordinates: polygons }
: { type: 'Polygon', coordinates: polygons[0] };
}
const ring = ringOf(mark.ring);
return ring ? { type: 'Polygon', coordinates: [ring] } : null;
}
/**
* The actions an application adds to Draw's rows, checked once: each has an
* `id`, a Material Symbols `icon`, a `label(name)` worded by the application,
* the `shapes` it is for (`area`, `line`), an optional `accepts(geometry)` and
* `run({ id, shape, name, title, geometry })` (`name` the person's own, `title`
* as the row shows it). Malformed or repeated ids are dropped.
*/
export function drawMarkActions(actions = []) {
const kept = new Map();
for (const action of Array.isArray(actions) ? actions : []) {
const wellFormed =
typeof action?.id === 'string' &&
/^[a-z][a-z0-9-]{0,39}$/.test(action.id) &&
typeof action.icon === 'string' &&
typeof action.label === 'function' &&
typeof action.run === 'function' &&
Array.isArray(action.shapes);
if (!wellFormed || kept.has(action.id)) continue;
kept.set(
action.id,
Object.freeze({
id: action.id,
icon: action.icon,
label: action.label,
shapes: Object.freeze(
action.shapes.filter((shape) =>
Object.values(ACTION_SHAPES).includes(shape),
),
),
accepts:
typeof action.accepts === 'function' ? action.accepts : () => true,
run: action.run,
}),
);
}
return Object.freeze([...kept.values()]);
}
/** The actions from `drawMarkActions` that `mark` is offered, with its geometry. */
export function drawMarkActionsFor(mark, actions = []) {
const shape = ACTION_SHAPES[mark?.type];
if (!shape || !actions.length) return { shape, geometry: null, actions: [] };
const geometry = drawMarkGeometry(mark);
if (!geometry) return { shape, geometry, actions: [] };
return {
shape,
geometry,
actions: actions.filter((action) => {
if (!action.shapes.includes(shape)) return false;
try {
return action.accepts(geometry, shape) === true;
} catch {
return false;
}
}),
};
}
/** An action's words for a row; an application's label that throws shows its id. */
function actionLabel(action, title) {
try {
const text = action.label(title);
return typeof text === 'string' && text.trim() ? text.trim() : action.id;
} catch {
return action.id;
}
}
/**
* The rows for the hand-drawn marks on the board, in board order. Text is
* worded here, in the current language; the view only lays it out.
* @param {object[]} marks The engine's `list()`.
* @param {ReturnType<typeof drawMarkActions>} [actions] The application's row actions.
* @param {Map<string, {busy: boolean, text: string}>} [notes] What an action said on a shape.
*/
export function presentDrawMarks(marks = []) {
export function presentDrawMarks(marks = [], actions = [], notes = new Map()) {
return marks
.filter((mark) => mark?.manual === true)
.map((mark) => {
const fallback = drawMarkFallbackName(mark);
const title = mark.name || fallback;
const visible = !mark.hidden;
const offered = drawMarkActionsFor(mark, actions).actions;
return {
id: mark.id,
name: mark.name || '',
placeholder: fallback,
color: DRAW_COLORS.includes(mark.color) ? mark.color : 'primary',
hidden: !visible,
busy: notes.get(mark.id)?.busy === true,
note: offered.length ? notes.get(mark.id)?.text || '' : '',
actions: offered.map((action) => ({
id: action.id,
icon: action.icon,
label: actionLabel(action, title),
})),
labels: {
name: t('Name of {name}', { name: title }),
swatch: t('Change the colour of {name}', { name: title }),
+219
View File
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkActions,
drawMarkActionsFor,
drawMarkFallbackName,
drawMarkGeometry,
presentDrawMarks,
} from './drawMarks.js';
@@ -78,3 +81,219 @@ test('rows list only hand-drawn marks, in board order, with their words', () =>
test('every colour has its swatch', () => {
assert.deepEqual(Object.keys(DRAW_COLOR_CSS), [...DRAW_COLORS]);
});
test("a drawn shape's geometry is GeoJSON in [lon, lat]: a closed area, a line, no pin", () => {
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
}),
{
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
],
},
);
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
],
}).coordinates[0].at(-1),
[0, 0],
'an open ring is closed',
);
assert.equal(
drawMarkGeometry({
type: 'area',
ring: [
[0, 0],
[1, 0],
],
}),
null,
);
assert.deepEqual(
drawMarkGeometry({
type: 'route',
path: [
{ lon: 1, lat: 2, height: 9 },
{ lon: 3, lat: 4, height: 0 },
],
}),
{
type: 'LineString',
coordinates: [
[1, 2],
[3, 4],
],
},
);
assert.equal(
drawMarkGeometry({ type: 'pin', longitude: 1, latitude: 2 }),
null,
);
const square = (x) => [
[x, 0],
[x + 1, 0],
[x + 1, 1],
[x, 0],
];
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: square(0),
polygons: [[square(0)], [square(5)]],
}),
{ type: 'MultiPolygon', coordinates: [[square(0)], [square(5)]] },
'an area handle with several parts keeps them all',
);
assert.equal(
drawMarkGeometry({
type: 'area',
polygons: [
[
square(0),
[
[0.2, 0.2],
[0.3, 0.2],
],
],
],
}),
null,
'a hole that cannot be given whole is never dropped silently',
);
});
test('longitudes are wrapped into range, and a shape across the antimeridian has no geometry', () => {
assert.deepEqual(
drawMarkGeometry({
type: 'route',
path: [
[370, 1],
[371, 1],
],
}).coordinates,
[
[10, 1],
[11, 1],
],
);
assert.deepEqual(
drawMarkGeometry({
type: 'area',
ring: [
[180, 0],
[179, 0],
[179, 1],
],
}).coordinates[0][0],
[180, 0],
'the antimeridian itself stays put',
);
assert.equal(
drawMarkGeometry({
type: 'area',
ring: [
[179.9, 0],
[-179.9, 0],
[-179.9, 1],
],
}),
null,
);
assert.equal(
drawMarkGeometry({
type: 'route',
path: [
[179.5, 0],
[180.5, 0],
],
}),
null,
);
assert.equal(
drawMarkGeometry({
type: 'route',
path: [
[0, 91],
[1, 0],
],
}),
null,
);
});
test('row actions are checked once: malformed ones and repeated ids are dropped', () => {
const run = () => {};
const label = () => 'x';
const actions = drawMarkActions([
{ id: 'Bad Id', icon: 'a', label, run, shapes: ['area'] },
{ id: 'tag', icon: 'a', label: 'not a function', run, shapes: ['area'] },
{
id: 'tag',
icon: 'sell',
label,
run,
shapes: ['area', 'line', 'pin'],
},
{ id: 'tag', icon: 'other', label, run, shapes: ['area'] },
null,
]);
assert.deepEqual(
actions.map(({ id, icon, shapes }) => ({ id, icon, shapes: [...shapes] })),
[{ id: 'tag', icon: 'sell', shapes: ['area', 'line'] }],
);
assert.deepEqual(drawMarkActions('nope'), []);
const area = {
type: 'area',
ring: [
[0, 0],
[1, 0],
[1, 1],
],
};
const offer = drawMarkActionsFor(area, actions);
assert.equal(offer.shape, 'area');
assert.deepEqual(
offer.actions.map((a) => a.id),
['tag'],
);
const refusing = drawMarkActions([
{
id: 'measure',
icon: 'a',
label,
run,
shapes: ['area'],
accepts: () => {
throw new Error('no');
},
},
]);
assert.deepEqual(
drawMarkActionsFor(area, refusing).actions,
[],
'an accepts that throws offers nothing',
);
assert.deepEqual(
presentDrawMarks([{ id: 'p', type: 'pin', manual: true }], actions)[0]
.actions,
[],
);
});
+90 -5
View File
@@ -50,9 +50,28 @@ import {
removeLastVertex,
} from './drawMode.js';
import { DRAW_COLORS, DRAW_COLOR_CSS, presentDrawMarks } from './drawMarks.js';
import {
DRAW_COLORS,
DRAW_COLOR_CSS,
drawMarkActions,
drawMarkActionsFor,
drawMarkFallbackName,
presentDrawMarks,
} from './drawMarks.js';
import { onLocaleChange, t, translateKnown } from '../i18n/index.js';
/** The longest note a row action leaves under its row, in characters. */
const NOTE_MAX = 200;
/** An action's answer as a row note: trimmed, and cut at a word with an ellipsis. */
function noteText(text) {
if (typeof text !== 'string') return '';
const trimmed = text.trim();
if (trimmed.length <= NOTE_MAX) return trimmed;
const cut = trimmed.slice(0, NOTE_MAX - 1);
const space = cut.lastIndexOf(' ');
return `${(space > NOTE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
}
/** The id this tool claims the pointer under. */
const DRAW_POINTER_OWNER = 'draw';
const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview';
@@ -67,7 +86,10 @@ const PREVIEW = DRAW_COLOR_CSS;
* through the actions it gives `connect`, which returns their disconnect.
* A row's fly-to is an immediate navigation through `navigation`, like
* Import's, so Cockpit can refuse it and a running orbit or follow lets go.
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function}} deps
* `markActions` are the application's own buttons on an area's or a line's
* row (drawMarks.js `drawMarkActions`); a press runs the action with the
* shape's id, kind, name and geometry (drawMarks.js `drawMarkGeometry`).
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, navigation?: object, publish?: Function, connect?: Function, markActions?: object[]}} deps
* @returns {{destroy: Function}|null}
*/
export function initDrawTool({
@@ -76,7 +98,12 @@ export function initDrawTool({
navigation = null,
publish = () => {},
connect = () => () => {},
markActions = [],
}) {
const rowActions = drawMarkActions(markActions);
// What a row action said last on each drawn shape: `{ run, busy, text }`.
const notes = new Map();
let noteRun = 0;
// A page without the Draw controls (the Display controls view omitted) has
// no draw tool.
if (!viewer || !annotations || !document.getElementById('draw-toggle'))
@@ -115,14 +142,19 @@ export function initDrawTool({
() => null,
);
const present = () =>
const present = () => {
const marks = annotations.list?.() || [];
// A note goes with its shape.
for (const id of notes.keys())
if (!marks.some((mark) => mark.id === id)) notes.delete(id);
publish({
active,
shape,
color,
hint,
marks: presentDrawMarks(annotations.list?.() || []),
marks: presentDrawMarks(marks, rowActions, notes),
});
};
// The hint is kept as the call that words it, so a language switch can
// word it again.
let hintText = () => '';
@@ -450,6 +482,56 @@ export function initDrawTool({
return annotations.frame?.(id) ?? false;
}
/**
* Run the application's row action `actionId` on drawn shape `id`, if the
* shape is still on the board and still offered it. Returns whether it ran.
* What the action returns, or resolves to, is the row's note (a measurement,
* "saved"): the row is busy until it settles, a newer press or the shape's
* removal makes a late answer moot, and a failure clears the note, so an
* action words its own refusals and resolves with them.
*/
function runMarkAction(id, actionId) {
const mark = (annotations.list?.() || []).find(
(entry) => entry.id === id && entry.manual === true,
);
const offer = drawMarkActionsFor(mark, rowActions);
const action = offer.actions.find((entry) => entry.id === actionId);
if (!action || notes.get(mark.id)?.busy) return false;
const run = ++noteRun;
const settle = (text) => {
if (destroyed || notes.get(mark.id)?.run !== run) return;
notes.set(mark.id, {
run,
busy: false,
text: noteText(text),
});
present();
};
const fail = (error) => {
console.warn(`[draw] ${actionId} failed`, error);
settle('');
};
notes.set(mark.id, { run, busy: true, text: '' });
let answer;
try {
answer = action.run({
id: mark.id,
shape: offer.shape,
name: mark.name || '',
title: mark.name || drawMarkFallbackName(mark),
geometry: offer.geometry,
});
} catch (error) {
fail(error);
return false;
}
if (typeof answer?.then === 'function') {
present();
Promise.resolve(answer).then(settle, fail);
} else settle(answer);
return true;
}
/** Draw `next` from now on; an open shape starts over in it. */
function chooseShape(next) {
shape = normalizeShape(next);
@@ -478,6 +560,7 @@ export function initDrawTool({
},
flyToDrawMark: flyToMark,
removeDrawMark: (id) => annotations.remove?.(id),
runDrawMarkAction: runMarkAction,
});
// The rows follow the board: a shape drawn, renamed, hidden or removed
// here, by voice or by a clear.
@@ -500,13 +583,15 @@ export function initDrawTool({
setActive,
setShape: chooseShape,
/** The hand-drawn marks the list shows, as its rows. */
marks: () => presentDrawMarks(annotations.list?.() || []),
marks: () =>
presentDrawMarks(annotations.list?.() || [], rowActions, notes),
setColor(next) {
color = DRAW_COLORS.includes(next) ? next : 'primary';
present();
syncPreview();
},
flyToMark,
runMarkAction,
/** Test seam: add a vertex from lon/lat as if it had been clicked. */
addVertex(lon, lat, height = 0) {
if (!session) return false;
+129 -1
View File
@@ -223,7 +223,7 @@ function fakeAnnotations() {
}
/** Stand up a tool against fresh doubles, with globals restored afterwards. */
function harness({ navigation = null } = {}) {
function harness({ navigation = null, markActions = [] } = {}) {
resetPointerOwnership();
const { byId, documentDouble } = fakeDom();
const viewer = fakeViewer();
@@ -239,6 +239,7 @@ function harness({ navigation = null } = {}) {
viewer,
annotations,
navigation,
markActions,
publish: (view) => (shown.view = view),
connect: (actions) => {
controls.actions = actions;
@@ -576,6 +577,133 @@ test('each drawn shape is a row whose actions reach the board, and fly-to is a n
}
});
test('an application\'s row actions show on the shapes they are for and run with the shape', async () => {
const ran = [];
const h = harness({
markActions: [
{
id: 'tag',
icon: 'sell',
label: (name) => `Tag ${name}`,
shapes: ['area', 'line'],
run: (shape) => ran.push(['tag', shape]),
},
{
id: 'measure',
icon: 'straighten',
label: (name) => `Measure ${name}`,
shapes: ['area'],
accepts: (geometry) => geometry.type === 'Polygon',
run: (shape) => ran.push(['measure', shape]),
},
],
});
try {
const board = h.annotations;
board.marks.push(
{ id: 'r1', type: 'area', manual: true, name: 'Strait', color: 'primary', hidden: false, ring: [[29, 41], [29.1, 41], [29.1, 41.1]] },
{ id: 'l1', type: 'route', manual: true, name: null, label: '9 km', color: 'amber', hidden: false, path: [{ lon: 29, lat: 41.05, height: 0 }, { lon: 29.1, lat: 41.05, height: 0 }] },
{ id: 'p1', type: 'pin', manual: true, name: 'Camp', color: 'red', hidden: false },
);
board.changed();
assert.deepEqual(
h.shown.view.marks.map(({ id, actions }) => [id, actions]),
[
['r1', [
{ id: 'tag', icon: 'sell', label: 'Tag Strait' },
{ id: 'measure', icon: 'straighten', label: 'Measure Strait' },
]],
['l1', [{ id: 'tag', icon: 'sell', label: 'Tag Line · 9 km' }]],
['p1', []],
],
);
assert.equal(h.act('runDrawMarkAction', 'l1', 'tag'), true);
assert.equal(h.act('runDrawMarkAction', 'l1', 'measure'), false, 'a line is not offered an area action');
assert.equal(h.act('runDrawMarkAction', 'p1', 'tag'), false);
assert.equal(h.act('runDrawMarkAction', 'gone', 'tag'), false);
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
assert.deepEqual(ran, [
['tag', { id: 'l1', shape: 'line', name: '', title: 'Line · 9 km', geometry: { type: 'LineString', coordinates: [[29, 41.05], [29.1, 41.05]] } }],
['measure', { id: 'r1', shape: 'area', name: 'Strait', title: 'Strait', geometry: { type: 'Polygon', coordinates: [[[29, 41], [29.1, 41], [29.1, 41.1], [29, 41]]] } }],
]);
} finally {
h.restore();
}
});
test('an action that throws is reported and leaves the row alone', async () => {
const h = harness({
markActions: [
{ id: 'boom', icon: 'bolt', label: () => { throw new Error('label'); }, shapes: ['area'], run: () => { throw new Error('run'); } },
],
});
const warn = console.warn;
const warned = [];
console.warn = (...args) => warned.push(args[0]);
try {
h.annotations.marks.push({ id: 'r1', type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
h.annotations.changed();
assert.deepEqual(h.shown.view.marks[0].actions, [{ id: 'boom', icon: 'bolt', label: 'boom' }]);
assert.equal(h.act('runDrawMarkAction', 'r1', 'boom'), false);
assert.deepEqual(warned, ['[draw] boom failed']);
} finally {
console.warn = warn;
h.restore();
}
});
test('what a row action answers is its row\'s note; the row is busy until then, and a removed shape drops it', async () => {
const pending = [];
const h = harness({
markActions: [
{
id: 'measure',
icon: 'straighten',
label: (name) => `Measure ${name}`,
shapes: ['area'],
run: () => new Promise((resolve, reject) => pending.push({ resolve, reject })),
},
{ id: 'now', icon: 'bolt', label: () => 'Now', shapes: ['area'], run: () => ' 12 km² ' },
{ id: 'long', icon: 'notes', label: () => 'Long', shapes: ['area'], run: () => `${'word '.repeat(60)}end` },
],
});
const warn = console.warn;
console.warn = () => {};
try {
const board = h.annotations;
const area = (id) => ({ id, type: 'area', manual: true, name: null, color: 'primary', hidden: false, ring: [[0, 0], [1, 0], [1, 1]] });
board.marks.push(area('r1'), area('r2'));
board.changed();
const row = (id) => h.shown.view.marks.find((entry) => entry.id === id);
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), true);
assert.deepEqual([row('r1').busy, row('r1').note], [false, '12 km²']);
h.act('runDrawMarkAction', 'r1', 'long');
assert.ok(row('r1').note.length <= 200 && row('r1').note.endsWith('word…'), 'a long note is cut at a word');
assert.equal(h.act('runDrawMarkAction', 'r1', 'measure'), true);
assert.deepEqual([row('r1').busy, row('r1').note], [true, '']);
assert.equal(h.act('runDrawMarkAction', 'r1', 'now'), false, 'a busy row takes no second press');
pending[0].resolve('412 km², 38 km of edge');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual([row('r1').busy, row('r1').note], [false, '412 km², 38 km of edge']);
h.act('runDrawMarkAction', 'r1', 'measure');
pending[1].reject(new Error('refused'));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual([row('r1').busy, row('r1').note], [false, ''], 'a failure clears the note');
h.act('runDrawMarkAction', 'r2', 'measure');
board.marks.splice(1, 1);
board.changed();
pending[2].resolve('late');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(row('r2'), undefined);
board.marks.push(area('r2'));
board.changed();
assert.deepEqual([row('r2').busy, row('r2').note], [false, ''], 'a late answer never reaches a shape that went');
} finally {
console.warn = warn;
h.restore();
}
});
test('Backspace on the page removes the last vertex', () => {
const h = harness();
try {
+5
View File
@@ -266,6 +266,8 @@ export function createOutlineRungs({
heightM: null,
synthesized: false,
outlineName: outline.name,
adminArea: outline.adminArea || null,
adminLevel: outline.adminLevel || null,
};
}
@@ -288,6 +290,9 @@ export function createOutlineRungs({
const patch = finish(fp, asScope);
if (patch?.ring) {
patch.outlineCredit = provider === 'openfreemap' ? 'tiles' : 'osm';
// Which source answered, so a stored area names it (OpenFreeMap or
// Nominatim) instead of defaulting to the Overpass rungs.
patch.outlineSource = provider;
ctx.credit?.(patch.outlineCredit);
// One line per answered outline, like the resolver's own trace.
console.log(
+37 -2
View File
@@ -27,6 +27,7 @@ import {
createGateStateStore,
createNominatimGate,
} from '../../server/providers/regional/nominatimGate.js';
import { createVoiceAreas } from '../voice/areaActions.js';
const TILES = [6745, 6746].map((y) =>
decodeOpenFreeMapOutlineTile(
@@ -145,7 +146,7 @@ test('a street comes from tiles before the configured ladder, as merged ribbons'
);
assert.ok(result.ring.length > 6);
assert.equal(result.polygons.length, 1);
assert.equal('outlineSource' in result, false);
assert.equal(result.outlineSource, 'openfreemap');
assert.equal(run.calls.base, 0, 'the ladder is not consulted');
assert.deepEqual(run.calls.finished, ['street']);
assert.deepEqual(credits, ['tiles']);
@@ -415,6 +416,7 @@ test('a city with no configured Overpass is outlined by Nominatim', async () =>
);
assert.deepEqual(result.ring, ring);
assert.equal(result.outlineCredit, 'osm');
assert.equal(result.outlineSource, 'nominatim');
assert.deepEqual(nominatim.asks[0], {
query: 'Toulouse',
kind: 'city',
@@ -876,6 +878,19 @@ const NOMINATIM_ROWS = {
geojson: { type: 'Polygon', coordinates: [square(40.78, -73.965, 0.01)] },
},
],
'Bagmati Province': [
{
category: 'boundary',
type: 'administrative',
addresstype: 'province',
name: 'Bagmati Province',
lat: '27.7',
lon: '85.3',
osm_type: 'relation',
osm_id: 12345,
geojson: { type: 'Polygon', coordinates: [square(27.7, 85.3, 0.4)] },
},
],
};
const PLACES = {
@@ -900,6 +915,13 @@ const PLACES = {
label: 'Congress Ave, Austin',
types: ['route'],
},
'Bagmati Province': {
lat: 27.7,
lng: 85.3,
name: 'Bagmati Province',
label: 'Bagmati Province, Nepal',
types: ['administrative_area_level_1', 'political'],
},
};
const viewerAt = (lat, lon, height = 4000) => ({
@@ -977,6 +999,19 @@ test('default settings: outline, street and building asks send nothing to Overpa
target: 'Central Park',
});
const areas = createVoiceAreas({
viewer: viewerAt(27.7, 85.3, 40_000),
annotationResolver: resolver,
placeSearch,
});
const verified = await areas.resolveAreaAction({
query: 'Bagmati Province',
level: 'admin1',
});
assert.equal(verified.ok, true);
assert.equal(verified.sourceIdentity, 'Bagmati Province');
assert.equal(areas.store.get(verified.areaId).meta.adminLevel, 'admin1');
const street = await resolve(viewerAt(30.27, -97.742), {
target: 'Congress Avenue',
});
@@ -1000,7 +1035,7 @@ test('default settings: outline, street and building asks send nothing to Overpa
);
assert.equal(
upstream.length,
2,
3,
'one upstream request per explicit ask that needed one',
);
assert.ok(!upstream.some((url) => /overpass/i.test(url)));
@@ -103,6 +103,48 @@ test('"around the Capitol grounds" is still a grounds ask', async () => {
assert.equal(asks.length, 1);
});
test('Nominatim administrative identity survives the outline rung', async () => {
const boundary = {
polygons: [
[
[
[84.4, 26.9],
[86.6, 26.9],
[86.6, 28.4],
[84.4, 28.4],
[84.4, 26.9],
],
],
],
class: 'boundary',
type: 'administrative',
adminArea: 'Bagmati Province',
adminLevel: 'admin1',
};
const { rungs } = setup(boundary);
const result = await rungs.resolve(
ctx({
scope: 'state',
target: 'Bagmati Province',
matchName: 'Bagmati Province',
lat: 27.7,
lon: 85.3,
view: { lat: 27.7, lon: 85.3 },
}),
{
base: async () => UNAVAILABLE,
finish: (fp) => ({
ring: fp.ring,
footprintKind: fp.kind,
adminArea: fp.adminArea,
adminLevel: fp.adminLevel,
}),
},
);
assert.equal(result.adminArea, 'Bagmati Province');
assert.equal(result.adminLevel, 'admin1');
});
test('named grounds around a building seed still require the original anchor', async () => {
// A place-search anchor ~250 m from the dome, outside the Capitol lawn.
const offLawn = { lat: 30.27265, lon: -97.73925 };
+40
View File
@@ -525,6 +525,46 @@ test('a coarse bundled admin-1 city waits for a finer outline', async () => {
);
});
test('deferred outlines preserve provider administrative identity metadata', async () => {
const overParis = viewerAt(48.8566, 2.3522, 40_000);
const ring = square(48.8566, 2.3522, 0.05);
const { resolve } = harness(PARIS_PLACE, {
outlineRungs: rungsAnswering({
ring,
kind: 'area',
heightM: null,
adminArea: 'Île-de-France',
adminLevel: 'admin1',
}),
});
const outline = await (await resolve('Paris', overParis)).resolveOutline();
assert.equal(outline.adminArea, 'Île-de-France');
assert.equal(outline.adminLevel, 'admin1');
});
test('an explicitly distant area keeps its deferred outline', async () => {
const kathmandu = {
lat: 27.7172,
lng: 85.324,
label: 'Kathmandu, Nepal',
name: 'Kathmandu',
types: ['locality', 'political'],
};
const ring = square(27.7172, 85.324, 0.05);
const { resolve } = harness(kathmandu, {
outlineRungs: rungsAnswering({ ring, kind: 'area', heightM: null }),
});
const resolved = await resolve('Kathmandu', SAN_FRANCISCO, {
allowDistant: true,
});
assert.equal(resolved.source, 'geocode');
assert.deepEqual(
(await resolved.resolveOutline()).ring,
ring,
'the deferred centroid gate honors the same explicit distance policy as the anchor',
);
});
test('detailed admin-1 units and admin asks keep the bundled shape', async () => {
const { resolve } = harness(null, { outlineRungs: rungsAnswering(null) });
for (const [name, lat, lon] of [
+6
View File
@@ -588,6 +588,7 @@ export function createAnnotationResolver({
buildingHeight: null,
synthesized: false,
naturalRegion: ne.name,
outlineSource: 'natural-earth',
lat: neCentroid?.lat ?? lat,
lon: neCentroid?.lon ?? lon,
height: sampleGroundHeight(
@@ -857,6 +858,7 @@ export function createAnnotationResolver({
if (
fromGeocode &&
!bypassNearViewGuards &&
!allowDistant &&
gateDrift(centroid.lat, centroid.lon)
)
return null;
@@ -867,6 +869,8 @@ export function createAnnotationResolver({
synthesized: Boolean(fp.synthesized),
outlineCredit: fp.outlineCredit || null,
...(Array.isArray(fp.polygons) ? { polygons: fp.polygons } : {}),
...(fp.adminArea ? { adminArea: fp.adminArea } : {}),
...(fp.adminLevel ? { adminLevel: fp.adminLevel } : {}),
lat: centroid.lat,
lon: centroid.lon,
height: sampleGroundHeight(viewer, centroid.lon, centroid.lat),
@@ -1119,6 +1123,7 @@ export function createAnnotationResolver({
buildingHeight: null,
synthesized: false,
adminArea: admin.name,
outlineSource: admin.source,
lat,
lon,
height: sampleGroundHeight(viewer, lon, lat),
@@ -1169,6 +1174,7 @@ export function createAnnotationResolver({
buildingHeight: null,
label: admin.name,
source: 'bundled',
outlineSource: admin.source,
synthesized: false,
outlineUnavailable: false,
viewport: {
+663 -1
View File
@@ -2,7 +2,20 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createApplication } from './application.js';
import { installTrackpadPinchZoom } from './viewer.js';
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
import * as Cesium from 'cesium';
import {
installFreeLookHeightLimit,
installGlobeInViewRecovery,
installTrackpadPinchZoom,
} from './viewer.js';
import {
FREE_LOOK_MAX_HEIGHT_METERS,
globeFrameShare,
globeOutOfFrame,
lookAngleWithinFrame,
lookStepLosesGlobe,
} from '../globeInView.js';
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) {
@@ -343,3 +356,652 @@ test('trackpad pinch falls back to the original event when relay construction fa
assert.equal(event.propagationStopped, false);
assert.equal(f.relayed.length, 0);
});
function freeLookFixture({ enableLook = true, height = 0 } = {}) {
const listeners = new Set();
const controller = { enableLook, minimumTrackBallHeight: 7_500_000 };
const camera = { positionCartographic: { height } };
const viewer = {
camera,
scene: {
screenSpaceCameraController: controller,
preUpdate: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
},
};
const frame = (nextHeight) => {
camera.positionCartographic = { height: nextHeight };
for (const listener of listeners) listener();
};
return { viewer, controller, listeners, frame };
}
test('free look follows camera height and is restored on dispose', () => {
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
assert.equal(f.controller.enableLook, true);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1);
assert.equal(f.controller.enableLook, false);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS);
assert.equal(f.controller.enableLook, true);
f.frame(Number.NaN);
assert.equal(f.controller.enableLook, true);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
dispose();
dispose();
assert.equal(f.controller.enableLook, true);
assert.equal(f.listeners.size, 0);
});
test('drags that start on the stars orbit above the free-look height', () => {
// Below Cesium's trackball height such a drag turns the camera in place.
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
assert.equal(
f.controller.minimumTrackBallHeight,
FREE_LOOK_MAX_HEIGHT_METERS,
);
dispose();
assert.equal(f.controller.minimumTrackBallHeight, 7_500_000);
const lower = freeLookFixture({ height: 500 });
lower.controller.minimumTrackBallHeight = 500_000;
const disposeLower = installFreeLookHeightLimit(lower.viewer);
assert.equal(
lower.controller.minimumTrackBallHeight,
500_000,
'never raised',
);
disposeLower();
assert.equal(lower.controller.minimumTrackBallHeight, 500_000);
const later = freeLookFixture({ height: 500 });
const disposeLater = installFreeLookHeightLimit(later.viewer);
later.controller.minimumTrackBallHeight = 2_000_000;
disposeLater();
assert.equal(
later.controller.minimumTrackBallHeight,
2_000_000,
'dispose keeps a value set after install',
);
});
test('free look keeps a setting other code makes after it is installed', () => {
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
f.controller.enableLook = false;
f.frame(10);
assert.equal(f.controller.enableLook, false, 'turned off stays off');
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
f.frame(10);
assert.equal(f.controller.enableLook, false, 'and stays off after orbit');
f.controller.enableLook = true;
f.frame(10);
assert.equal(f.controller.enableLook, true, 'turned back on');
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
assert.equal(f.controller.enableLook, false, 'still limited from orbit');
f.frame(10);
f.controller.enableLook = false;
dispose();
assert.equal(f.controller.enableLook, false, 'dispose keeps a later setting');
});
test('free look stays off when the viewer had it disabled', () => {
const f = freeLookFixture({ enableLook: false, height: 500 });
installFreeLookHeightLimit(f.viewer);
f.frame(10);
assert.equal(f.controller.enableLook, false);
});
// A real Cesium camera on the smallest scene its constructor reads, so the
// frustum test runs on Cesium's own view math rather than a stub.
function realCamera({ width = 1600, height = 900, mode } = {}) {
const scene = {
canvas: { clientWidth: width, clientHeight: height },
drawingBufferWidth: width,
drawingBufferHeight: height,
mapProjection: new Cesium.GeographicProjection(),
mode: mode ?? Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
ellipsoid: Cesium.Ellipsoid.WGS84,
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
tweens: new Cesium.TweenCollection(),
renders: 0,
requestRender() {
this.renders += 1;
},
};
const camera = new Cesium.Camera(scene);
camera.frustum.aspectRatio = width / height;
return { camera, scene, viewer: { camera, scene } };
}
function aim(
camera,
{ lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 },
) {
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt),
orientation: {
heading: Cesium.Math.toRadians(heading),
pitch: Cesium.Math.toRadians(pitch),
roll: Cesium.Math.toRadians(roll),
},
});
}
const reportedStuckView = {
alt: 17_996_811,
heading: 180,
pitch: -42,
roll: 180,
};
// Oblique poses that still show part of the planet: Director recipe shots
// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit.
const partialGlobeViews = [
{ lon: -30, lat: 20, alt: 19_000_000, pitch: -65 },
{ lon: 2, lat: 46, alt: 8_000_000, pitch: -52 },
{ lon: 110, lat: 20, alt: 9_500_000, pitch: -66 },
{ lon: 0, lat: 0, alt: 2_000_000, pitch: -30 },
];
// Stars and a few pixels of limb, no surface: where a frame limit measured
// against a sphere of the planet's largest radius used to stop a voice tilt.
const limbOnlyView = { alt: 18_000_000, pitch: -56.75 };
/**
* An oracle independent of the code under test: the share of an 80×45 grid of
* Cesium pick rays across the canvas that hit the ellipsoid surface.
*/
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
let hits = 0;
let counted = 0;
for (let row = 0; row < 45; row += 1) {
for (let column = 0; column < 80; column += 1) {
const x = ((column + 0.5) * width) / 80;
const y = ((row + 0.5) * height) / 45;
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
continue;
counted += 1;
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
hits += 1;
}
}
return hits / counted;
}
test('the globe is out of frame only when no surface is on screen', () => {
const cases = [
[reportedStuckView, 1225, 923, true],
[limbOnlyView, 1600, 900, true],
[{ alt: 17_996_811, pitch: -90 }, 1225, 923, false],
...partialGlobeViews.map((view) => [view, 1600, 900, false]),
];
for (const [view, width, height, out] of cases) {
const { camera } = realCamera({ width, height });
aim(camera, view);
const label = JSON.stringify(view);
assert.equal(globeOutOfFrame(camera), out, label);
assert.equal(surfacePixelShare(camera, width, height) === 0, out, label);
assert.ok(
Math.abs(
globeFrameShare(camera) - surfacePixelShare(camera, width, height),
) < 0.03,
`${label}: the frame share matches the pick-ray oracle`,
);
}
});
test('a move that ends with the globe out of frame turns back to nadir', () => {
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const dispose = installGlobeInViewRecovery(viewer);
aim(camera, reportedStuckView);
const position = Cesium.Cartesian3.clone(camera.positionWC);
const heading = camera.heading;
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6));
assert.ok(
Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3),
);
assert.equal(globeOutOfFrame(camera), false);
assert.equal(scene.renders, 1);
// Looking up near the ground, then zoomed out past the limit.
aim(camera, { alt: 3_834_000, pitch: 10 });
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
// Only the limb on a 16:9 screen still counts as lost.
const wide = realCamera({ width: 1600, height: 900 });
const disposeWide = installGlobeInViewRecovery(wide.viewer);
aim(wide.camera, limbOnlyView);
wide.camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6));
disposeWide();
dispose();
dispose();
aim(camera, reportedStuckView);
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert');
});
// True when a moveEnd leaves the camera's view direction as it was.
function moveEndKeepsView(camera) {
const direction = Cesium.Cartesian3.clone(camera.directionWC);
camera.moveEnd.raiseEvent();
return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9);
}
test('globe recovery leaves partial, low, locked and 2D views alone', () => {
for (const view of partialGlobeViews) {
const { camera, viewer } = realCamera();
installGlobeInViewRecovery(viewer);
aim(camera, view);
assert.ok(moveEndKeepsView(camera), JSON.stringify(view));
}
const low = realCamera();
installGlobeInViewRecovery(low.viewer);
aim(low.camera, { alt: 900_000, pitch: 85 });
assert.equal(globeOutOfFrame(low.camera), true);
assert.ok(moveEndKeepsView(low.camera), 'below the limit');
// Locked on a point in space, between it and the planet, facing away.
const locked = realCamera();
installGlobeInViewRecovery(locked.viewer);
locked.camera.lookAtTransform(
Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)),
new Cesium.Cartesian3(-1_000_000, 0, 0),
);
assert.equal(globeOutOfFrame(locked.camera), true);
assert.ok(moveEndKeepsView(locked.camera), 'locked camera');
// Tracking an entity that has no position yet keeps an identity transform.
const tracked = realCamera();
tracked.viewer.trackedEntity = { id: 'pending-fix' };
installGlobeInViewRecovery(tracked.viewer);
aim(tracked.camera, reportedStuckView);
assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera');
const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW });
installGlobeInViewRecovery(flat.viewer);
flat.scene.mode = Cesium.SceneMode.SCENE3D;
aim(flat.camera, reportedStuckView);
flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
assert.ok(moveEndKeepsView(flat.camera), 'Columbus view');
});
test('the look limit from orbit tolerates grid noise and never reaches zero', () => {
const radians = Cesium.Math.toRadians;
// About 2% of a 16:9 frame on the planet, below the 10% limit.
const sliver = { alt: 18_000_000, pitch: -60 };
const { camera } = realCamera();
aim(camera, sliver);
const start = surfacePixelShare(camera, 1600, 900);
assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`);
// Sideways across the sliver: the share wobbles by a cell, the planet stays.
assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false);
const startShare = globeFrameShare(camera);
for (let i = 0; i < 20; i += 1) {
const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), {
startShare,
});
assert.equal(step, radians(0.25), `sideways step ${i + 1}`);
camera.lookLeft(step);
}
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
// Toward the planet is always allowed.
aim(camera, sliver);
assert.equal(
lookAngleWithinFrame(camera, 'lookDown', radians(10)),
radians(10),
);
// Away from it stops short of losing it.
aim(camera, sliver);
const away = lookAngleWithinFrame(camera, 'lookUp', radians(30));
assert.ok(away < radians(30));
camera.lookUp(away);
assert.ok(globeFrameShare(camera) > 0);
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('the look limit leaves a view with no surface to the move-end recovery', () => {
const { camera } = realCamera({ width: 1225, height: 923 });
aim(camera, reportedStuckView);
assert.equal(
lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)),
Cesium.Math.toRadians(5),
);
});
// Surface only in the corners of a 16:9 frame, all of it outside the scope's
// keyhole: the full frame shows some planet, the scope shows none.
const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 };
test('with a scope keyhole, surface hidden in the masked corners counts as none', () => {
const { camera } = realCamera();
aim(camera, cornerOnlyView);
const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS };
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0);
assert.equal(globeOutOfFrame(camera), false);
assert.equal(globeOutOfFrame(camera, keyhole), true);
aim(camera, { alt: 18_000_000, pitch: -90 });
assert.ok(
Math.abs(
globeFrameShare(camera, keyhole) -
surfacePixelShare(camera, 1600, 900, { keyhole: true }),
) < 0.03,
'the keyhole share matches the pick-ray oracle',
);
});
test('the recovery follows the scope: corner-only views are turned back only behind it', () => {
for (const [scopeOn, expectRecovery] of [
[true, true],
[false, false],
]) {
const { camera, viewer } = realCamera();
installGlobeInViewRecovery(viewer, {
keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0),
});
aim(camera, cornerOnlyView);
camera.moveEnd.raiseEvent();
assert.equal(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
expectRecovery,
`scope ${scopeOn ? 'on' : 'off'}`,
);
}
});
test('the recovery leaves a camera flight and an authored scene alone', () => {
let playing = true;
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
scene.camera = camera;
scene.screenSpaceCameraController = { enableInputs: true };
installGlobeInViewRecovery(viewer, { paused: () => playing });
aim(camera, reportedStuckView);
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'a playing scene is left alone');
playing = false;
// A flight that pauses looking away is left alone until it ends.
camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(10, 20, 18_000_000),
duration: 5,
});
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'a flight is left alone');
camera.cancelFlight();
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
});
test('a check a scene or flight defers runs once it ends', () => {
for (const viaMoveEnd of [false, true]) {
let playing = true;
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, {
paused: () => playing,
...timers,
});
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
// A hold on a stars-only shot: quiet, with or without Cesium's moveEnd.
for (let i = 0; i < 4; i += 1) timers.tick();
if (viaMoveEnd) camera.moveEnd.raiseEvent();
// Longer than the settle check's five-minute bound.
for (let i = 0; i < 1500; i += 1) timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays');
assert.equal(timers.pending(), 1, 'still watching');
playing = false;
timers.tick();
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
`recovered once the scene ends (${viaMoveEnd ? 'after moveEnd' : 'no moveEnd'})`,
);
}
});
// Holding a drag still for half a second on a view that already showed no
// surface looked like a settled camera, and the view was turned down under
// the user's hand.
test('the recovery waits while a pointer is held on the globe', () => {
const timers = fakeTimers();
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const document = new EventTarget();
scene.canvas = Object.assign(new EventTarget(), {
clientWidth: 1225,
clientHeight: 923,
ownerDocument: document,
});
const dispose = installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
scene.canvas.dispatchEvent(new Event('pointerdown'));
camera.moveStart.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
camera.moveEnd.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'left alone while held');
document.dispatchEvent(new Event('pointerup'));
for (let i = 0; i < 3; i += 1) timers.tick();
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
'recovered once the pointer is released',
);
dispose();
aim(camera, reportedStuckView);
scene.canvas.dispatchEvent(new Event('pointerdown'));
document.dispatchEvent(new Event('pointerup'));
assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners');
});
/** A held-pointer recovery over a canvas in a document in a window. */
function heldPointerRecovery() {
const timers = fakeTimers();
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const window = new EventTarget();
const document = Object.assign(new EventTarget(), {
defaultView: window,
visibilityState: 'visible',
});
scene.canvas = Object.assign(new EventTarget(), {
clientWidth: 1225,
clientHeight: 923,
ownerDocument: document,
});
const dispose = installGlobeInViewRecovery(viewer, timers);
const pointer = (target, type, pointerId, init = {}) =>
target.dispatchEvent(
Object.assign(new Event(type), { pointerId, ...init }),
);
/** Press, move and hold still: the check defers while a pointer is held. */
const holdStill = () => {
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
camera.moveEnd.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
};
const settle = () => {
for (let i = 0; i < 3; i += 1) timers.tick();
};
const recovered = () =>
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6);
return {
camera,
document,
window,
dispose,
pointer,
holdStill,
settle,
recovered,
canvas: scene.canvas,
};
}
// Releasing one of two fingers cleared the held flag, and the recovery
// turned the view down under the finger still on the glass.
test('the recovery waits until every held pointer is released', () => {
const h = heldPointerRecovery();
h.pointer(h.canvas, 'pointerdown', 1);
h.pointer(h.canvas, 'pointerdown', 2);
h.holdStill();
h.pointer(h.document, 'pointerup', 1);
h.settle();
assert.equal(h.recovered(), false, 'left alone under the second finger');
assert.equal(globeOutOfFrame(h.camera), true);
h.pointer(h.document, 'pointercancel', 2);
h.settle();
assert.equal(h.recovered(), true, 'recovered once both are released');
h.dispose();
});
// A release the page never sees (the pointer let go outside the window)
// must not hold the recovery off for good.
for (const [label, lose] of [
['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))],
[
'the page is hidden',
(h) => {
h.document.visibilityState = 'hidden';
h.document.dispatchEvent(new Event('visibilitychange'));
},
],
[
'the canvas loses the pointer capture',
(h) => h.pointer(h.canvas, 'lostpointercapture', 1),
],
[
'the pointer moves with no button down',
(h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }),
],
]) {
test(`a held pointer whose release is lost stops deferring when ${label}`, () => {
const h = heldPointerRecovery();
h.pointer(h.canvas, 'pointerdown', 1);
h.holdStill();
h.pointer(h.document, 'pointermove', 1, { buttons: 1 });
h.settle();
assert.equal(h.recovered(), false, 'left alone while held');
lose(h);
h.settle();
assert.equal(h.recovered(), true, 'recovered once the hold is let go');
h.dispose();
});
}
function fakeTimers() {
let next = 1;
const pending = new Map();
return {
setTimeout: (callback) => {
const id = next++;
pending.set(id, callback);
return id;
},
clearTimeout: (id) => pending.delete(id),
pending: () => pending.size,
/** Run the earliest pending callback, as 250 ms passing would. */
tick() {
const [id, callback] = pending.entries().next().value ?? [];
if (id === undefined) return false;
pending.delete(id);
callback();
return true;
},
};
}
test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => {
// At this pose the camera can drift by a rounding error every frame, so
// Cesium never sees it stop and never raises moveEnd for it.
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough');
// A rounding-error drift between samples still counts as at rest.
camera.lookUp(1e-15);
timers.tick();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
// Under that drift Cesium raises no new moveStart, so a later move that
// ends looking away is caught by the same check.
aim(camera, reportedStuckView);
timers.tick();
timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'not yet at rest');
timers.tick();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
camera.moveEnd.raiseEvent();
assert.equal(timers.pending(), 0, 'moveEnd ends the check');
});
test('the settle check follows a long move and gives up five minutes after the last one', () => {
// A drag of half a minute that stops at a stuck pose is still recovered.
const long = fakeTimers();
const dragged = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(dragged.viewer, long);
aim(dragged.camera, reportedStuckView);
dragged.camera.moveStart.raiseEvent();
for (let i = 0; i < 120; i += 1) {
dragged.camera.moveLeft(0.01);
long.tick();
}
assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving');
while (long.tick());
assert.ok(
Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6),
'recovered once it stopped',
);
// A camera that keeps moving is followed, and left alone, for as long as it
// moves; once it rests the check gives up five minutes later.
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
for (let i = 0; i < 2000; i += 1) {
camera.moveLeft(0.01);
timers.tick();
}
assert.equal(timers.pending(), 1, 'still following after eight minutes');
assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone');
let idleSamples = 0;
while (timers.tick()) idleSamples += 1;
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
'recovered once it stopped',
);
assert.ok(
idleSamples >= 1200 && idleSamples <= 1205,
`gave up ${idleSamples} samples after it stopped`,
);
const disposed = fakeTimers();
const other = realCamera({ width: 1225, height: 923 });
const dispose = installGlobeInViewRecovery(other.viewer, disposed);
aim(other.camera, reportedStuckView);
other.camera.moveStart.raiseEvent();
dispose();
assert.equal(disposed.pending(), 0, 'dispose cancels the pending check');
});
+1
View File
@@ -5,6 +5,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
satellitesLayer: 'satellites',
cctvLayer: 'cctv',
streetLevelLayer: 'street-level',
wildlifeSightingsLayer: 'wildlife-sightings',
radioLayer: 'radio',
bikeshareLayer: 'bikeshare',
transitLayer: 'transit',
+2
View File
@@ -139,6 +139,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
'rocket-launches',
'street-level',
'local-adsb',
'wildlife-sightings',
];
const layers = ids.map((id) => ({ id }));
const catalog = createLayerCatalog(layers, metadata(layers));
@@ -147,6 +148,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
assert.equal(services.transitLayer, layers[7]);
assert.equal(services.aisLiveVesselsLayer, layers[8]);
assert.equal(services.localAdsbLayer, layers[13]);
assert.equal(services.wildlifeSightingsLayer, layers[14]);
assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws(
() => catalogControlServices(createLayerCatalog([], [])),
+17 -3
View File
@@ -1,7 +1,8 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
import { worldClock } from '../time/worldClock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createApplicationCyclones } from './layers/cyclones.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
@@ -15,6 +16,7 @@ import { createApplicationTraffic } from './layers/traffic.js';
import { createApplicationBikeshare } from './layers/bikeshare.js';
import { createApplicationDirections } from './layers/directions.js';
import { createApplicationRecentImagery } from './layers/recentImagery.js';
import { createApplicationOsmPlaces } from './layers/osmPlaces.js';
import { createApplicationTransit } from './layers/transit.js';
import { createApplicationInstallations } from './layers/militaryInstallations.js';
import { createApplicationSatellites } from './layers/satellites.js';
@@ -28,6 +30,8 @@ import { createApplicationAirQuality } from './layers/airQuality.js';
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';
@@ -42,6 +46,8 @@ import { createBhoteKoshiLocatorLayer } from '../data/bhoteKoshiLocator.js';
*/
export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([
Object.freeze({ id: 'local-adsb', disposition: 'local-only' }),
// Voice search results belong to this session, like a receiver to this machine.
Object.freeze({ id: 'osm-places', disposition: 'local-only' }),
]);
/** Serialization metadata for every layer the application catalog constructs. */
@@ -73,7 +79,7 @@ export function createApplicationCatalog({
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const weatherClock = createWeatherClock({ world: worldClock });
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
@@ -130,6 +136,13 @@ export function createApplicationCatalog({
windowMs: sources['gnss-integrity'].windowMs,
}),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationDroneAirspace({
source: sources['drone-airspace'],
}),
createApplicationWildlifeSightings({
source: sources['wildlife-sightings'],
groundFloor: surface.groundFloor,
}),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
@@ -145,6 +158,7 @@ export function createApplicationCatalog({
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
createApplicationOsmPlaces(),
vessels,
installations,
createApplicationAwareness({
@@ -169,7 +183,7 @@ export function createApplicationCatalog({
id: 'weather-lightning',
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
createApplicationCyclones({ feed: sources.cyclones }),
...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
+8 -2
View File
@@ -40,8 +40,13 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 35);
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'),
{ id: 'osm-places', disposition: 'local-only' },
'voice search results never enter share links or stored state',
);
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
{ id: 'local-adsb', disposition: 'local-only' },
@@ -62,6 +67,7 @@ test('catalogs construct distinct layers and classification from their supplied
assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('gnss-integrity'));
assert.ok(first.get('s2-ghostmaps'));
assert.ok(first.get('drone-airspace'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -155,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, 35);
assert.equal(catalog.layers.length, 38);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+7
View File
@@ -1,6 +1,7 @@
import { catalogControlServices } from './catalog.js';
import { StyleManager } from '../ui/composition.js';
import { flyToAustin } from '../camera.js';
import { installZoomOutGuard } from './zoomOutGuard.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
import { t } from '../i18n/index.js';
@@ -41,6 +42,12 @@ export function createApplicationControls({
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
defer(() => styleManager.dispose());
defer(
installZoomOutGuard(viewer, {
subscribeCameraHandoff: (listener) =>
styleManager.subscribeCameraHandoff?.(listener),
}),
);
// The previous multi-canvas weather compositor remains disabled. Cockpit
// clouds use a separate, capped low-resolution GPU pass that never attaches
// Cesium fog or post-process stages and is fully stopped in map mode.
+68
View File
@@ -0,0 +1,68 @@
import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import '../testSupport/stubMgrs.mjs';
import { installVueDom } from '../testSupport/vueDom.mjs';
import { MAXIMUM_ZOOM_DISTANCE_M } from './zoomOutGuard.js';
const { createApplicationControls } = await import('./controls.js');
let uninstallDom;
before(() => {
uninstallDom = installVueDom();
});
after(() => uninstallDom());
test('application controls install the zoom-out guard and remove it on teardown', () => {
const canvas = document.createElement('canvas');
const controller = { maximumZoomDistance: Infinity };
const scene = {
mode: Cesium.SceneMode.SCENE3D,
screenSpaceCameraController: controller,
preRender: new Cesium.Event(),
postUpdate: new Cesium.Event(),
};
const camera = {
position: Cesium.Cartesian3.fromDegrees(0, 0, 1_000_000),
positionCartographic: { height: 1_000_000 },
};
const viewer = { scene, camera, canvas };
const handoffs = new Set();
class Controls {
hasShareState = true;
orbitController = { stop() {} };
hud = { destroy() {} };
dispose() {}
subscribeCameraHandoff(listener) {
handoffs.add(listener);
return () => handoffs.delete(listener);
}
}
const cleanups = [];
createApplicationControls({
scene: {
viewer,
mapStackController: {},
operations: {
surface: { controlServices: {} },
searchAndFlyTo() {},
requests: { regional: {}, weather: {} },
},
},
loaderStatus: {},
Controls,
catalog: { get: (id) => ({ id }) },
defer: (cleanup) => cleanups.push(cleanup),
});
assert.equal(controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
assert.equal(scene.preRender.numberOfListeners, 1);
assert.equal(scene.postUpdate.numberOfListeners, 1);
assert.equal(handoffs.size, 1);
for (const cleanup of cleanups.reverse()) cleanup();
assert.equal(controller.maximumZoomDistance, Infinity);
assert.equal(scene.preRender.numberOfListeners, 0);
assert.equal(scene.postUpdate.numberOfListeners, 0);
assert.equal(handoffs.size, 0);
});
+10
View File
@@ -0,0 +1,10 @@
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
import * as context from '../../data/contextStore.js';
/** Wire cyclone selection to the application shared context. */
export function createApplicationCyclones(options) {
return createCyclonesLayer({
context: typeof window !== 'undefined' ? context : null,
...options,
});
}
+22
View File
@@ -0,0 +1,22 @@
import * as Cesium from 'cesium';
import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire FAA special-use airspace into the application catalog. */
export function createApplicationDroneAirspace(options) {
return createDroneAirspaceLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+5 -1
View File
@@ -1,6 +1,10 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
import { worldTimeFor } from '../../time/worldClock.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer(options);
return createGnssIntegrityLayer({
worldNow: worldTimeFor('gnss-integrity'),
...options,
});
}
+7
View File
@@ -0,0 +1,7 @@
import { createOsmPlacesLayer } from '../../layers/osmPlaces/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire OSM Places labels to the application overlay host. */
export function createApplicationOsmPlaces() {
return createOsmPlacesLayer({ overlayHost });
}
+4
View File
@@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
import { worldTimeFor } from '../../time/worldClock.js';
const imageryTime = worldTimeFor('recent-imagery');
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
@@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) {
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
now: () => new Date(imageryTime()),
});
}
+30
View File
@@ -0,0 +1,30 @@
import * as Cesium from 'cesium';
import { createWildlifeSightingsLayer } from '../../layers/wildlifeSightings/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as sprites from '../../data/spriteOrder.js';
import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as focus from '../../worldFocus.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Bind the wildlife layer to the application's scene and selection owners. */
export function createApplicationWildlifeSightings(options) {
return createWildlifeSightingsLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
sprites,
render,
context,
focus,
...options,
});
}
+10
View File
@@ -2,6 +2,7 @@ import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import {
createApplicationViewer,
installFreeLookHeightLimit,
installTrackpadPinchZoom,
} from '../app/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js';
@@ -14,6 +15,8 @@ import {
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
import { worldClock, worldTimeFor } from '../time/worldClock.js';
import { followWorldTime } from '../time/viewerClock.js';
import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
@@ -58,6 +61,13 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
defer(
followWorldTime(viewer, worldTimeFor('sky'), {
subscribe: worldClock.subscribe,
requestRender: () => governorRequestRender('world-time'),
}),
);
defer(installFreeLookHeightLimit(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
+2
View File
@@ -49,6 +49,8 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
'air-quality': contract(['air-quality'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
});
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 35);
assert.equal(actual.layers.length, 38);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+19 -1
View File
@@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
installScopeMask,
destroyScopeMask,
isScopeMaskEnabled,
} from '../scopeMask.js';
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
import { installGlobeInViewRecovery } from './viewer.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import {
installRenderGovernor,
@@ -29,6 +35,7 @@ export function createApplicationTools({
startChrome,
onSceneDirector,
sceneDataPacks,
drawMarkActions = [],
signal,
defer,
}) {
@@ -44,6 +51,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,
@@ -66,12 +81,15 @@ export function createApplicationTools({
// while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button. A row's fly-to
// goes through the shell's navigation policy, as Import's does.
// `drawMarkActions` are an application's own buttons on a drawn area's or
// line's row (annotations/drawMarks.js); the standalone shell adds none.
const drawTool = initDrawTool({
viewer,
annotations,
navigation: styleManager,
publish: (view) => displayStore.publishDraw(view),
connect: connectDisplay,
markActions: drawMarkActions,
});
defer(() => drawTool?.destroy());
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
+273
View File
@@ -1,6 +1,53 @@
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';
/**
* At the farthest a hand can zoom out, the globe is this share of the
* screen's height across. Cesium sets no limit, so zooming on left the globe
* a speck among the stars.
*/
const MIN_GLOBE_SHARE = 0.3;
/**
* The farthest a hand can zoom out, in metres above the ellipsoid, for a
* vertical field of view. It follows the screen's shape, as the app's own
* whole-globe view does, so that view always lies within it.
* @param {number} fovy Vertical field of view, radians.
*/
export function zoomOutLimit(fovy) {
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
const angular = Math.atan(MIN_GLOBE_SHARE * Math.tan(fovy / 2));
return radius / Math.sin(angular) - radius;
}
/**
* Hold a hand's zoom out to `zoomOutLimit` on the globe, each frame, since the
* field of view follows the screen's shape. The limit never sits below the
* camera: one already past it (a flight, a screen turned) stops there rather
* than jumping in at the next zoom. Flat maps keep Cesium's own, unlimited.
* @returns {() => void} Stop limiting.
*/
export function limitZoomOut(viewer) {
const { scene, camera } = viewer;
return scene.preRender.addEventListener(() => {
const controller = scene.screenSpaceCameraController;
const fovy = camera.frustum.fovy;
if (scene.mode !== Cesium.SceneMode.SCENE3D || !Number.isFinite(fovy)) {
controller.maximumZoomDistance = Number.POSITIVE_INFINITY;
return;
}
controller.maximumZoomDistance = Math.max(
zoomOutLimit(fovy),
camera.positionCartographic?.height ?? 0,
);
});
}
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
@@ -103,6 +150,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 {
@@ -142,6 +414,7 @@ export function createApplicationViewer({ container, creditContainer }) {
// required on touch-first screens. Mouse/trackpad sensitivity stays native.
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
viewer.scene.screenSpaceCameraController.zoomFactor *= 3;
limitZoomOut(viewer);
// Before any tile builds a draw command: Cesium's per-vertex model
// atmosphere fails to LINK on Apple's Metal backend and kills the
// render loop. See app/atmosphereCompat.js.
+73
View File
@@ -0,0 +1,73 @@
// A hand zooming out stops while the globe is still a fair share of the
// screen, on any screen's shape, rather than leaving it a speck.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { limitZoomOut, zoomOutLimit } from './viewer.js';
// The share of the screen's height the globe spans from `altitude`.
function globeShare(altitude, fovy) {
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
const angular = Math.asin(radius / (radius + altitude));
return Math.tan(angular) / Math.tan(fovy / 2);
}
test("zooming out stops with the globe 30% of the screen's height across", () => {
for (const fovy of [Math.PI / 3, 0.4, 0.2]) {
const limit = zoomOutLimit(fovy);
assert.ok(Math.abs(globeShare(limit, fovy) - 0.3) < 1e-9, `fovy ${fovy}`);
}
});
test('a wide screen, whose view is narrower, may zoom out farther', () => {
assert.ok(zoomOutLimit(0.4) > zoomOutLimit(Math.PI / 3));
assert.ok(zoomOutLimit(Math.PI / 3) > 20_000_000, 'past the views a link opens');
});
function fakeViewer({ fovy = Math.PI / 3, height = 10_000_000 } = {}) {
const scene = {
mode: Cesium.SceneMode.SCENE3D,
preRender: new Cesium.Event(),
screenSpaceCameraController: { maximumZoomDistance: Infinity },
};
const camera = {
frustum: { fovy },
positionCartographic: { height },
};
return { scene, camera };
}
test('the limit follows the screen, and holds a camera already past it', () => {
const viewer = fakeViewer();
const controller = viewer.scene.screenSpaceCameraController;
const stop = limitZoomOut(viewer);
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(Math.PI / 3));
// A wider screen narrows the view, and the globe may go farther.
viewer.camera.frustum.fovy = 0.4;
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4));
// Flown past it, the next zoom out stops there instead of jumping in.
viewer.camera.positionCartographic.height = zoomOutLimit(0.4) * 2;
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2);
stop();
viewer.camera.positionCartographic.height = 0;
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2);
});
test('flat maps keep zooming out unlimited', () => {
const viewer = fakeViewer();
const controller = viewer.scene.screenSpaceCameraController;
limitZoomOut(viewer);
viewer.scene.preRender.raiseEvent();
assert.ok(Number.isFinite(controller.maximumZoomDistance));
viewer.scene.mode = Cesium.SceneMode.SCENE2D;
viewer.camera.frustum = {};
viewer.scene.preRender.raiseEvent();
assert.equal(controller.maximumZoomDistance, Infinity);
});
+241
View File
@@ -0,0 +1,241 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from '../renderGovernor.js';
/** Whole-Earth input limit; explicit camera destinations can go farther. */
export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000;
/** Below this, oblique browsing is left alone. */
export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
/** Look this far below the Earth's limb, so the globe stays on screen. */
export const LIMB_MARGIN_RAD = Cesium.Math.toRadians(8);
const PITCH_EASE = 0.35;
const PITCH_SNAP_RAD = Cesium.Math.toRadians(0.1);
const HEIGHT_EPSILON_M = 0.0001;
const EARTH_RADIUS_M = 6_371_000;
const INPUT_BINDINGS = Object.values(Cesium.CameraEventType).flatMap((type) =>
[undefined, ...Object.values(Cesium.KeyboardEventModifier)].map(
(modifier) => ({
type,
modifier,
}),
),
);
/**
* The shallowest pitch (radians, negative is down) that still keeps the Earth
* framed from height `h`, or null where tilted views are left alone.
* The limb sits acos(R / (R + h)) below the local horizontal.
*/
export function maxPitchForHeight(h) {
if (!(h >= PITCH_CLAMP_MIN_HEIGHT_M)) return null;
const limb = Math.acos(EARTH_RADIUS_M / (EARTH_RADIUS_M + h));
return -Math.min(limb + LIMB_MARGIN_RAD, Cesium.Math.PI_OVER_TWO);
}
function matchesZoomBinding(bindings, type, modifier) {
if (Array.isArray(bindings))
return bindings.some((binding) =>
matchesZoomBinding(binding, type, modifier),
);
return typeof bindings === 'number'
? bindings === type && modifier === undefined
: bindings?.eventType === type && bindings?.modifier === modifier;
}
function readInput(aggregator, bindings) {
let outward = false;
let cancel = false;
for (const { type, modifier } of INPUT_BINDINGS) {
if (!aggregator.isMoving(type, modifier)) continue;
const movement = aggregator.getMovement(type, modifier);
const pinch = type === Cesium.CameraEventType.PINCH;
const distance = pinch ? movement.distance : movement;
const delta = distance.endPosition.y - distance.startPosition.y;
// Cesium tilts by the fingers' midpoint while zooming by their spacing,
// both in the same quarter-pixel units. Fingers rarely keep a perfectly
// even spacing, so the larger of the two movements decides the gesture.
const tilt = pinch
? movement.angleAndHeight.endPosition.y -
movement.angleAndHeight.startPosition.y
: 0;
if (
matchesZoomBinding(bindings, type, modifier) &&
Math.abs(delta) > Math.abs(tilt)
) {
outward ||= delta < 0;
cancel ||= delta > 0;
} else {
// Includes modifier-specific tilts and parallel two-finger movement.
cancel = true;
}
}
aggregator.reset();
return { outward, cancel };
}
/**
* Keep outward user zooms bounded and framed, without taking camera ownership
* from tilts, tracked entities or flights. Returns an idempotent disposer.
*/
export function installZoomOutGuard(
viewer,
{
requestRender = governorRequestRender,
subscribeCameraHandoff,
createAggregator = (canvas) => new Cesium.CameraEventAggregator(canvas),
} = {},
) {
const scene = viewer?.scene;
const controller = scene?.screenSpaceCameraController;
const camera = viewer?.camera;
const canvas = viewer?.canvas;
if (
!controller ||
!camera ||
!canvas ||
!scene.preRender ||
!scene.postUpdate
)
throw new TypeError('A complete Cesium viewer is required');
const aggregator = createAggregator(canvas);
const originalMaximumZoomDistance = controller.maximumZoomDistance;
const ownsCeiling = originalMaximumZoomDistance > MAXIMUM_ZOOM_DISTANCE_M;
let maximumZoomDistance = Math.min(
originalMaximumZoomDistance,
MAXIMUM_ZOOM_DISTANCE_M,
);
controller.maximumZoomDistance = maximumZoomDistance;
// Cesium clamps any zoom that starts above the limit to the limit, in either
// direction. A camera placed higher by a flight or share link keeps its own
// height as the limit, so it zooms in gradually and can't climb further.
const followCeiling = () => {
if (!ownsCeiling || controller.maximumZoomDistance !== maximumZoomDistance)
return;
// While tracking, Cesium measures zoom from the target, not the Earth, so
// the camera's altitude would lift the ceiling with every outward step.
const untracked =
scene.mode === Cesium.SceneMode.SCENE3D &&
Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
const height = untracked ? camera.positionCartographic?.height : undefined;
maximumZoomDistance =
height > MAXIMUM_ZOOM_DISTANCE_M ? height : MAXIMUM_ZOOM_DISTANCE_M;
controller.maximumZoomDistance = maximumZoomDistance;
};
followCeiling();
let lastHeight = camera.positionCartographic?.height;
let lastPosition = Cesium.Cartesian3.clone(camera.position);
let lastHeading = camera.heading;
let lastPitch = camera.pitch;
let lastRoll = camera.roll;
let zoomingOut = false;
let settling = false;
const cancel = () => {
zoomingOut = false;
settling = false;
};
const guard = () => {
const input = readInput(aggregator, controller.zoomEventTypes);
const height = camera.positionCartographic?.height;
const climbing = height > lastHeight + HEIGHT_EPSILON_M;
const descending = height < lastHeight - HEIGHT_EPSILON_M;
try {
if (
scene.mode !== Cesium.SceneMode.SCENE3D ||
controller.enableInputs === false ||
controller.enableZoom === false ||
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY) ||
// Cesium has no public running-flight flag.
camera._currentFlight ||
input.cancel ||
(descending && !settling)
) {
cancel();
return;
}
if (input.outward) {
zoomingOut = true;
// A genuine outward gesture can be blocked by the controller's ceiling.
settling = true;
}
if (climbing) {
if (!zoomingOut) return;
settling = true;
} else {
zoomingOut = false;
// Outward inertia can lower altitude when Cesium clamps slant range at
// the ceiling. Finish that correction; fresh inward input cancels above.
// A stationary correction owns only the pose it last produced.
if (
!input.outward &&
((!descending &&
!Cesium.Cartesian3.equals(camera.position, lastPosition)) ||
Math.abs(Cesium.Math.negativePiToPi(camera.heading - lastHeading)) >
1e-10 ||
Math.abs(camera.pitch - lastPitch) > 1e-10 ||
Math.abs(Cesium.Math.negativePiToPi(camera.roll - lastRoll)) >
1e-10)
)
settling = false;
if (!settling) return;
}
const maxPitch = maxPitchForHeight(height);
if (maxPitch === null || !(camera.pitch > maxPitch)) {
settling = false;
} else {
const error = maxPitch - camera.pitch;
const easedPitch =
Math.abs(error) <= PITCH_SNAP_RAD
? maxPitch
: camera.pitch + error * PITCH_EASE;
// A large wheel step must not leave the view above the limb while easing.
const limbPitch = -Math.acos(
EARTH_RADIUS_M / (EARTH_RADIUS_M + height),
);
const pitch = Math.min(easedPitch, limbPitch);
camera.setView({
orientation: { heading: camera.heading, pitch, roll: camera.roll },
});
settling = pitch > maxPitch;
}
// Observe the end of inertia too; no arbitrary timeout cuts off a long zoom.
if (zoomingOut || settling) requestRender('zoom-out-guard');
} finally {
lastHeight = camera.positionCartographic?.height;
lastPosition = Cesium.Cartesian3.clone(camera.position, lastPosition);
lastHeading = camera.heading;
lastPitch = camera.pitch;
lastRoll = camera.roll;
}
};
const removePreRender = scene.preRender.addEventListener(guard);
// Fires every frame, after Cesium has applied this frame's input, while
// preRender fires only on frames that draw. Input that didn't move the camera
// (such as wheel at the ceiling) would otherwise wait for an unrelated frame.
const removePostUpdate = scene.postUpdate.addEventListener(() => {
followCeiling();
if (
INPUT_BINDINGS.some(({ type, modifier }) =>
aggregator.isMoving(type, modifier),
)
)
requestRender('zoom-out-guard');
});
const removeCameraHandoff = subscribeCameraHandoff?.(() => {
cancel();
// Discard input queued before an explicit navigation action took ownership.
aggregator.reset();
});
let active = true;
return () => {
if (!active) return;
active = false;
removeCameraHandoff?.();
removePreRender?.();
removePostUpdate?.();
aggregator.destroy();
if (controller.maximumZoomDistance === maximumZoomDistance)
controller.maximumZoomDistance = originalMaximumZoomDistance;
};
}
+764
View File
@@ -0,0 +1,764 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
installZoomOutGuard,
maxPitchForHeight,
MAXIMUM_ZOOM_DISTANCE_M,
} from './zoomOutGuard.js';
const deg = Cesium.Math.toDegrees;
const { WHEEL, PINCH, RIGHT_DRAG, LEFT_DRAG, MIDDLE_DRAG } =
Cesium.CameraEventType;
const { CTRL, SHIFT, ALT } = Cesium.KeyboardEventModifier;
test('maxPitchForHeight leaves low-altitude tilt alone', () => {
assert.equal(maxPitchForHeight(0), null);
assert.equal(maxPitchForHeight(299_999), null);
assert.equal(maxPitchForHeight(NaN), null);
assert.equal(maxPitchForHeight(undefined), null);
});
test('maxPitchForHeight looks 8 degrees below the limb', () => {
assert.ok(Math.abs(deg(maxPitchForHeight(1_000_000)) + 38.19) < 0.01);
assert.equal(deg(maxPitchForHeight(1e12)), -90);
});
test('maxPitchForHeight steepens monotonically with height', () => {
let previous = 0;
for (let h = 300_000; h <= 100_000_000; h *= 1.5) {
const pitch = maxPitchForHeight(h);
assert.ok(pitch <= previous, `pitch at ${h} m`);
previous = pitch;
}
});
function guardFixture({ maximumZoomDistance = Infinity } = {}) {
const preRender = new Set();
const postUpdate = new Set();
const handoffs = new Set();
const container = new EventTarget();
const canvas = new EventTarget();
const movements = new Map();
const key = (type, modifier) => `${type}:${modifier}`;
const aggregator = {
resets: 0,
destroys: 0,
isMoving: (type, modifier) => movements.has(key(type, modifier)),
getMovement: (type, modifier) => movements.get(key(type, modifier)),
reset() {
movements.clear();
this.resets++;
},
destroy() {
this.destroys++;
movements.clear();
},
};
const camera = {
height: 170_000,
longitude: 0,
latitude: 0,
heading: 0.4,
pitch: Cesium.Math.toRadians(-25),
roll: 0,
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
views: [],
get positionCartographic() {
return {
height: this.height,
longitude: this.longitude,
latitude: this.latitude,
};
},
get position() {
return this.positionWC;
},
get positionWC() {
return Cesium.Cartesian3.fromRadians(
this.longitude,
this.latitude,
this.height,
);
},
setView({ orientation }) {
this.views.push(orientation);
this.heading = orientation.heading;
this.pitch = orientation.pitch;
this.roll = orientation.roll;
},
};
const controller = {
maximumZoomDistance,
enableInputs: true,
enableZoom: true,
zoomEventTypes: [
RIGHT_DRAG,
WHEEL,
PINCH,
{ eventType: WHEEL, modifier: CTRL },
],
};
const scene = {
mode: Cesium.SceneMode.SCENE3D,
screenSpaceCameraController: controller,
preRender: {
addEventListener(handler) {
preRender.add(handler);
return () => preRender.delete(handler);
},
},
postUpdate: {
addEventListener(handler) {
postUpdate.add(handler);
return () => postUpdate.delete(handler);
},
},
};
const renders = [];
const dispose = installZoomOutGuard(
{ camera, container, canvas, scene },
{
subscribeCameraHandoff(listener) {
handoffs.add(listener);
return () => handoffs.delete(listener);
},
createAggregator(element) {
assert.equal(element, canvas);
return aggregator;
},
requestRender: (reason) => renders.push(reason),
},
);
// A frame Cesium skips drawing in request-render mode: no preRender.
const idleFrame = () => {
for (const handler of postUpdate) handler();
};
const frame = (height = camera.height) => {
camera.height = height;
idleFrame();
for (const handler of preRender) handler();
};
const movement = (type, deltaY, { modifier, tiltDeltaY = 0 } = {}) => {
const distance = {
startPosition: new Cesium.Cartesian2(0, 0),
endPosition: new Cesium.Cartesian2(0, deltaY),
};
movements.set(
key(type, modifier),
type === PINCH
? {
distance,
angleAndHeight: {
startPosition: new Cesium.Cartesian2(0, 0),
endPosition: new Cesium.Cartesian2(0, tiltDeltaY),
},
}
: distance,
);
// Also exercise the former raw DOM path when running against the PR head.
const event = new Event(
type === WHEEL ? 'wheel' : type === PINCH ? 'touchmove' : 'pointermove',
);
Object.assign(event, {
buttons: type === RIGHT_DRAG ? 2 : type === MIDDLE_DRAG ? 4 : 1,
ctrlKey: modifier === CTRL,
shiftKey: modifier === SHIFT,
altKey: modifier === ALT,
touches: type === PINCH ? [1, 2] : [],
});
container.dispatchEvent(event);
};
return {
camera,
controller,
scene,
aggregator,
preRender,
postUpdate,
handoffs,
handoff: () => {
for (const listener of handoffs) listener();
},
renders,
dispose,
frame,
idleFrame,
movement,
};
}
test('a tilted wheel zoom-out pitches down and preserves heading and roll', () => {
const f = guardFixture();
f.frame();
f.movement(WHEEL, -10);
f.frame(1_000_000);
const target = maxPitchForHeight(1_000_000);
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25));
assert.ok(f.camera.pitch > target, 'eases rather than snapping');
assert.equal(f.camera.heading, 0.4);
assert.equal(f.camera.roll, 0);
for (let i = 1; i <= 40; i++) f.frame(1_000_000 + i);
assert.ok(
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-3,
);
assert.ok(f.renders.length > 0);
});
test('a large outward step immediately keeps the Earth limb in view', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -100);
f.frame(20_000_000);
const limbPitch = -Math.acos(6_371_000 / (6_371_000 + 20_000_000));
assert.ok(f.camera.pitch <= limbPitch);
assert.ok(f.camera.pitch > maxPitchForHeight(20_000_000));
});
test('pinch, right-drag and configured Ctrl+wheel can zoom out', () => {
for (const [type, modifier] of [[PINCH], [RIGHT_DRAG], [WHEEL, CTRL]]) {
const f = guardFixture();
f.frame(500_000);
f.movement(type, -10, { modifier });
f.frame(700_000);
assert.equal(f.camera.views.length, 1, `${type}:${modifier}`);
f.dispose();
}
});
test('Ctrl+right-drag tilt is left at the pose chosen by Cesium', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(RIGHT_DRAG, 3, { modifier: CTRL });
// Real Cesium's orbit tilt raises altitude with no zoom movement.
f.camera.pitch = Cesium.Math.toRadians(-26.07722459498313);
f.frame(529_392.3759876974);
assert.equal(f.camera.views.length, 0);
assert.equal(deg(f.camera.pitch), -26.07722459498313);
});
test('parallel two-finger tilt is not mistaken for pinch zoom', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(PINCH, 0, { tiltDeltaY: 3 });
f.frame(600_000);
assert.equal(f.camera.views.length, 0);
});
test('two-finger tilt with finger-spacing drift is left to Cesium', () => {
for (const [spacing, tilt] of [
[-0.01, 3],
[-2, 3],
[-3, -3],
]) {
const f = guardFixture();
f.frame(500_000);
f.movement(PINCH, spacing, { tiltDeltaY: tilt });
// Real Cesium's two-finger tilt climbs and picks its own pitch.
f.camera.pitch = Cesium.Math.toRadians(-26.07746);
f.frame(529_406);
assert.equal(f.camera.views.length, 0, `${spacing}, ${tilt}`);
f.dispose();
}
});
test('a pinch that closes the fingers more than it moves them still zooms out', () => {
// One finger still, the other sliding towards it: the midpoint moves half as far.
const f = guardFixture();
f.frame(500_000);
f.movement(PINCH, -10, { tiltDeltaY: -5 });
f.frame(700_000);
assert.equal(f.camera.views.length, 1);
f.dispose();
});
test('unconfigured wheel modifiers and one-finger drag do not enable the guard', () => {
for (const [type, modifier] of [[WHEEL, SHIFT], [WHEEL, ALT], [LEFT_DRAG]]) {
const f = guardFixture();
f.frame(500_000);
f.movement(type, -10, { modifier });
f.frame(600_000);
assert.equal(f.camera.views.length, 0, `${type}:${modifier}`);
f.dispose();
}
});
test('current controller zoom bindings are honored', () => {
const f = guardFixture();
f.frame(500_000);
f.controller.zoomEventTypes = { eventType: MIDDLE_DRAG, modifier: ALT };
f.movement(WHEEL, -10);
f.frame(600_000);
assert.equal(f.camera.views.length, 0);
f.movement(MIDDLE_DRAG, -10, { modifier: ALT });
f.frame(700_000);
assert.equal(f.camera.views.length, 1);
});
test('programmatic height changes alone never activate the guard', () => {
const f = guardFixture();
f.frame(170_000);
f.frame(2_000_000);
f.frame(3_000_000);
assert.equal(f.camera.views.length, 0);
});
test('inward zoom does not activate the guard even when altitude rises', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, 10);
f.frame(600_000);
assert.equal(f.camera.views.length, 0);
});
test('outward input with falling height does not alter pitch', () => {
const f = guardFixture();
f.frame(2_000_000);
f.movement(WHEEL, -10);
f.frame(1_500_000);
f.frame(1_500_000);
assert.equal(f.camera.views.length, 0);
});
test('pitch finishes settling when the zoom stops changing height', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(2_000_000);
const firstPitch = f.camera.pitch;
for (let i = 0; i < 40; i++) f.frame();
assert.ok(f.camera.pitch < firstPitch);
assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10);
const views = f.camera.views.length;
f.frame();
assert.equal(
f.camera.views.length,
views,
'settled pose does not keep rendering',
);
});
test('completed settling does not authorize a later programmatic climb', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
for (let i = 0; i < 40; i++) f.frame();
const views = f.camera.views.length;
f.frame(3_000_000);
assert.equal(f.camera.views.length, views);
});
test('a fresh inward zoom or unrelated gesture cancels pending correction', () => {
for (const [type, delta, options] of [
[WHEEL, 10, {}],
[RIGHT_DRAG, 3, { modifier: CTRL }],
[LEFT_DRAG, 3, {}],
[MIDDLE_DRAG, 3, {}],
[PINCH, 0, { tiltDeltaY: 3 }],
]) {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
const views = f.camera.views.length;
f.movement(type, delta, options);
f.frame(800_000);
f.frame(900_000);
assert.equal(f.camera.views.length, views, `${type}:${options.modifier}`);
f.dispose();
}
});
test('an external pose change at fixed height cancels pending correction', () => {
for (const field of ['heading', 'pitch', 'roll', 'longitude', 'latitude']) {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
const views = f.camera.views.length;
f.camera[field] += 0.1;
f.frame();
f.frame(900_000);
assert.equal(f.camera.views.length, views, field);
f.dispose();
}
});
test('disabled controls, flights, tracked transforms and non-3D scenes cancel intent', () => {
const cases = [
(f) => {
f.controller.enableInputs = false;
return () => {
f.controller.enableInputs = true;
};
},
(f) => {
f.controller.enableZoom = false;
return () => {
f.controller.enableZoom = true;
};
},
(f) => {
f.camera._currentFlight = {};
return () => {
delete f.camera._currentFlight;
};
},
(f) => {
f.camera.transform = Cesium.Matrix4.fromTranslation(
new Cesium.Cartesian3(1, 2, 3),
);
return () => {
f.camera.transform = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY);
};
},
(f) => {
f.scene.mode = Cesium.SceneMode.SCENE2D;
return () => {
f.scene.mode = Cesium.SceneMode.SCENE3D;
};
},
];
for (const block of cases) {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(700_000);
const views = f.camera.views.length;
const restore = block(f);
f.movement(WHEEL, -10);
f.frame(800_000);
restore();
f.frame(900_000);
assert.equal(f.camera.views.length, views);
f.movement(WHEEL, -10);
f.frame(1_000_000);
assert.equal(f.camera.views.length, views + 1);
f.dispose();
}
});
test('installer sets the ceiling and disposer releases aggregator and frame listener', () => {
const f = guardFixture();
assert.equal(f.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
assert.equal(f.preRender.size, 1);
assert.equal(f.postUpdate.size, 1);
f.frame();
assert.ok(f.aggregator.resets > 0);
f.dispose();
f.dispose();
assert.equal(f.controller.maximumZoomDistance, Infinity);
assert.equal(f.aggregator.destroys, 1);
assert.equal(f.preRender.size, 0);
assert.equal(f.postUpdate.size, 0);
assert.equal(f.handoffs.size, 0);
});
test('installer preserves a stricter host zoom ceiling', () => {
const f = guardFixture({ maximumZoomDistance: 123 });
assert.equal(f.controller.maximumZoomDistance, 123);
f.dispose();
assert.equal(f.controller.maximumZoomDistance, 123);
});
test('disposer keeps a ceiling someone else changed later', () => {
const f = guardFixture();
f.controller.maximumZoomDistance = 5;
f.dispose();
assert.equal(f.controller.maximumZoomDistance, 5);
});
test('installer rejects an incomplete viewer', () => {
assert.throws(() => installZoomOutGuard({}), TypeError);
});
test('outward input at the ceiling reframes a deliberately tilted camera', () => {
const f = guardFixture();
f.frame(MAXIMUM_ZOOM_DISTANCE_M);
f.movement(WHEEL, -10);
f.frame();
for (let i = 0; i < 40; i++) f.frame();
assert.ok(
Math.abs(f.camera.pitch - maxPitchForHeight(MAXIMUM_ZOOM_DISTANCE_M)) <
1e-10,
);
f.dispose();
});
test('input that leaves the camera still requests a frame to read it', () => {
const f = guardFixture();
f.frame(MAXIMUM_ZOOM_DISTANCE_M);
f.idleFrame();
assert.deepEqual(f.renders, [], 'an idle scene stays idle');
// At the ceiling Cesium doesn't move the camera, so nothing would draw.
f.movement(WHEEL, -10);
f.idleFrame();
assert.deepEqual(f.renders, ['zoom-out-guard']);
f.frame();
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25), 'read that frame');
f.dispose();
});
test('equivalent heading and roll wraps do not cancel stationary settling', () => {
const f = guardFixture();
f.frame(500_000);
f.movement(WHEEL, -10);
f.frame(2_000_000);
f.camera.heading += Cesium.Math.TWO_PI;
f.camera.roll += Cesium.Math.TWO_PI;
for (let i = 0; i < 40; i++) f.frame();
assert.ok(Math.abs(f.camera.pitch - maxPitchForHeight(2_000_000)) < 1e-10);
f.dispose();
});
test('a real Cesium camera settles across the north heading wrap at Istanbul', () => {
const scene = {
canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 },
drawingBufferWidth: 1200,
drawingBufferHeight: 800,
mapProjection: new Cesium.GeographicProjection(),
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
preRender: new Cesium.Event(),
postUpdate: new Cesium.Event(),
screenSpaceCameraController: {
maximumZoomDistance: Infinity,
enableInputs: true,
zoomEventTypes: [WHEEL],
},
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
const setHeight = (height) =>
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, height),
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-25), roll: 0 },
});
setHeight(1_000_000);
let outward = false;
const dispose = installZoomOutGuard(
{ scene, camera, canvas: scene.canvas },
{
createAggregator: () => ({
isMoving: (type, modifier) =>
outward && type === WHEEL && modifier === undefined,
getMovement: () => ({
startPosition: { y: 0 },
endPosition: { y: -10 },
}),
reset: () => {
outward = false;
},
destroy() {},
}),
requestRender() {},
},
);
try {
setHeight(MAXIMUM_ZOOM_DISTANCE_M);
outward = true;
for (let i = 0; i < 40; i++) scene.preRender.raiseEvent(scene);
assert.ok(
Math.abs(
camera.pitch - maxPitchForHeight(camera.positionCartographic.height),
) < 1e-8,
);
} finally {
dispose();
}
});
test('an outward inertia ceiling adjustment can descend while pitch settles', () => {
const f = guardFixture();
f.frame(39_000_000);
f.movement(WHEEL, -10);
f.frame(39_999_999);
const before = f.camera.pitch;
// Cesium limits slant range on an inertial pick, slightly below the altitude cap.
f.frame(39_997_258);
for (let i = 0; i < 40; i++) f.frame();
assert.ok(f.camera.pitch < before);
assert.ok(
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-10,
);
f.dispose();
});
test('a descending programmatic tilt cancels pending correction', () => {
const f = guardFixture();
f.frame(39_000_000);
f.movement(WHEEL, -10);
f.frame(39_999_999);
const count = f.camera.views.length;
f.camera.pitch += 0.1;
f.frame(39_997_258);
for (let i = 0; i < 40; i++) f.frame();
assert.equal(f.camera.views.length, count);
f.dispose();
});
test('explicit navigation cancels outward inertia before a climbing UI tilt', () => {
const f = guardFixture();
f.movement(WHEEL, -10);
f.frame(1_000_000);
f.movement(WHEEL, -10);
f.handoff();
const writes = f.camera.views.length;
f.camera.pitch = Cesium.Math.toRadians(-25);
f.frame(1_100_000);
f.frame(1_200_000);
assert.equal(f.camera.views.length, writes);
assert.equal(deg(f.camera.pitch), -25);
// A subsequent user zoom starts a new correction normally.
f.movement(WHEEL, -10);
f.frame(1_300_000);
assert.ok(f.camera.views.length > writes);
f.dispose();
});
// A real Cesium camera and controller, fed by DOM wheel events on the canvas.
function realControllerScene(startHeight) {
const canvas = Object.assign(new EventTarget(), {
clientWidth: 1200,
clientHeight: 800,
width: 1200,
height: 800,
style: {},
// Keep Cesium's input handler off the missing `document`.
disableRootEvents: true,
onwheel: null,
getBoundingClientRect: () => ({
left: 0,
top: 0,
width: 1200,
height: 800,
}),
});
const scene = {
canvas,
drawingBufferWidth: 1200,
drawingBufferHeight: 800,
mapProjection: new Cesium.GeographicProjection(),
ellipsoid: Cesium.Ellipsoid.WGS84,
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
verticalExaggeration: 1,
verticalExaggerationRelativeHeight: 0,
preRender: new Cesium.Event(),
postUpdate: new Cesium.Event(),
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
const controller = new Cesium.ScreenSpaceCameraController(scene);
controller.inertiaZoom = 0;
scene.screenSpaceCameraController = controller;
const setHeight = (h) =>
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(28.9784, 41.0082, h),
orientation: { heading: 0, pitch: -Cesium.Math.PI_OVER_TWO, roll: 0 },
});
setHeight(startHeight);
const dispose = installZoomOutGuard(
{ scene, camera, canvas },
{ requestRender() {} },
);
// Scene.render order: the controller applies input, then postUpdate, then
// preRender on frames that draw.
const frame = () => {
controller.update();
scene.postUpdate.raiseEvent(scene);
scene.preRender.raiseEvent(scene);
};
const wheel = (deltaY) =>
canvas.dispatchEvent(
Object.assign(new Event('wheel'), {
deltaY,
deltaMode: 0,
clientX: 600,
clientY: 400,
}),
);
const height = () => camera.positionCartographic.height;
return {
scene,
camera,
controller,
setHeight,
frame,
wheel,
height,
dispose,
};
}
test('a camera placed above the ceiling cannot zoom out and zooms in without a jump', () => {
const s = realControllerScene(60_000_000);
try {
s.frame();
s.wheel(1);
s.frame();
assert.ok(Math.abs(s.height() - 60_000_000) < 1, `${s.height()} m`);
s.wheel(-1);
s.frame();
const firstStep = s.height();
assert.ok(firstStep < 60_000_000, 'zooms in');
assert.ok(firstStep > 50_000_000, `${firstStep} m is gradual`);
// The camera can no longer climb back above where it has descended to.
s.wheel(1);
s.frame();
assert.ok(Math.abs(s.height() - firstStep) < 1, `${s.height()} m`);
for (let i = 0; i < 40; i++) {
s.wheel(-100);
s.frame();
}
assert.ok(s.height() < MAXIMUM_ZOOM_DISTANCE_M);
assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
for (let i = 0; i < 80; i++) {
s.wheel(100);
s.frame();
}
assert.ok(Math.abs(s.height() - MAXIMUM_ZOOM_DISTANCE_M) < 1_000);
} finally {
s.dispose();
}
assert.equal(s.controller.maximumZoomDistance, Infinity);
});
test('a share-link view restored above the ceiling is honoured by later frames', () => {
const s = realControllerScene(1_000_000);
try {
s.frame();
s.setHeight(97_679_259);
s.frame();
s.wheel(100);
s.frame();
assert.ok(Math.abs(s.height() - 97_679_259) < 1, `${s.height()} m`);
} finally {
s.dispose();
}
});
test('a tracked camera cannot climb past the ceiling from its target', () => {
const s = realControllerScene(1_000_000);
try {
s.frame();
// In look-at mode Cesium measures zoom from the target, not the Earth.
s.camera.lookAt(
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
new Cesium.HeadingPitchRange(0, -0.5, 5_000_000),
);
for (let i = 0; i < 20; i++) {
s.wheel(100);
s.frame();
}
const range = Cesium.Cartesian3.magnitude(s.camera.position);
assert.ok(range <= MAXIMUM_ZOOM_DISTANCE_M + 1_000, `${range} m`);
assert.equal(s.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
} finally {
s.dispose();
}
});
+172
View File
@@ -0,0 +1,172 @@
/**
* @module awarenessEcho
* @description The Global Context sonar echo field. The awareness layer
* publishes it while its range pulses run; the detection overlay reads it to
* pulse the brackets of contacts a pulse front has just crossed. It holds no
* awareness policy of its own: the publisher passes radius, pulse timing and
* echo styling in, so the overlay never imports awareness internals.
*/
import * as Cesium from 'cesium';
/**
* @typedef {object} AwarenessEchoField
* @property {Cesium.Cartesian3} center Subject's ground point. The publisher
* may move it in place; it is read on every query.
* @property {number} radiusM Range the pulses grow to.
* @property {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse
* @property {{fadeMs: number, layerIds: string[]}} echo
* @property {(nowMs: number) => number} [level] How far the pulses are
* switched on, 0 to 1; echoes scale with it, so they rest with the pulses.
*/
/** @type {AwarenessEchoField|null} */
let field = null;
let layerIds = new Set();
const groundScratch = new Cesium.Cartesian3();
/**
* Start (or replace) the echo field.
* @param {AwarenessEchoField} next
* @returns {void}
*/
export function setAwarenessEchoField(next) {
field = next;
layerIds = new Set(next.echo.layerIds);
}
/** Stop all echoes. Safe when none were set. */
export function clearAwarenessEchoField() {
field = null;
layerIds = new Set();
}
/** @returns {AwarenessEchoField|null} The field the overlay styles from. */
export function getAwarenessEchoField() {
return field;
}
/**
* Whether any echo can light at `nowMs`: a field is set and its pulses are
* running. The overlay checks this once per frame, so resting pulses cost no
* per-contact work.
* @param {number} nowMs Monotonic clock in milliseconds.
* @returns {boolean}
*/
export function awarenessEchoActive(nowMs) {
return Boolean(field) && (field.level?.(nowMs) ?? 1) > 0;
}
/**
* How lit a contact's echo is at `nowMs`: 1 the moment a pulse front reaches
* its distance, fading as (1 - elapsed / `fadeMs`) squared, 0 otherwise. The
* strongest pulse wins. A front that has wrapped back to the centre still
* counts for the contact it last crossed, so rim contacts fade out instead of
* cutting off. Nothing nearer than the birth ring `startFraction` out echoes,
* and within the pulse's `fadeInFraction` the echo is scaled down with it.
* @param {number} nowMs Monotonic clock in milliseconds.
* @param {number} distanceM Contact's distance from the subject.
* @param {number} radiusM Range the pulses grow to.
* @param {{count: number, periodMs: number, startFraction: number, fadeInFraction?: number}} pulse
* @param {{fadeMs: number}} echo
* @returns {number} 0 to 1.
*/
export function awarenessEchoStrength(nowMs, distanceM, radiusM, pulse, echo) {
// Pulses are born `startFraction` out, so nothing nearer (the subject
// itself, its wingmen) is ever crossed; without this it lit on every birth.
if (
!Number.isFinite(distanceM) ||
distanceM < radiusM * pulse.startFraction ||
distanceM > radiusM
)
return 0;
// The phase at which a pulse front sits at this distance.
const touchPhase =
(distanceM / radiusM - pulse.startFraction) / (1 - pulse.startFraction);
let strength = 0;
for (let index = 0; index < pulse.count; index++) {
const cycle = nowMs / pulse.periodMs + index / pulse.count;
let since = cycle - Math.floor(cycle) - touchPhase;
// Rounding at the exact crossing must not read as a whole period ago.
if (since < -1e-9) since += 1;
else if (since < 0) since = 0;
const elapsedMs = since * pulse.periodMs;
if (elapsedMs < echo.fadeMs)
strength = Math.max(strength, (1 - elapsedMs / echo.fadeMs) ** 2);
}
// A front still fading in over `fadeInFraction` of its travel echoes no
// brighter than it shows.
const fadeIn = pulse.fadeInFraction ?? 0;
return fadeIn > 0 ? strength * Math.min(1, touchPhase / fadeIn) : strength;
}
/**
* The echo for one contact of `layerId` at world `position`, or 0 when no
* field is set or the layer does not echo. Distance is measured between
* ground points, so an aircraft's altitude does not shift its echo.
* @param {string} layerId Owning layer of the contact.
* @param {Cesium.Cartesian3} position Contact's world position.
* @param {number} nowMs Monotonic clock in milliseconds.
* @returns {number} 0 to 1.
*/
export function awarenessEchoAt(layerId, position, nowMs) {
if (!field || !position || !layerIds.has(layerId)) return 0;
const level = field.level?.(nowMs) ?? 1;
if (!(level > 0)) return 0;
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(
position,
groundScratch,
);
if (!ground) return 0;
return (
level *
awarenessEchoStrength(
nowMs,
Cesium.Cartesian3.distance(field.center, ground),
field.radiusM,
field.pulse,
field.echo,
)
);
}
/**
* The echo bracket the detection overlay draws around one contact, or null
* when it has no echo. `band` is the alpha band, 1 to `steps`, the echo's
* strength times the contact bracket's own `bracketAlpha` rounded to the
* nearest band, so an echo is never drawn brighter than its bracket; one
* that rounds to nothing is not drawn. `spreadPx` is how far the echo sits
* outside the contact's bracket, 0 as the front arrives, growing to the
* field's `spreadPx` as it fades.
* @param {string} layerId Owning layer of the contact.
* @param {Cesium.Cartesian3} position Contact's world position.
* @param {number} nowMs Monotonic clock in milliseconds.
* @param {number} bracketAlpha The contact bracket's drawn alpha, 0 to 1.
* @param {number} steps Alpha bands the overlay batches by.
* @returns {{band: number, spreadPx: number}|null}
*/
export function awarenessEchoBracket(
layerId,
position,
nowMs,
bracketAlpha,
steps,
) {
const echo = awarenessEchoAt(layerId, position, nowMs);
if (!(echo > 0.01)) return null;
const band = Math.min(steps, Math.round(echo * bracketAlpha * steps));
if (!(band >= 1)) return null;
return { band, spreadPx: field.echo.spreadPx * (1 - echo) };
}
/**
* The echo's stroke colour: the field's own colour on the default detection
* style, and the style's line colour under the others (NVG, FLIR, CRT), so
* an echo never stands out of a monochrome palette.
* @param {string} styleLine The active detection style's line colour.
* @param {boolean} defaultStyle Whether the default detection style is active.
* @returns {string|null} Null when no field is set.
*/
export function awarenessEchoColor(styleLine, defaultStyle) {
if (!field) return null;
return defaultStyle ? field.echo.color : styleLine;
}
+141
View File
@@ -0,0 +1,141 @@
// src/awarenessEcho.test.mjs — the Global Context echo field: when a sonar
// pulse front crosses a flight or vessel, its bracket echo lights and fades.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
awarenessEchoActive,
awarenessEchoAt,
awarenessEchoStrength,
clearAwarenessEchoField,
getAwarenessEchoField,
setAwarenessEchoField,
} from './awarenessEcho.js';
const R = 250_000;
const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 };
const echo = { fadeMs: 1400, layerIds: ['flights', 'ais-live-vessels'] };
const strength = (nowMs, distanceM) =>
awarenessEchoStrength(nowMs, distanceM, R, pulse, echo);
/** The moment pulse 0's front sits at `distanceM`, in a later cycle. */
const arrival = (distanceM) =>
((distanceM / R - pulse.startFraction) / (1 - pulse.startFraction)) *
pulse.periodMs +
pulse.periodMs * 4;
test('an echo lights as a pulse front reaches the contact, then fades', () => {
const d = 100_000;
const t0 = arrival(d);
assert.ok(Math.abs(strength(t0, d) - 1) < 1e-6);
assert.ok(Math.abs(strength(t0 + echo.fadeMs / 2, d) - 0.25) < 1e-6);
assert.equal(strength(t0 + echo.fadeMs + 1, d), 0);
assert.equal(strength(t0 - 50, d), 0, 'dark just before the front');
// Every pulse lights it once a period: `count` separate flashes in any
// one-period window that starts after a flash has faded.
let flashes = 0;
let wasLit = false;
const from = t0 + echo.fadeMs + 1;
for (let ms = 0; ms < pulse.periodMs; ms += 10) {
const lit = strength(from + ms, d) > 0.5;
if (lit && !wasLit) flashes++;
wasLit = lit;
}
assert.equal(flashes, pulse.count);
});
test('rim contacts fade out after the front wraps; outside the range never', () => {
const rim = R * 0.999;
assert.ok(strength(arrival(rim) + echo.fadeMs / 2, rim) > 0.2);
// Inside the ring pulses are born on, nothing is ever crossed: the
// subject and anything within 5 km never echo, at any moment.
const inner = R * pulse.startFraction;
for (let ms = 0; ms < pulse.periodMs; ms += 10) {
assert.equal(strength(ms, 0), 0, 'the subject itself');
assert.equal(strength(ms, inner - 1), 0);
}
assert.ok(strength(pulse.periodMs * 5, inner) > 0.99, 'the birth ring');
for (let ms = 0; ms < pulse.periodMs; ms += 50)
assert.equal(strength(ms, R + 1), 0);
assert.equal(strength(0, Number.NaN), 0);
assert.equal(strength(0, -1), 0);
});
test('awarenessEchoAt reads the published field, by layer, at ground level', (t) => {
t.after(clearAwarenessEchoField);
const contact = Cesium.Cartesian3.fromDegrees(3.5, 27, 0);
assert.equal(awarenessEchoAt('flights', contact, 0), 0, 'no field, no echo');
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
setAwarenessEchoField({ center, radiusM: R, pulse, echo });
assert.ok(getAwarenessEchoField());
const d = Cesium.Cartesian3.distance(center, contact);
const t0 = arrival(d);
assert.ok(awarenessEchoAt('flights', contact, t0) > 0.99);
assert.ok(awarenessEchoAt('ais-live-vessels', contact, t0) > 0.99);
// Layers outside the echo list never light.
assert.equal(awarenessEchoAt('cctv', contact, t0), 0);
assert.equal(awarenessEchoAt('military-installations', contact, t0), 0);
// An aircraft 11 km up echoes with the ground point under it.
const aloft = Cesium.Cartesian3.fromDegrees(3.5, 27, 11_000);
assert.ok(
Math.abs(
awarenessEchoAt('flights', aloft, t0 + 300) -
awarenessEchoAt('flights', contact, t0 + 300),
) < 0.01,
);
// The field's centre is read in place, so a moved subject moves the echo:
// about 50 km farther, the contact lights at its new distance's moment.
Cesium.Cartesian3.clone(Cesium.Cartesian3.fromDegrees(3.5, 26.1, 0), center);
const moved = Cesium.Cartesian3.distance(center, contact);
assert.ok(Math.abs(moved - d) > 1000);
assert.ok(awarenessEchoAt('flights', contact, arrival(moved)) > 0.99);
assert.equal(awarenessEchoAt('flights', null, t0), 0);
clearAwarenessEchoField();
assert.equal(getAwarenessEchoField(), null);
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
});
test('echoes scale with the field level and rest with the pulses', (t) => {
t.after(clearAwarenessEchoField);
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
// ~100 km east of the subject, on the ground.
const contact = Cesium.Cartesian3.fromDegrees(4.008, 27, 0);
let level = 1;
setAwarenessEchoField({
center,
radiusM: R,
pulse,
echo,
level: () => level,
});
const d = Cesium.Cartesian3.distance(center, contact);
const t0 = arrival(d);
const full = awarenessEchoAt('flights', contact, t0);
assert.ok(full > 0.99);
level = 0.5;
assert.ok(
Math.abs(awarenessEchoAt('flights', contact, t0) - full / 2) < 1e-9,
);
assert.equal(awarenessEchoActive(t0), true);
level = 0;
assert.equal(awarenessEchoAt('flights', contact, t0), 0);
// At rest the overlay skips echoes for the whole frame.
assert.equal(awarenessEchoActive(t0), false);
clearAwarenessEchoField();
assert.equal(awarenessEchoActive(t0), false);
});
test('near the subject an echo is no brighter than the fading-in front', () => {
const fading = { ...pulse, fadeInFraction: 0.08 };
const at = (distanceM) =>
awarenessEchoStrength(arrival(distanceM), distanceM, R, fading, echo);
// Born at 5 km, the front reaches full strength 8% of the way out.
const full = R * (fading.startFraction + 0.08 * (1 - fading.startFraction));
assert.equal(at(R * fading.startFraction), 0);
const halfway = (R * fading.startFraction + full) / 2;
assert.ok(Math.abs(at(halfway) - 0.5) < 0.01, `${at(halfway)}`);
assert.ok(at(full + 1) > 0.99);
assert.ok(
at(R * 0.999) > 0.99,
'the rim, and so the ring flash, is unchanged',
);
});
+669
View File
@@ -13,8 +13,16 @@ import {
moveCamera,
} from './cameraVerbs.js';
import { getRenderGovernorDiagnostics } from './renderGovernor.js';
import {
MIN_GLOBE_FRAME_SHARE,
globeFrameShare,
globeOutOfFrame,
} from './globeInView.js';
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
import { setScopeMaskEnabled } from './scopeMask.js';
const radians = Cesium.Math.toRadians;
const degrees = Cesium.Math.toDegrees;
const clone = (value) => Cesium.Cartesian3.clone(value);
const longitude = (camera) =>
Cesium.Cartographic.fromCartesian(camera.positionWC).longitude;
@@ -1347,3 +1355,664 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer
samePose(h.camera, before);
assert.equal(hasRenderHold(), false);
});
/**
* An oracle independent of the code under test: the share of an 80×45 grid of
* Cesium pick rays across the canvas that hit the ellipsoid surface. With
* `keyhole`, only the pixels inside the scope's visible circle count.
*/
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
let hits = 0;
let counted = 0;
for (let row = 0; row < 45; row += 1) {
for (let column = 0; column < 80; column += 1) {
const x = ((column + 0.5) * width) / 80;
const y = ((row + 0.5) * height) / 45;
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
continue;
counted += 1;
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
hits += 1;
}
}
return hits / counted;
}
/** Run a test with the scope mask on or off, restoring the default after. */
function withScope(t, enabled) {
setScopeMaskEnabled(enabled);
t.after(() => setScopeMaskEnabled(true));
}
// The frame limit is measured on a coarser grid than the oracle.
const SHARE_SLACK = 0.02;
// Far-out poses, height in metres and pitch in degrees, where a few tilts up
// are accepted before the limit refuses the next one.
const FAR_OUT_POSES = [
[35_884_000, -86.26],
[35_884_000, -89],
[30_000_000, -86],
[25_000_000, -80],
[40_000_000, -88],
];
test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => {
withScope(t, false); // the whole frame is visible
// Slightly off nadir: from straight down a tilt is clamped before it moves.
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
const results = [];
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
results.push(result);
if (result.ok) finishOnce();
assert.ok(
surfacePixelShare(camera, 1600, 900) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`after tilt ${i + 1}`,
);
}
// From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down
// to about -67°: the first tilt stops there, and later ones are refused.
assert.deepEqual(
results.map((result) => result.ok),
[true, false, false, false],
);
assert.match(results[1].error, /globe out of view/);
assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66));
});
// The frame limit judges a tilt by the way its pitch moves, not by the
// direction word: a tilt down or to a steeper angle turns toward the planet.
for (const args of [{ direction: 'down' }, { pitchDeg: -80 }]) {
test(`a tilt ${JSON.stringify(args)} from the frame limit is judged as the look down it is`, (t) => {
withScope(t, false);
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
finishOnce();
const atLimit = camera.pitch;
const result = moveCamera({ motion: 'tilt', ...args });
assert.equal(result.ok, true, result.error);
finishOnce();
assert.ok(camera.pitch < atLimit - radians(5), 'the tilt turned down');
});
}
for (const args of [{ pitchDeg: -10 }, {}]) {
test(`a tilt ${JSON.stringify(args)} up from the frame limit is refused, not reported as done`, (t) => {
withScope(t, false);
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
finishOnce();
const atLimit = camera.pitch;
// A bare tilt means -45°: up from the -67° limit.
const result = moveCamera({ motion: 'tilt', ...args });
assert.equal(result.ok, false);
assert.match(result.error, /globe out of view/);
assert.equal(camera.pitch, atLimit);
});
}
// A tilt to an angle, or a bare tilt (-45°), from orbit turns in place like
// a nudge and must keep the planet in frame the whole way, or the globe
// recovery would snap the view back down.
for (const [label, args, options] of [
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
[
'a continuous tilt up',
{ motion: 'tilt', direction: 'up', mode: 'continuous' },
{ pitch: -90 },
],
[
'a tilt to -5° from 2,000 km',
{ motion: 'tilt', pitchDeg: -5 },
{ pitch: -90, height: 2_000_000 },
],
]) {
test(`${label} from top-down never turns the planet out of frame`, (t) => {
withScope(t, false);
const { camera, tick } = harness(t, {
...options,
viewportWidth: 1600,
viewportHeight: 900,
});
const startPitch = camera.pitch;
const result = moveCamera(args);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
tick();
assert.ok(
surfacePixelShare(camera, 1600, 900) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`frame ${i}: the planet left the frame`,
);
}
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
assert.equal(globeOutOfFrame(camera), false, 'no snap-back');
if (result.ok) assert.ok(camera.pitch > startPitch, 'the tilt moved');
});
}
// The frame limit stops a tilt to an angle short of it: the reply names the
// angle the camera reaches, not the one asked for (a tilt to -10° from
// top-down reported -10° and ended at -66.5°).
for (const [label, args, options] of [
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
[
'a tilt to -10° from -80°',
{ motion: 'tilt', pitchDeg: -10 },
{ pitch: -80 },
],
[
'a tilt to -5° from 2,000 km',
{ motion: 'tilt', pitchDeg: -5 },
{ pitch: -90, height: 2_000_000 },
],
// A rolled camera's look turns its pitch by less than the look angle:
// reported -25°, ended at -27.3°.
[
'a tilt to -5° from 2,000 km rolled 30°',
{ motion: 'tilt', pitchDeg: -5 },
{ pitch: -70, roll: 30, height: 2_000_000 },
],
]) {
test(`${label} from orbit reports the angle it reaches`, (t) => {
withScope(t, false);
const { camera, tick } = harness(t, {
...options,
viewportWidth: 1600,
viewportHeight: 900,
});
const result = moveCamera(args);
assert.equal(result.ok, true, result.error);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
const reached = degrees(camera.pitch);
assert.ok(
Math.abs(reached - result.pitchDeg) < 1,
`reported ${result.pitchDeg}°, reached ${reached}°`,
);
const asked = args.pitchDeg ?? -45;
if (Math.abs(reached - asked) >= 1)
assert.match(result.note ?? '', /globe out of view/);
else assert.equal(result.note, undefined);
});
}
// A continuous tilt promises no angle: it runs until stopped or the frame
// limit ends it.
test('a continuous tilt up from orbit claims no angle it may not reach', (t) => {
withScope(t, false);
const { tick } = harness(t, {
pitch: -90,
viewportWidth: 1600,
viewportHeight: 900,
});
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'continuous',
});
assert.equal(result.ok, true, result.error);
assert.equal('pitchDeg' in result, false);
assert.equal(result.mode, 'continuous');
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'the frame limit ends it');
});
test('voice turn in place from orbit stops with the planet still on screen', (t) => {
withScope(t, false); // the whole frame is visible
const { camera, tick } = harness(t, { pitch: -75 });
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
true,
);
const heading = camera.heading;
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
tick();
assert.ok(
surfacePixelShare(camera, 1280, 800) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`tick ${i + 1}`,
);
}
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.notEqual(camera.heading, heading, 'the turn moved before stopping');
});
test('a long voice turn below the limit keeps the share it started with', (t) => {
withScope(t, false); // the whole frame is visible
// About 2% of a 16:9 frame on the planet: the limit is that share, less two
// grid cells of sampling noise, for the whole motion rather than per step.
const { camera, tick } = harness(t, {
pitch: -60,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
true,
);
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
tick();
assert.ok(
globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9,
`tick ${i + 1}`,
);
}
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('repeated voice turns from one spot cannot each take a little more', (t) => {
withScope(t, false); // the whole frame is visible
// About 2% of a 16:9 frame on the planet. Every turn from this position is
// measured against the same baseline, so asking again stops being allowed.
const { camera, finishOnce, tick } = harness(t, {
pitch: -60,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
const floor = start - 2 / (32 * 18) - 1e-9;
const results = [];
for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) {
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({ motion: 'rotate', direction: 'left', mode });
results.push(result.ok);
if (!result.ok) continue;
if (mode === 'once') finishOnce();
else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`);
}
}
assert.ok(results.includes(false), 'a turn at the limit is refused');
assert.equal(results.at(-1), false);
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('a voice turn after something else turned the camera starts over', (t) => {
withScope(t, false); // the whole frame is visible
// The same spot, first framed straight down, then turned to about 2% of the
// frame by something other than voice: a turn left is measured from there.
const { camera, finishOnce } = harness(t, {
pitch: -90,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
true,
);
finishOnce();
camera.setView({
orientation: { heading: 0, pitch: radians(-60), roll: 0 },
});
const start = globeFrameShare(camera);
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
true,
);
finishOnce();
assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9);
});
test('nudging the camera between voice turns cannot take the planet below the 1% floor', (t) => {
withScope(t, false); // the whole frame is visible
// A wheel notch or an orbit drag between requests starts each turn afresh;
// the 1% floor still holds, and below it a turn may only keep what it has.
for (const [nudge, pitch] of [
[(camera) => camera.moveBackward(2), -60],
[(camera) => camera.rotateRight(radians(0.2)), -60],
[(camera) => camera.moveBackward(2), -58],
]) {
const { camera, tick } = harness(t, {
pitch,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
const floor = Math.min(0.01, start) - 1e-9;
for (let i = 0; i < 12; i += 1) {
nudge(camera);
if (
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
.ok
)
for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
assert.ok(
globeFrameShare(camera) >= floor,
`start ${start}, turn ${i + 1}`,
);
}
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
}
});
test('after the frame changes size, a voice turn toward the planet is allowed', (t) => {
withScope(t, false); // the whole frame is visible
// A tilt stops at its limit; then the window widens at the same pose, so the
// frame shows less than the carried limit. Toward the planet must still go.
const { camera, canvas, viewer, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
true,
);
finishOnce();
canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200;
camera.frustum.aspectRatio = 3200 / 900;
const narrowed = globeFrameShare(camera);
assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`);
const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' });
assert.equal(down.ok, true, down.error);
finishOnce();
assert.ok(globeFrameShare(camera) > narrowed);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok,
true,
);
finishOnce();
const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
if (up.ok) finishOnce();
assert.ok(surfacePixelShare(camera, 3200, 900) > 0);
});
test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => {
withScope(t, true);
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
const results = [];
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
results.push(result.ok);
if (result.ok) finishOnce();
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`after tilt ${i + 1}`,
);
}
assert.equal(results[0], true);
assert.equal(results.at(-1), false, 'a tilt past the limit is refused');
});
test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => {
// An oblique orbit view: turning in place swings the planet toward a corner,
// where the full frame still shows it but the scope mask hides it.
for (const [height, pitch] of [
[5_000_000, -70],
[3_000_000, -75],
[18_000_000, -80],
]) {
withScope(t, true);
const { camera, tick } = harness(t, {
height,
pitch,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
.ok,
true,
);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`${height / 1000} km at ${pitch}°`,
);
}
});
test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => {
// A small planet disc: its sampled share wobbles as it crosses the grid.
// Whatever the limit decides, "done" must mean the camera turned, and some
// of these tilts must be accepted for that to be checked at all.
let accepted = 0;
for (const [height, pitch] of FAR_OUT_POSES) {
withScope(t, true);
const { camera, tick } = harness(t, {
latitude: 45.37,
longitudeDeg: -33.33,
height,
heading: 89.9,
pitch,
viewportWidth: 1280,
viewportHeight: 1024,
});
for (let i = 0; i < 4; i += 1) {
const before = camera.pitch;
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
if (!result.ok) {
assert.match(result.error, /zoom in first/);
continue;
}
accepted += 1;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
assert.ok(
camera.pitch - before > radians(0.5) - 1e-9,
`${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`,
);
}
assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0);
}
assert.ok(accepted > 0, 'no far-out tilt was accepted');
});
test('a voice turn reported as done turns at least the half degree its check proved', (t) => {
// A grazing sliver: the first frame's small step lands in a dip of the
// sampled share, which used to stop the turn before it moved at all.
for (const scope of [true, false]) {
withScope(t, scope);
const { camera, tick } = harness(t, {
latitude: 20,
longitudeDeg: 10,
height: 18_000_000,
pitch: -58,
heading: 37,
viewportWidth: 1600,
viewportHeight: 900,
});
const before = Cesium.Cartesian3.clone(camera.directionWC);
const result = moveCamera({
motion: 'rotate',
direction: 'left',
mode: 'once',
});
if (!result.ok) {
assert.match(result.error, /globe out of view/);
continue;
}
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
assert.ok(
turned >= radians(0.5) - 1e-9,
`scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`,
);
assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0);
}
});
/** Track an entity below `camera`, locking it into that entity's frame. */
function trackFrom(viewer, camera) {
viewer.trackedEntity = { id: 'high-satellite' };
const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000);
camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below));
}
test('a voice turn that releases a tracked camera is checked as released', (t) => {
// Voice releases the follow camera inside the navigation that runs the
// motion, so the request is judged by the pose the release will leave.
let accepted = 0;
for (const [scope, direction, motion] of [
[true, 'up', 'tilt'],
[false, 'up', 'tilt'],
[false, 'left', 'rotate'],
]) {
withScope(t, scope);
const { camera, viewer, tick } = harness(t, {
latitude: 20,
longitudeDeg: 10,
height: 18_000_000,
pitch: -58,
heading: 37,
viewportWidth: 1600,
viewportHeight: 900,
});
trackFrom(viewer, camera);
const releaseThenRun = (start) => {
viewer.trackedEntity = undefined;
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
return start();
};
const before = Cesium.Cartesian3.clone(camera.directionWC);
const result = moveCamera(
{ motion, direction, mode: 'once' },
releaseThenRun,
);
const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`;
if (!result.ok) {
assert.match(result.error, /globe out of view/, label);
continue;
}
accepted += 1;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`);
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0,
`${label}: the planet stays on screen`,
);
}
assert.ok(accepted > 0, 'no released turn was accepted');
});
test('a refused voice turn leaves a tracked camera tracking', (t) => {
for (const scope of [true, false]) {
withScope(t, scope);
const { camera, viewer, tick } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
// Tilt up to the limit untracked, then track from there.
for (let i = 0; i < 40; i += 1) {
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
break;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
}
trackFrom(viewer, camera);
const tracked = viewer.trackedEntity;
const transform = Cesium.Matrix4.clone(camera.transform);
let released = false;
const result = moveCamera(
{ motion: 'tilt', direction: 'up', mode: 'once' },
(start) => {
released = true;
viewer.trackedEntity = undefined;
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
return start();
},
);
const label = `scope ${scope ? 'on' : 'off'}`;
assert.equal(result.ok, false, label);
assert.match(result.error, /globe out of view/, label);
assert.equal(released, false, label);
assert.equal(viewer.trackedEntity, tracked, label);
assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label);
}
});
test('a turn proven at one position is checked again after a release moves the camera', (t) => {
withScope(t, false);
const { camera, tick } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
// Tilt up to the limit, then check from 4,000 km closer along the same view
// direction, where the next tilt fits; the release moves the camera back.
for (let i = 0; i < 40; i += 1) {
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
break;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
}
camera.moveForward(4_000_000);
const result = moveCamera(
{ motion: 'tilt', direction: 'up', mode: 'once' },
(start) => {
camera.moveBackward(4_000_000);
return start();
},
);
assert.equal(result.ok, false);
assert.match(result.error, /globe out of view/);
});
test('the voice frame limit leaves Columbus view alone', (t) => {
// At its limit in 3D a further tilt up is refused; the same tilt in Columbus
// view is not limited by the frame.
withScope(t, false);
const { viewer, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
true,
);
finishOnce();
const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.match(refused.error || '', /globe out of view/);
viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.doesNotMatch(result.error || '', /globe out of view/);
});
test('voice tilt below the free-look height is not limited by the frame', (t) => {
const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 });
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.equal(result.ok, true);
finishOnce();
near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt');
});
+13 -3
View File
@@ -42,7 +42,7 @@ function ordered(source, needles, label) {
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
const enter = cockpitEnter.toString();
ordered(enter, [
'if (!info || !entity?.position) return false;',
'if (!info || (!vessel && !satellite && !entity?.position)) return false;',
'this.onCameraTakeover?.();',
'this.viewer.camera.cancelFlight();',
'this.viewer.trackedEntity = undefined;',
@@ -106,7 +106,8 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
);
ordered(transaction, [
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
'if (!entryError) entered = Boolean(cockpitView.enter());',
// Aircraft-only: a selected vessel must not stand in for a voice target.
"if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' }));",
], 'Cockpit entry transaction');
// No direct camera control: every mutation goes through a layer tracker or
// the cockpit controller, both of which stamp.
@@ -223,11 +224,20 @@ test('validated voice camera destinations share the UI navigation authority faca
'move camera',
);
ordered(move, [
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
'if (argsError) return argsError;',
'const start = () => {',
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
], 'move validation before handoff');
const moveArgs = body(
cameraVerbs,
/export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/,
'move camera argument checks',
);
assert.ok(
moveArgs.includes("if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))"),
'unknown motions are refused before handoff',
);
const route = body(
cameraVerbs,
+773
View File
@@ -0,0 +1,773 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
adjustOrbitRange,
getActiveCameraMotion,
initCameraVerbs,
interruptCameraMotion,
moveCamera,
} from './cameraVerbs.js';
import { createGevActionRunner } from './voice/gevActions.js';
const radians = Cesium.Math.toRadians;
const degrees = Cesium.Math.toDegrees;
/** Real Cesium camera over London with no renderer: the voice fly-to frame. */
function harness(
t,
{ height = 4000, heading = 0, pitch = -90, pivot = true } = {},
) {
const realPerformance = globalThis.performance;
let nowMs = 0;
globalThis.performance = { now: () => nowMs };
const canvas = Object.assign(new EventTarget(), {
clientWidth: 1280,
clientHeight: 800,
width: 1280,
height: 800,
});
const ellipsoid = Cesium.Ellipsoid.WGS84;
const scene = {
canvas,
drawingBufferWidth: 1280,
drawingBufferHeight: 800,
frameState: { mode: Cesium.SceneMode.SCENE3D },
mapProjection: new Cesium.GeographicProjection(ellipsoid),
globe: { ellipsoid },
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
tweens: [],
requestRender() {},
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-0.1276, 51.5072, height),
orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 },
});
const viewer = {
camera,
scene,
trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(),
clock: { onTick: new Cesium.Event() },
};
// The app's view target: the ground point under the screen centre.
const viewTarget = () => {
const ray = new Cesium.Ray(camera.positionWC, camera.directionWC);
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
};
const target = viewTarget();
initCameraVerbs(viewer, pivot ? viewTarget : () => undefined);
t.after(() => {
interruptCameraMotion('test-cleanup');
globalThis.performance = realPerformance;
});
const tick = (seconds = 1 / 30) => {
nowMs += seconds * 1000;
viewer.clock.onTick.raiseEvent(viewer.clock);
};
const finish = () => {
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
};
return { viewer, camera, target, tick, finish, viewTarget };
}
const pitchDeg = (camera) => degrees(camera.pitch);
/** Tick until done() holds; a regression fails here instead of hanging. */
const tickUntil = (h, done, frames = 600) => {
for (let i = 0; i < frames && !done(); i += 1) h.tick();
assert.ok(done(), `not reached in ${frames} frames`);
};
const headingDeg = (camera) =>
degrees(Cesium.Math.negativePiToPi(camera.heading));
/** Metres between the original centre and the ground now under the crosshair. */
function centreDrift(h) {
return Cesium.Cartesian3.distance(h.viewTarget(), h.target);
}
test('tilt up leaves a top-down view and keeps the place centred', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(result.ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -75) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
});
test('continuous tilt up from top-down keeps moving', (t) => {
const h = harness(t);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
true,
);
for (let i = 0; i < 30; i += 1) h.tick();
assert.equal(getActiveCameraMotion()?.kind, 'tilt');
assert.ok(pitchDeg(h.camera) > -82, `pitch ${pitchDeg(h.camera)}`);
});
test('a requested angle tilts to that pitch', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
assert.equal(result.ok, true);
assert.equal(result.direction, 'up');
assert.equal(result.pitchDeg, -45);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -45) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('a bare tilt settles on the default oblique angle', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt' });
assert.equal(result.ok, true);
assert.equal(result.pitchDeg, -45);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
});
test('a requested angle can tilt back down toward top-down', (t) => {
const h = harness(t, { pitch: -30 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -80 });
assert.equal(result.direction, 'down');
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
});
test('requested angles clamp to the tilt limits', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -2 }).pitchDeg, -5);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a positive angle is read as the same angle below the horizon', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 45 }).pitchDeg, -45);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
});
for (const pitch of ['', null, false, ' ', 'steep'])
test(`a blank or non-numeric angle ${JSON.stringify(pitch)} is ignored`, (t) => {
harness(t);
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
// No angle and no direction: the default oblique view, never the horizon.
assert.equal(result.pitchDeg, -45);
});
test('a requested angle the camera already holds is a no-op', (t) => {
harness(t, { pitch: -45 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(getActiveCameraMotion(), null);
});
for (const pitch of [-90, -89]) {
test(`a top-down view asked for ${pitch}° stays put`, (t) => {
// The tool description calls -89 top-down; the clamp must not tilt a
// -90° view up 1° and report it as a move up.
const h = harness(t);
const before = pitchDeg(h.camera);
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(result.pitchDeg, Math.round(before));
assert.equal(getActiveCameraMotion(), null);
assert.equal(pitchDeg(h.camera), before);
});
}
test('tilt down from top-down still refuses honestly', (t) => {
harness(t);
const result = moveCamera({ motion: 'tilt', direction: 'down' });
assert.equal(result.ok, false);
assert.match(result.error, /straight down/);
});
test('a tilt up nudge stops at the horizon limit', (t) => {
const h = harness(t, { pitch: -12 });
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('without a ground pivot a tilt still leaves top-down in place', (t) => {
const h = harness(t, { pivot: false });
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -75) < 0.5);
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-6);
});
for (const heading of [0, 30, -120])
test(`tilt from top-down keeps the ${heading}° heading`, (t) => {
const h = harness(t, { heading, height: 67000 });
// Top-down float noise must not pick a new heading for the pivot.
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.finish();
assert.ok(
Math.abs(headingDeg(h.camera) - heading) < 1,
`heading ${headingDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 50, `centre drifted ${centreDrift(h)} m`);
});
test('an oblique tilt keeps its heading toward the pivot', (t) => {
const h = harness(t, { heading: 75, pitch: -60 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -30 }).ok, true);
h.finish();
assert.ok(Math.abs(headingDeg(h.camera) - 75) < 0.5);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('a pivoting tilt yields when tracking takes the camera', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.tick();
const entity = { id: 'tracked' };
h.viewer.trackedEntity = entity;
h.viewer.trackedEntityChanged.raiseEvent(entity);
assert.equal(getActiveCameraMotion(), null);
});
/** Metres between the camera and the point it pivots about. */
const pivotRange = (h) =>
Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
const heightM = (camera) =>
Cesium.Cartographic.fromCartesian(camera.positionWC).height;
test('a zoom during a pivoting tilt changes the range it holds', (t) => {
const h = harness(t);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
true,
);
h.tick();
const before = pivotRange(h);
assert.deepEqual(
adjustOrbitRange(0.5),
{ kind: 'tilt', changed: true },
'the tilt must take the zoom',
);
for (let i = 0; i < 5; i += 1) h.tick();
// The next frame's lookAt must not put the old range back.
assert.ok(
Math.abs(pivotRange(h) - before / 2) < 1,
`range ${pivotRange(h)} m, wanted ${before / 2} m`,
);
});
test('a zoom with no pivot to hold is left to the camera', (t) => {
harness(t, { pivot: false });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
assert.equal(adjustOrbitRange(0.5), null);
});
test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => {
// A close top-down view: swinging 250 m of range to -5° would end ~22 m up.
const h = harness(t, { height: 250 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -5 }).ok, true);
let lowest = Infinity;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
lowest = Math.min(lowest, heightM(h.camera));
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(lowest > 149, `camera dropped to ${lowest} m`);
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
});
test('a continuous tilt up from close in keeps its clearance', (t) => {
const h = harness(t, { height: 250 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a city-scale pivot tilt keeps the place centred to the horizon limit', (t) => {
const h = harness(t);
moveCamera({ motion: 'tilt', pitchDeg: -5 });
h.finish();
// 4 km of range clears the floor at every pitch: all pivot, no drift.
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('pitchDeg against a contradicting direction is refused', (t) => {
harness(t, { pitch: -45 });
// "tilt down 10 degrees": -10° is an absolute angle, which is up from -45°.
const result = moveCamera({
motion: 'tilt',
direction: 'down',
pitchDeg: 10,
});
assert.equal(result.ok, false);
assert.match(result.error, /absolute angle/);
assert.equal(getActiveCameraMotion(), null);
});
test('pitchDeg with a direction that agrees still tilts', (t) => {
const h = harness(t, { pitch: -45 });
const result = moveCamera({
motion: 'tilt',
direction: 'down',
pitchDeg: -80,
});
assert.equal(result.ok, true);
assert.equal(result.direction, 'down');
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
});
test('the pitchDeg and direction check uses the camera pitch', (t) => {
// At regional range the Earth curves away and the pivot pitch is a degree
// or two shallower than the camera's (-30°); -29° is still up from it.
harness(t, { height: 150000, pitch: -30 });
const down = moveCamera({ motion: 'tilt', direction: 'down', pitchDeg: -29 });
assert.equal(down.ok, false);
assert.match(down.error, /tilts up, not down/);
const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 });
assert.equal(up.ok, true);
assert.equal(up.direction, 'up');
});
test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => {
const h = harness(t, { height: 1000 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -30);
adjustOrbitRange(1 / 2.4);
adjustOrbitRange(1 / 2.4);
h.tick();
const afterZoom = heightM(h.camera);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(
heightM(h.camera) > afterZoom - 1,
`camera fell from ${afterZoom} m to ${heightM(h.camera)} m`,
);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a zoom in during a close pivot tilt never pushes the camera out', (t) => {
// 62 m of range, under the 80 m zoom floor.
const h = harness(t, { height: 40, pitch: -40 });
moveCamera({ motion: 'tilt', direction: 'down', mode: 'continuous' });
h.tick();
const before = pivotRange(h);
assert.ok(before < 80, `range ${before} m`);
adjustOrbitRange(1 / 1.6);
h.tick();
assert.ok(pivotRange(h) <= before + 0.01, `range grew to ${pivotRange(h)} m`);
});
test('a zoom out during a far orbit never pulls the camera in', (t) => {
// 15,000 km up, beyond the 5,000 km zoom cap.
const h = harness(t, { height: 15_000_000 });
assert.equal(moveCamera({ motion: 'orbit', mode: 'continuous' }).ok, true);
h.tick();
const before = pivotRange(h);
assert.ok(before > 5_000_000, `range ${before} m`);
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'orbit', changed: false });
h.tick();
assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`);
});
test('an out-of-range pitchDeg is clamped to the tilt limits', (t) => {
// The schema carries no range; 120 reads as 120° below the horizon.
const h = harness(t, { pitch: -45 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 120 }).ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -89) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('a zoom in during a pivot tilt stops at the clearance', (t) => {
// 400 m of range at -40°, then "zoom in a lot" twice: unfloored, that left
// the camera about 107 m up, then at the 80 m zoom floor.
const h = harness(t, { height: 400 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -40);
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true });
// At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached.
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false });
// The camera must take that range, not just report it.
h.tick();
const floor = 150 / Math.sin(radians(40));
assert.ok(
Math.abs(pivotRange(h) - floor) < floor * 0.02,
`range ${pivotRange(h)} m, wanted about ${floor} m`,
);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a voice zoom during a pivot tilt says what it changed', async (t) => {
const h = harness(t, { height: 400 });
const runner = createGevActionRunner({
viewer: h.viewer,
styleManager: {},
dataManager: { layers: new Map(), getAll: () => [] },
});
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
h.tick();
const zoom = { direction: 'in', amount: 'lot' };
const first = await runner('adjust_camera_zoom', zoom);
assert.equal(first.ok, true);
assert.equal(first.tiltRangeAdjusted, true);
assert.equal('orbitRadiusAdjusted' in first, false);
// 400 m → 167 m → the 150 m clearance; a third zoom in moves nothing, and
// says so.
assert.equal((await runner('adjust_camera_zoom', zoom)).ok, true);
const third = await runner('adjust_camera_zoom', zoom);
assert.equal(third.ok, false);
assert.match(third.error, /tilt is already at its closest range/);
});
test('from globe range a tilt happens in place', (t) => {
// A pivot 2,000 km out would swing the camera thousands of km.
const h = harness(t, { height: 2_000_000 });
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3);
});
for (const height of [100_000, 400_000])
test(`from ${height / 1000} km a tilt to -5° ends with the camera at -5°`, (t) => {
// The pivot pitch is shallower than the camera's by up to 4° at this
// range; the camera reached only -5.9° and -8.6° when the swing stopped
// at a pivot pitch of -5°.
const h = harness(t, { height });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.pitchDeg, -5);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(nudge.ok, false);
assert.match(nudge.error, /upper tilt limit/);
});
// From 550 km out the camera's -5° lies beyond the place's own horizon: the
// swing ran into its clearance, let go of the pivot and tilted the rest in
// place, which put the crosshair on the sky (28 to 63 km up), and a return
// to top-down then landed 600 to 900 km from the place.
for (const height of [600_000, 700_000, 900_000]) {
test(`from ${height / 1000} km a tilt to -5° keeps the place centred`, (t) => {
const h = harness(t, { height });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.ok, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
// The reply names the angle the camera reached, not the one asked for.
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
assert.ok(
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
);
assert.match(result.note, /horizon/);
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(nudge.ok, false);
assert.match(nudge.error, /upper tilt limit/);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -89 }).ok, true);
h.finish();
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
// Back over the place at the range it began with (-89° is ~1° off nadir).
const range = Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
assert.ok(
Math.abs(range - height) < height * 0.01,
`top-down ${range} m from the place`,
);
});
test(`a continuous tilt up from ${height / 1000} km stops with the place centred`, (t) => {
const h = harness(t, { height });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
for (let i = 0; i < 1200 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(h.viewTarget(), `the crosshair is on the sky at frame ${i}`);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must stop at its limit');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
});
}
test('a pitchDeg tilt toward the horizon limit says it is already there', (t) => {
const h = harness(t, { height: 900_000 });
moveCamera({ motion: 'tilt', pitchDeg: 5 });
h.finish();
const again = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(again.ok, false);
assert.match(again.error, /upper tilt limit/);
});
test('a regional tilt-up nudge goes to the camera horizon limit', (t) => {
// 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can
// still tilt up 2°.
const h = harness(t, { height: 20000, pitch: -7 });
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('a voice tilt from a tracked view judges its limit from the camera pitch', (t) => {
const h = harness(t, { height: 20000, pitch: -5.2 });
h.viewer.trackedEntity = { id: 'tracked' };
// Voice releases the follow camera before the motion starts.
const release = (start) => {
h.viewer.trackedEntity = undefined;
return start();
};
const result = moveCamera({ motion: 'tilt', direction: 'up' }, release);
assert.equal(result.ok, false);
assert.match(result.error, /upper tilt limit/);
});
test('a zoom out during a pivoting tilt stops at the pivot range limit', (t) => {
// 900 km top-down, "tilt to 10°", then "zoom out a lot" twice: capped at
// the orbit's 5,000 km, the swing reached 2,386 km up at a real -44°.
const h = harness(t, { height: 900_000 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -10 }).ok, true);
h.tick();
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: true });
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: false });
let farthest = 0;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
farthest = Math.max(farthest, pivotRange(h));
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(farthest <= 1_000_001, `swung ${farthest} m from the pivot`);
assert.ok(
Math.abs(pitchDeg(h.camera) - -10) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('an unchanged angle still stops a running motion through navigation', (t) => {
const h = harness(t);
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -45.3);
let handoffs = 0;
const navigate = (start) => {
handoffs += 1;
return start();
};
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }, navigate);
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(handoffs, 1, 'the request must take navigation');
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
const held = pitchDeg(h.camera);
for (let i = 0; i < 30; i += 1) h.tick();
assert.equal(pitchDeg(h.camera), held);
});
for (const direction of ['left', 'right'])
test(`a tilt ${direction} is refused`, (t) => {
harness(t, { pitch: -45 });
const result = moveCamera({ motion: 'tilt', direction });
assert.equal(result.ok, false);
assert.match(result.error, /tilt needs up or down/);
const angled = moveCamera({ motion: 'tilt', direction, pitchDeg: -30 });
assert.equal(angled.ok, false);
assert.match(angled.error, /pitchDeg alone/);
assert.equal(getActiveCameraMotion(), null);
});
// The 1,000 km pivot cutoff includes its boundary: at exactly 1,000 km the
// pivot range came back a hair over it, so the tilt ran in place and put the
// crosshair on the sky.
test('from exactly 1,000 km a tilt to -5° still pivots about the place', (t) => {
const h = harness(t, { height: 1_000_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.ok, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
assert.ok(
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
);
assert.match(result.note, /horizon/);
});
// A zoom mid-tilt moves the pivot's horizon limit: the zoom's reply names the
// angle the tilt now ends at, and the camera ends there.
test('a zoom out during a tilt to an angle reports the angle it now ends at', (t) => {
const h = harness(t, { height: 400_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
assert.equal(result.pitchDeg, -5);
assert.equal(result.note, undefined);
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.kind, 'tilt');
assert.equal(zoom.changed, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(zoom.pitchDeg < -5, `zoom reported ${zoom.pitchDeg}°`);
assert.ok(
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
test('a zoom in during a horizon-limited tilt reports the angle it now reaches', (t) => {
const h = harness(t, { height: 900_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = adjustOrbitRange(1 / 2.4);
assert.equal(zoom.changed, true);
h.finish();
assert.equal(zoom.pitchDeg, -5);
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`camera at ${pitchDeg(h.camera)}°`,
);
});
test('a voice zoom during a tilt to an angle passes on the new end angle', async (t) => {
const h = harness(t, { height: 400_000 });
const runner = createGevActionRunner({
viewer: h.viewer,
styleManager: {},
dataManager: { layers: new Map(), getAll: () => [] },
});
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = await runner('adjust_camera_zoom', {
direction: 'out',
amount: 'lot',
});
assert.equal(zoom.ok, true);
assert.equal(zoom.tiltRangeAdjusted, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// Late in the tilt the camera is already past the angle the new range's
// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8°): past it
// the place is behind the Earth, so the camera steps down to that limit with
// the zoom, then holds it with no further jump, and the zoom's reply names
// the angle it ends at, not the one it stood at.
test('a late zoom out during a tilt steps to the new limit and reports it', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.changed, true);
h.tick();
assert.equal(zoom.pitchDeg, Math.round(pitchDeg(h.camera)));
let last = pitchDeg(h.camera);
let maxStep = 0;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
maxStep = Math.max(maxStep, Math.abs(pitchDeg(h.camera) - last));
last = pitchDeg(h.camera);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(maxStep < 0.1, `pitch jumped ${maxStep}° in one frame`);
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
// Replies give whole degrees: the reported angle is the reached one rounded.
assert.equal(
zoom.pitchDeg,
Math.round(pitchDeg(h.camera)),
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// Easing from the camera's old pitch at the zoomed-out range put the camera
// below the place's horizon: the first frame's crosshair hit the ground
// 186 km from London, and London stayed hidden for ~11 frames.
test('a late zoom out during a tilt keeps the place under the crosshair', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.changed, true);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
const hit = h.viewTarget();
assert.ok(hit, `frame ${i}: the crosshair is on the sky`);
const drift = centreDrift(h);
assert.ok(
drift < 100,
`frame ${i}: crosshair ${Math.round(drift)} m off the place`,
);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(
Math.abs(pivotRange(h) - 1_000_000) < 1,
`range ${pivotRange(h)} m`,
);
assert.equal(
zoom.pitchDeg,
Math.round(pitchDeg(h.camera)),
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// The zoom out left the pivot pitch level with the place, so the zoom in's
// clearance floor came out infinite and it was refused at 1,000 km.
test('a zoom in right after a late zoom out brings the tilt back in', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const range = pivotRange(h);
assert.equal(adjustOrbitRange(2.5).changed, true);
const zoomIn = adjustOrbitRange(0.4);
assert.equal(zoomIn.changed, true, 'the zoom in must not be refused');
h.finish();
assert.ok(
Math.abs(pivotRange(h) - range) < 1,
`range ${pivotRange(h)} m, wanted ${range} m`,
);
assert.ok(
centreDrift(h) < 100,
`crosshair ${centreDrift(h)} m off the place`,
);
});
+748 -83
View File
@@ -20,11 +20,19 @@
import * as Cesium from 'cesium';
import { GLOBE_VIEW } from './locations.js';
import {
globeFrameShare,
isFreeGlobeCamera,
lookAngleWithinFrame,
lookStepLosesGlobe,
} from './globeInView.js';
import {
projectEarthDiscToViewport,
earthDiscScreenRadius,
getKeyholeGeometry,
KEYHOLE_OUTER_RADIUS,
} from './celestialRing.js';
import { isScopeMaskEnabled } from './scopeMask.js';
import {
holdContinuousRender,
releaseContinuousRender,
@@ -48,6 +56,33 @@ const ONCE = {
};
const PITCH_MIN = Cesium.Math.toRadians(-89);
const PITCH_MAX = Cesium.Math.toRadians(-5);
/** A bare "tilt the camera" / "oblique view" settles here. */
const TILT_DEFAULT_PITCH_DEG = -45;
/** °/s for a tilt to a requested angle; short ones keep the once duration. */
const TILT_TO_DEG_S = 30;
/**
* A pivoting tilt keeps the range, so its height above the pivot is
* range × sin|pitch|. It swings no lower than this; the rest tilts in place.
*/
const TILT_MIN_CLEARANCE_M = 150;
/**
* Beyond this range a pivot would swing the camera thousands of km about the
* crosshair point, so the tilt happens in place instead.
*/
const TILT_PIVOT_MAX_RANGE_M = 1_000_000;
/**
* A pivot range rebuilt from the camera's position comes back a hair off the
* range it was set at: a range this close past the cutoff still counts as on
* it.
*/
const TILT_PIVOT_RANGE_SLACK_M = 1;
/**
* The shallowest a pivot swing goes when its clearance is not what stops it.
* From several hundred km the camera's own horizon limit lies past the
* place's horizon; tilting the rest in place there would lift the crosshair
* onto the sky, so the swing stops with the place this far above it.
*/
const TILT_MIN_PIVOT_ELEVATION = Cesium.Math.toRadians(0.5);
/* ── Route dolly: cinematic tuning ──────────────────────────────────────────
* Every knob an owner may want to retune lives in this block. The shaping is
@@ -966,6 +1001,17 @@ let _trackingRemover = null;
let _inputRemovers = [];
/** Monotonic id for the motion currently running; 0 means "no motion". */
let _motionSeq = 0;
/** Canvas grabs, touches and wheel turns since load. */
let _manualInputCount = 0;
/**
* Lets a caller holding a motion back (behind a fly-to) see whether the user
* took the camera meanwhile; Cesium does not cancel a flight on input.
* @returns {number} Manual camera inputs so far.
*/
export function manualCameraInputCount() {
return _manualInputCount;
}
/**
* Install a motion and stamp it with a fresh id. The id is what lets a caller
@@ -1008,6 +1054,187 @@ export function interruptCameraMotionIfActive(
return interruptCameraMotion(reason);
}
// The frame share that in-place voice turns measure their limit against, with
// the pose the last voice turn left the camera in. A turn that starts where
// the last one left off keeps that baseline (raised if the planet now fills
// more of the frame), so repeating a request cannot take a little more each
// time. Once anything else has moved or turned the camera, a turn starts over
// from what is on screen.
let _lookBaseline = null;
// The pre-check's proof that a half-degree turn from this pose keeps the planet
// in view: which way, from which position and direction. A motion that starts from that
// pose turns at least that far, so a request reported as done really moves
// even when a smaller first step lands in a dip of the sampled share.
const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5);
let _provenLook = null;
/** The part of the frame a viewer can see: the scope keyhole when it is on. */
function visibleFrame() {
return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 };
}
/** The frame share an in-place turn from `cam`'s pose is limited against. */
function lookBaselineShare(cam) {
const share = globeFrameShare(cam, visibleFrame());
const continues =
_lookBaseline &&
Cesium.Cartesian3.equalsEpsilon(
_lookBaseline.position,
cam.positionWC,
0,
1,
) &&
Cesium.Cartesian3.equalsEpsilon(
_lookBaseline.direction,
cam.directionWC,
0,
1e-9,
);
_lookBaseline = {
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
share: continues ? Math.max(_lookBaseline.share, share) : share,
};
return _lookBaseline.share;
}
/**
* One in-place look step of motion `m`. From orbit a free globe camera turns
* only as far as keeps the planet in frame, measured against the baseline the
* turn started from, then the motion ends there.
*/
function lookWithinFrame(cam, m, look, angle) {
let allowed = angle;
if (isFreeGlobeCamera(_viewer)) {
if (m.globeShareAtStart === undefined) {
m.globeShareAtStart = lookBaselineShare(cam);
m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
m.turnedRad = 0;
}
_provenLook = null;
allowed = lookAngleWithinFrame(cam, look, angle, {
...visibleFrame(),
startShare: m.globeShareAtStart,
});
const owed = Math.min(angle, m.provenTurnRad - m.turnedRad);
if (allowed < owed) allowed = owed;
m.turnedRad += allowed;
}
if (allowed > 0) cam[look](allowed);
// The pose this turn leaves, for a turn that continues from it.
if (_lookBaseline)
Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction);
if (allowed < angle) interruptCameraMotion('globe-out-of-frame');
}
/**
* The in-place look a pitch change from `fromPitch` to `goalPitch` makes:
* 'lookUp', 'lookDown', or null when it does not move. A tilt is judged by
* the way its pitch moves, whatever form or direction word it came in.
*/
function tiltLook(fromPitch, goalPitch) {
if (!Number.isFinite(fromPitch) || !Number.isFinite(goalPitch)) return null;
if (goalPitch > fromPitch) return 'lookUp';
return goalPitch < fromPitch ? 'lookDown' : null;
}
/** Tilt `m` in place to `goalPitch`, as far as the frame limit allows. */
function tiltInPlaceWithinFrame(cam, m, goalPitch) {
const look = tiltLook(cam.pitch, goalPitch);
if (look) lookWithinFrame(cam, m, look, Math.abs(goalPitch - cam.pitch));
}
/**
* The pitch an in-place tilt from `cam`'s pose toward `goalPitch` reaches
* before the frame limit stops it, judged as its steps will be: `goalPitch`
* itself when the planet stays in frame the whole way, or the camera is not a
* free globe camera. The steps run on a copy of the camera, each looking by
* the pitch still to go as the tilt's frames do, because a rolled camera's
* look turns its pitch by less than the look angle.
*/
function tiltPitchWithinFrame(cam, goalPitch) {
const look = tiltLook(cam.pitch, goalPitch);
if (!look || !isFreeGlobeCamera(_viewer)) return goalPitch;
const frame = { ...visibleFrame(), startShare: lookBaselineShare(cam) };
// A proven half degree is turned whatever the sampled share says.
let owed = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
const probe = Cesium.Camera.clone(cam);
for (let i = 0; i < 32; i += 1) {
if (tiltLook(probe.pitch, goalPitch) !== look) return goalPitch;
const angle = Math.abs(goalPitch - probe.pitch);
if (angle < 1e-6) return goalPitch;
const allowed = Math.max(
lookAngleWithinFrame(probe, look, angle, frame),
Math.min(angle, owed),
);
owed -= allowed;
if (allowed > 0) probe[look](allowed);
if (allowed < angle) return probe.pitch;
}
return probe.pitch;
}
/** The reply's note for a tilt the frame limit stops short of its angle. */
function tiltFrameNote(pitch) {
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this height a shallower angle would turn the globe out of view. Zoom in to tilt further.`;
}
/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */
function lookProven(cam, look) {
return (
_provenLook?.look === look &&
Cesium.Cartesian3.equalsEpsilon(
_provenLook.position,
cam.positionWC,
0,
1,
) &&
Cesium.Cartesian3.equalsEpsilon(
_provenLook.direction,
cam.directionWC,
0,
1e-9,
)
);
}
/**
* The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft'
* or 'lookRight') must pass from orbit: refused when even half a degree would
* cross the frame limit, otherwise proven for that half degree from this pose.
* With `released`, the turn runs through a navigation that first releases a
* tracked or locked camera and keeps its world pose. The frame measure and the
* turn are the same in any reference frame, so such a camera is judged as it
* is, before the release, and a refusal leaves tracking as it was. Otherwise a
* camera that is not a free globe camera is not checked. Returns the refusal,
* or null.
*/
function checkLookFromOrbit(look, { released = false } = {}) {
_provenLook = null;
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null;
const cam = _viewer.camera;
if (!released && !isFreeGlobeCamera(_viewer)) return null;
if (
lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, {
...visibleFrame(),
startShare: lookBaselineShare(cam),
})
)
return {
ok: false,
action: 'move_camera',
error:
'From this height that would turn the globe out of view — zoom in first, or turn the other way.',
};
_provenLook = {
look,
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
};
return null;
}
function clearLookAt() {
try {
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
@@ -1052,7 +1279,9 @@ export function interruptCameraMotion(reason = 'interrupt') {
if (
wasActive &&
!tracking &&
(_active.kind === 'orbit' || _active.kind === 'route')
(_active.kind === 'orbit' ||
_active.kind === 'route' ||
(_active.kind === 'tilt' && _active.hpr))
)
clearLookAt();
// Instant, not eased: the user grabbed the camera, or the flight is over.
@@ -1104,7 +1333,7 @@ function onTick() {
let scale = 1;
if (m.mode === 'once') {
m.elapsed = (m.elapsed || 0) + dt;
const t = Math.min(1, m.elapsed / ONCE.durationS);
const t = Math.min(1, m.elapsed / (m.durationS || ONCE.durationS));
scale = 1 - (1 - t) * (1 - t); // ease-out progress share
if (t >= 1) {
interruptCameraMotion('once-complete');
@@ -1173,30 +1402,20 @@ function onTick() {
else if (m.direction === 'right') cam.moveRight(step);
else if (m.direction === 'up') cam.moveUp(step);
else cam.moveDown(step);
} else if (
m.kind === 'tilt' ||
m.kind === 'rotate' ||
m.kind === 'rotate_globe'
) {
} else if (m.kind === 'tilt') {
tickTilt(cam, m, scale, dt);
} else if (m.kind === 'rotate' || m.kind === 'rotate_globe') {
const degS = TILT_DEG_S[m.speed];
const stepRad =
m.mode === 'once'
? Cesium.Math.toRadians(
m.kind === 'tilt' ? ONCE.tiltDeg : ONCE.rotateDeg,
) *
(scale - (m.lastScale || 0))
? Cesium.Math.toRadians(ONCE.rotateDeg) * (scale - (m.lastScale || 0))
: Cesium.Math.toRadians(degS) * dt;
m.lastScale = scale;
if (m.kind === 'rotate_globe') {
// Tracking can acquire the camera without going through navigation.
// Yield without resetting its pose/frame: the world-space arc proof
// only describes Cesium's rotation while the frame is identity.
if (
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
_viewer.trackedEntity ||
_isCockpitActive?.() ||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
) {
if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) {
interruptCameraMotion('globe-camera-owner-changed');
return;
}
@@ -1218,18 +1437,14 @@ function onTick() {
return;
}
cam.rotate(m.rotationAxis, angle);
} else if (m.kind === 'tilt') {
const up = m.direction === 'up';
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
const next = cam.pitch + (up ? stepRad : -stepRad);
if (next > PITCH_MAX || next < PITCH_MIN) {
interruptCameraMotion('tilt-clamp');
return;
}
if (up) cam.lookUp(stepRad);
else cam.lookDown(stepRad);
} else if (m.direction === 'left') cam.lookLeft(stepRad);
else cam.lookRight(stepRad);
} else {
lookWithinFrame(
cam,
m,
m.direction === 'left' ? 'lookLeft' : 'lookRight',
stepRad,
);
}
} else if (m.kind === 'route') {
// The dolly's shaping lives in advanceRouteFlight(); the tick only
// applies the pose and honours the same completion path as before.
@@ -1252,6 +1467,186 @@ function onTick() {
}
}
/**
* One frame of tilt. With a ground pivot the camera swings about it (lookAt),
* so the place under the crosshair stays centred; without one it looks up or
* down in place. Every angle and limit is the camera's own pitch, which at
* regional range is several degrees steeper than the pivot's (the Earth
* curves away), so the swing solves for the pivot pitch that gives it. Pitch
* is clamped to [-89°, -5°] only in the direction of travel: a top-down view
* sits at -90°, below the limit, and must still be able to tilt up out of it.
* A pivot swing toward the horizon lowers the camera, so it stops at
* TILT_MIN_CLEARANCE_M and the rest tilts in place. Farther out it stops with
* the camera TILT_MIN_PIVOT_ELEVATION above the place's horizon instead, and
* keeps the place centred: tilting in place there would lift the crosshair
* onto the sky.
*/
function tickTilt(cam, m, scale, dt) {
const from = cam.pitch;
const want =
m.mode === 'once'
? m.startPitch + (m.goalPitch - m.startPitch) * scale
: from +
Cesium.Math.toRadians(TILT_DEG_S[m.speed]) *
dt *
(m.direction === 'up' ? 1 : -1);
const up = want > from;
const limited = up
? Math.min(want, Math.max(from, PITCH_MAX))
: Math.max(want, Math.min(from, PITCH_MIN));
let atLimit = false;
if (m.hpr) {
// Apply a range a zoom set since the last frame, even when the pitch
// then has nowhere to go (a zoom in to the clearance ends the pivot).
cam.lookAt(m.target, m.hpr);
// Up toward the horizon the swing lowers the camera: hold the clearance,
// and keep the place below the horizon.
const cap = up
? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range))
: Infinity;
for (let i = 0; i < 3 && Math.abs(limited - cam.pitch) > 1e-6; i += 1) {
const pitch = Math.min(
cap,
Math.max(-Cesium.Math.PI_OVER_TWO, m.hpr.pitch + limited - cam.pitch),
);
if (pitch === m.hpr.pitch) break;
m.hpr.pitch = pitch;
cam.lookAt(m.target, m.hpr);
}
// A once motion that just completed released the lookAt frame before
// this last step re-took it.
if (_active !== m) clearLookAt();
if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) {
if (tiltClearanceBinds(m.hpr.range)) {
// Down to its clearance: let go of the pivot and tilt the rest in
// place, which keeps the height.
if (_active === m) clearLookAt();
m.hpr = null;
m.target = null;
} else {
// The place is at its horizon limit: stop here, still centred.
atLimit = true;
}
}
}
if (!m.hpr) tiltInPlaceWithinFrame(cam, m, limited);
if (m.mode !== 'once' && (limited !== want || atLimit))
interruptCameraMotion('tilt-clamp');
}
/** The refusal for a tilt up the camera has no room left for. */
function tiltUpLimitRefusal() {
return {
ok: false,
action: 'move_camera',
error:
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
};
}
/** Shallowest pivot pitch that keeps the clearance at this range. */
function tiltPivotPitchMax(range) {
return Math.min(
-Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)),
-TILT_MIN_PIVOT_ELEVATION,
);
}
/**
* Whether the clearance, not the horizon, stops a swing at this range. Only
* then does the rest of the tilt happen in place: that close in (under ~17
* km) the camera's -5° limit stays well below its own horizon.
*/
function tiltClearanceBinds(range) {
return TILT_MIN_CLEARANCE_M / range >= Math.sin(TILT_MIN_PIVOT_ELEVATION);
}
/**
* The shallowest camera pitch a tilt starting from this frame can reach with
* the place under the crosshair still in view: PITCH_MAX, or less where a
* far pivot meets its horizon limit first.
*/
function tiltUpLimit({ target, pivot }) {
if (!pivot || tiltClearanceBinds(pivot.range)) return PITCH_MAX;
return Math.min(
PITCH_MAX,
pivotCameraPitch(
target,
pivot,
Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)),
),
);
}
/** The camera pitch lookAt gives for this pivot at the given pivot pitch. */
function pivotCameraPitch(target, { heading, range }, pitch) {
const offset = new Cesium.Cartesian3(
-range * Math.cos(pitch) * Math.sin(heading),
-range * Math.cos(pitch) * Math.cos(heading),
-range * Math.sin(pitch),
);
const eye = Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
offset,
new Cesium.Cartesian3(),
);
const look = Cesium.Cartesian3.subtract(target, eye, new Cesium.Cartesian3());
Cesium.Cartesian3.normalize(look, look);
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
const upAtEye = ellipsoid.geodeticSurfaceNormal(eye, new Cesium.Cartesian3());
return Math.asin(
Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1),
);
}
/**
* Where a tilt starts. It swings about the ground under the crosshair so the
* place stays centred. It tilts in place with no ground there, from globe
* range, or while an entity is tracked (the follow camera owns that frame;
* voice releases it before moving, so a voice tilt still pivots).
*/
function tiltStartFrame(cam) {
const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer);
let pivot = target ? tiltPivot(cam, target) : null;
if (pivot && !tiltPivotInReach(pivot.range)) pivot = null;
return { target, pivot, from: cam.pitch };
}
/** Whether a tilt may pivot at this range: up to and including the cutoff. */
function tiltPivotInReach(range) {
return range <= TILT_PIVOT_MAX_RANGE_M + TILT_PIVOT_RANGE_SLACK_M;
}
/** The reply's note for a tilt the place's horizon stops short of its angle. */
function tiltHorizonNote(pitch) {
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`;
}
/** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */
function tiltPivot(cam, target) {
const local = Cesium.Matrix4.multiplyByPoint(
Cesium.Matrix4.inverseTransformation(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
new Cesium.Matrix4(),
),
cam.positionWC,
new Cesium.Cartesian3(),
);
const range = Cesium.Cartesian3.magnitude(local);
if (!(range > 1) || local.z <= 0) return null;
// Near straight overhead the offset's heading is float noise (it flipped a
// north-up London view to south); keep the camera's own.
const heading =
Math.hypot(local.x, local.y) > range * 0.01
? Math.atan2(-local.x, -local.y)
: cam.heading;
return new Cesium.HeadingPitchRange(
heading,
-Math.asin(Math.min(1, local.z / range)),
range,
);
}
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
function globeRotationHasClearance(position) {
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
@@ -1413,6 +1808,7 @@ export function initCameraVerbs(
_isCockpitActive =
typeof isCockpitActive === 'function' ? isCockpitActive : null;
_viewer = viewer;
_lookBaseline = null;
if (_tickRemover) _tickRemover();
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
if (_trackingRemover) _trackingRemover();
@@ -1421,18 +1817,108 @@ export function initCameraVerbs(
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
if (entity && _active?.kind === 'rotate_globe')
interruptCameraMotion('globe-camera-owner-changed');
// A pivoting tilt's lookAt would fight the follow camera every frame.
else if (entity && _active?.kind === 'tilt' && _active.hpr)
interruptCameraMotion('tilt-camera-owner-changed');
});
for (const rm of _inputRemovers) rm();
_inputRemovers = [];
const canvas = viewer.scene.canvas;
// ANY manual camera input reclaims control — the cancelFlight reflex.
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
const h = () => interruptCameraMotion('manual-input');
const h = () => {
_manualInputCount += 1;
interruptCameraMotion('manual-input');
};
canvas.addEventListener(evt, h, { passive: true });
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
}
}
/**
* A tilt's angle: the one asked for, the default oblique view when a bare
* "tilt the camera" names neither angle nor direction, or null for a nudge.
*/
function requestedTiltPitch(args, direction) {
// Only a real number or numeric text: Number('') and Number(null) are 0,
// which would swing the camera to the horizon.
const raw = args.pitchDeg;
const requested =
typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')
? Number(raw)
: NaN;
// The camera never looks above the horizon, so a positive angle can only
// mean the same angle below it ("tilt 45 degrees" sent as 45).
if (Number.isFinite(requested))
return Cesium.Math.clamp(
Cesium.Math.toRadians(-Math.abs(requested)),
PITCH_MIN,
PITCH_MAX,
);
return direction === null
? Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG)
: null;
}
/**
* Argument errors move_camera refuses whatever the camera is doing, so a
* caller holding a motion back behind a flight can refuse it before waiting.
* @param {Object} args
* @returns {Object|null} The refusal, or null when the arguments are usable.
*/
export function moveCameraArgsError(args = {}) {
const motion = String(args.motion || '').toLowerCase();
const direction = args.direction
? String(args.direction).toLowerCase()
: null;
if (motion === 'stop') return null;
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
return {
ok: false,
action: 'move_camera',
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
};
}
if (
motion !== 'orbit' &&
motion !== 'rotate_globe' &&
(motion !== 'tilt' || requestedTiltPitch(args, direction) === null) &&
!['left', 'right', 'up', 'down'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: `${motion} needs a direction (${motion === 'tilt' ? 'up/down' : motion === 'rotate' ? 'left/right' : 'left/right/up/down'}).`,
};
}
if (motion === 'tilt' && (direction === 'left' || direction === 'right')) {
return {
ok: false,
action: 'move_camera',
error: 'tilt needs up or down, or pitchDeg alone for an angle.',
};
}
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
return {
ok: false,
action: 'move_camera',
error: 'rotate needs left or right.',
};
}
if (
motion === 'rotate_globe' &&
direction !== null &&
!['left', 'right'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: 'rotate_globe needs left or right.',
};
}
return null;
}
/**
* move_camera implementation. Returns the house result shape; rejections are
* plain-English `ok:false` errors.
@@ -1465,54 +1951,66 @@ export function moveCamera(args = {}, runNavigation = null) {
};
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
}
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
return {
ok: false,
action: 'move_camera',
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
};
}
if (
motion !== 'orbit' &&
motion !== 'rotate_globe' &&
!['left', 'right', 'up', 'down'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`,
};
}
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
return {
ok: false,
action: 'move_camera',
error: 'rotate needs left or right.',
};
}
if (
motion === 'rotate_globe' &&
direction !== null &&
!['left', 'right'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: 'rotate_globe needs left or right.',
};
}
if (motion === 'tilt') {
// Honest limits: at the clamp a tilt is a no-op — say so instead of
// reporting success and letting the model gaslight itself (field test).
const pitch = _viewer.camera.pitch;
if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) {
const argsError = moveCameraArgsError(args);
if (argsError) return argsError;
const tiltToPitch =
motion === 'tilt' ? requestedTiltPitch(args, direction) : null;
if (motion === 'tilt' && tiltToPitch !== null) {
// Angles and limits are the camera's own pitch, whatever the swing pivots
// about; releasing the follow camera does not change it.
const from = _viewer.camera.pitch;
// -89 or -90 means top-down, which a -90° view already is: the clamp
// must not tilt it up 1°.
const atTopDown = tiltToPitch <= PITCH_MIN && from <= PITCH_MIN;
if (
atTopDown ||
Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)
) {
// Still a camera request: it stops a running motion and releases the
// follow camera, as a tilt that moves would.
const hold = () => {
interruptCameraMotion('replaced');
return {
ok: true,
action: 'move_camera',
motion,
pitchDeg: Math.round(
Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch),
),
unchanged: true,
};
};
return typeof runNavigation === 'function' ? runNavigation(hold) : hold();
}
// pitchDeg is an absolute angle, not a step: "tilt down 10 degrees" sent
// as {direction:'down', pitchDeg:10} would tilt up from -45°.
const towards = tiltToPitch > from ? 'up' : 'down';
if ((direction === 'up' || direction === 'down') && direction !== towards) {
const target = Math.round(Cesium.Math.toDegrees(tiltToPitch));
const now = Math.round(Cesium.Math.toDegrees(from));
return {
ok: false,
action: 'move_camera',
error:
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
error: `pitchDeg is an absolute angle, not a step: ${target}° from ${now}° tilts ${towards}, not ${direction}. Send direction alone for a nudge, or pitchDeg alone for an angle.`,
};
}
if (
towards === 'up' &&
from >=
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
)
return tiltUpLimitRefusal();
} else if (motion === 'tilt') {
// Honest limits: at the clamp a tilt is a no-op — say so instead of
// reporting success and letting the model gaslight itself (field test).
const pitch = _viewer.camera.pitch;
if (
direction === 'up' &&
pitch >=
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
) {
return tiltUpLimitRefusal();
}
if (
direction === 'down' &&
pitch <= PITCH_MIN + Cesium.Math.toRadians(0.5)
@@ -1524,6 +2022,34 @@ export function moveCamera(args = {}, runNavigation = null) {
};
}
}
// A tilt is judged by the way its pitch will move: toward its angle, or a
// nudge up or down; a rotate turns left only when told to, as it runs.
const look =
motion === 'tilt'
? tiltLook(
_viewer.camera.pitch,
tiltToPitch ??
_viewer.camera.pitch +
Cesium.Math.toRadians(ONCE.tiltDeg) *
(direction === 'up' ? 1 : -1),
)
: motion === 'rotate'
? direction === 'left'
? 'lookLeft'
: 'lookRight'
: null;
// Honest limits from orbit too: a stopped turn rests within 0.05° of its
// limit, so one that cannot go half a degree further is refused rather than
// run and reported as done, and one that can is then turned at least that
// far. Near the limit the sampled share wobbles as the planet's edge crosses
// the grid, so a turn that would keep the planet can occasionally be refused
// too.
// A camera that is tracked or locked now is judged as the navigation that
// runs the turn will release it, so a refusal keeps the tracking.
const lookRefusal =
look &&
checkLookFromOrbit(look, { released: typeof runNavigation === 'function' });
if (lookRefusal) return lookRefusal;
if (
(motion === 'rotate_globe' ||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
@@ -1607,14 +2133,70 @@ export function moveCamera(args = {}, runNavigation = null) {
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
};
}
// The angle a tilt to an angle stops at short of it, and why.
let stoppedAt = null;
let stopNote = null;
const start = () => {
interruptCameraMotion('replaced');
// Checked again only if the release left the camera somewhere other than
// where the check above expected it.
if (look && !lookProven(_viewer.camera, look)) {
const refusal = checkLookFromOrbit(look);
if (refusal) return refusal;
}
const state = {
kind: motion,
direction: direction || 'right',
speed,
mode,
};
if (motion === 'tilt') {
const frame = tiltStartFrame(_viewer.camera);
const { target, pivot, from } = frame;
if (pivot) {
state.target = target;
state.hpr = pivot;
}
state.startPitch = from;
const upLimit = tiltUpLimit(frame);
if (tiltToPitch !== null) {
// From far out the place reaches its horizon before the camera
// reaches the asked angle: stop there, and say so.
let goal =
tiltToPitch > from
? Math.min(tiltToPitch, Math.max(from, upLimit))
: tiltToPitch;
if (goal !== tiltToPitch) stopNote = tiltHorizonNote(goal);
// From orbit an in-place tilt stops where the planet would leave the
// frame: aim it there, and say so.
if (!pivot) {
const framed = tiltPitchWithinFrame(_viewer.camera, goal);
if (framed !== goal) {
goal = framed;
stopNote = tiltFrameNote(goal);
}
}
state.mode = 'once';
state.direction = goal > from ? 'up' : 'down';
state.goalPitch = goal;
// A zoom mid-tilt moves the horizon limit; it re-judges the goal
// against the angle asked for.
state.askedPitch = tiltToPitch;
state.durationS = Math.max(
ONCE.durationS,
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
);
if (goal !== tiltToPitch) stoppedAt = goal;
} else {
// A nudge stops at the limit it travels toward.
const step =
Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1);
state.goalPitch =
step > 0
? Math.min(from + step, Math.max(from, upLimit))
: Math.max(from + step, Math.min(from, PITCH_MIN));
}
}
if (motion === 'rotate_globe') {
// Convert an inherited ground-target frame without changing the world
// pose. Rotation must pivot about Earth, never the previous local target.
@@ -1633,9 +2215,11 @@ export function moveCamera(args = {}, runNavigation = null) {
}
if (motion === 'orbit') {
if (_viewer.scene.tweens.length > 0) {
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
// and capture at the destination; a target existing right now is
// meaningless (the globe is always under the crosshair).
// A camera flight is in progress — ALWAYS arm and capture at the
// destination; a target existing right now is meaningless (the globe
// is always under the crosshair). The voice runner already holds an
// orbit until its own fly-to lands; this covers any other
// flight.
setActiveMotion({
kind: 'orbit',
direction: direction || 'right',
@@ -1699,7 +2283,11 @@ export function moveCamera(args = {}, runNavigation = null) {
motion,
direction: state.direction,
speed,
mode,
mode: state.mode,
...(tiltToPitch !== null && {
pitchDeg: Math.round(Cesium.Math.toDegrees(stoppedAt ?? tiltToPitch)),
}),
...(stopNote !== null && { note: stopNote }),
};
};
const preserveCameraFlight =
@@ -1714,16 +2302,93 @@ export function moveCamera(args = {}, runNavigation = null) {
/**
* Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom
* fight the per-frame lookAt (field test: "zoom out did literally nothing").
* A tilt swinging about its pivot holds a range the same way.
* @param {number} factor >1 zooms out (radius grows), <1 zooms in.
* @returns {boolean} true when an orbit consumed the zoom.
* @returns {{kind: string, changed: boolean, pitchDeg?: number,
* note?: string}|null} the motion that consumed the zoom, and whether its
* range moved; null when none did. A tilt to an angle whose end the new
* range moves also names the angle it now ends at (pitchDeg), with a note.
*/
export function adjustOrbitRange(factor) {
if (!_active || _active.kind !== 'orbit' || !_active.hpr) return false;
_active.hpr.range = Math.max(
80,
Math.min(5_000_000, _active.hpr.range * factor),
if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr)
return null;
// A tilt's zoom in stops at the range that keeps its clearance. The limits
// never move a view the wrong way: a zoom in leaves a closer view in place,
// and a zoom out leaves a farther one.
const { kind, hpr } = _active;
const floor =
kind === 'tilt'
? Math.max(80, TILT_MIN_CLEARANCE_M / Math.abs(Math.sin(hpr.pitch)))
: 80;
// A tilt pivots only within TILT_PIVOT_MAX_RANGE_M; farther out the swing
// would carry the camera thousands of km.
const ceiling = kind === 'tilt' ? TILT_PIVOT_MAX_RANGE_M : 5_000_000;
const range = hpr.range;
hpr.range = Math.max(
Math.min(floor, range),
Math.min(Math.max(ceiling, range), range * factor),
);
return true;
// Under half a metre counts as not moved, as for a plain zoom.
const changed = Math.abs(hpr.range - range) >= 0.5;
return { kind, changed, ...(changed && retargetTiltAfterZoom(_active)) };
}
/**
* A zoom changed the range a tilt to an angle holds, and with it the place's
* horizon limit: aim the tilt at the angle asked for or the new limit,
* whichever comes first, from where the camera is now. The pivot pitch is
* re-solved so the new range keeps the camera's pitch, and a limit the camera
* is already past is eased down to rather than jumped to. Returns the end
* angle and a note for the reply when that end moved, otherwise nothing.
*/
function retargetTiltAfterZoom(m) {
if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined)
return {};
let from = _viewer.camera.pitch;
if (!(m.askedPitch > from)) return {};
if (m.hpr) {
// At the same pivot pitch a longer range looks down more steeply (the
// Earth curves away): find the pivot pitch that holds this camera pitch.
// It stays below the place's horizon limit (or where the pivot already
// was): past it the place drops behind the Earth, so a camera the zoom
// leaves past that limit steps down to it with the zoom.
const cap = Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range));
for (let i = 0; i < 4; i += 1) {
const error = from - pivotCameraPitch(m.target, m.hpr, m.hpr.pitch);
if (Math.abs(error) < 1e-7) break;
const pitch = Cesium.Math.clamp(
m.hpr.pitch + error,
-Cesium.Math.PI_OVER_TWO,
cap,
);
if (pitch === m.hpr.pitch) break;
m.hpr.pitch = pitch;
}
from = Math.min(from, pivotCameraPitch(m.target, m.hpr, m.hpr.pitch));
}
const limit =
m.hpr && !tiltClearanceBinds(m.hpr.range)
? Math.min(
PITCH_MAX,
pivotCameraPitch(m.target, m.hpr, tiltPivotPitchMax(m.hpr.range)),
)
: PITCH_MAX;
const goal = Math.min(m.askedPitch, limit);
if (Math.abs(goal - m.goalPitch) < 1e-6) return {};
m.goalPitch = goal;
m.startPitch = from;
m.elapsed = 0;
m.durationS = Math.max(
ONCE.durationS,
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
);
const limited = goal !== m.askedPitch;
return {
pitchDeg: Math.round(Cesium.Math.toDegrees(goal)),
note: limited
? tiltHorizonNote(goal)
: `The tilt now reaches ${Math.round(Cesium.Math.toDegrees(goal))}°.`,
};
}
/**
+25 -1
View File
@@ -1,5 +1,9 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from './renderGovernor.js';
import { worldClock, worldTimeFor } from './time/worldClock.js';
// The ring shows the sun and moon at the time the sky is drawn.
const skyTime = worldTimeFor('sky');
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
export const KEYHOLE_OUTER_RADIUS = 1.05;
@@ -28,6 +32,9 @@ export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4;
*/
export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01;
// How far the world time moves before the sun and moon move with it: a
// minute moves the sun a quarter of a degree.
const EPHEMERIS_WORLD_STEP_MS = 60_000;
const RING_INSET_PX = 11;
const MARKER_INSET_PX = 36;
const COLLIDING_MARKER_EXTRA_INSET_PX = 32;
@@ -445,6 +452,20 @@ export class CelestialRing {
if (typeof document !== 'undefined' && document.hidden) return;
governorRequestRender('celestial-ephemeris');
}, 60_000);
// A jump in the world time moves the sun and moon at once.
this._skyDate = new Date();
this._skyTime = new Cesium.JulianDate();
// Played through, the sky moves on each minute of world time it passes.
this._ephemerisWorldMs = null;
this._unsubscribeWorldTime = worldClock.subscribe(({ timeMs }) => {
if (
this._ephemerisWorldMs !== null &&
Math.abs(timeMs - this._ephemerisWorldMs) < EPHEMERIS_WORLD_STEP_MS
)
return;
this._ephemerisWorldMs = timeMs;
this._ephemerisDirty = true;
});
this.setEnabled(this.enabled);
}
@@ -801,7 +822,8 @@ export class CelestialRing {
return;
}
const time = Cesium.JulianDate.now();
this._skyDate.setTime(skyTime());
const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
if (!this._updateEphemeris(time)) return;
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
const camera = this.viewer.camera;
@@ -890,6 +912,8 @@ export class CelestialRing {
clearInterval(this._ephemerisTimer);
this._ephemerisTimer = null;
}
this._unsubscribeWorldTime?.();
this._unsubscribeWorldTime = null;
if (this._removePostRender) this._removePostRender();
this._removePostRender = null;
if (this._shown()) {
+16 -1
View File
@@ -170,6 +170,18 @@ export function cockpitWeatherEnabledFromStoredValue(value) {
return value === '1';
}
/**
* Whether the cloud pass runs for a Cockpit subject's layer. Not in orbit:
* the pass fades out above 24 km (weatherAltitudeFactors), so a satellite
* would pay for it and see nothing. The saved preference is left as it is
* for the next aircraft or bridge; cloud cover from orbit is the Weather
* layer's.
* @param {string|null|undefined} layerId
*/
export function cockpitCloudsShowFor(layerId) {
return layerId !== 'satellites';
}
/**
* Weather-backed volumetric cloud pass that exists only while cockpit mode is
* active. It owns no Cesium fog/post-process stages and cannot affect map mode.
@@ -207,10 +219,12 @@ class CockpitCloudEffectsController {
this.enabled = this.readEnabledPreference();
this.onResize = () => this.resize();
this.orbit = false;
this.onCockpitMode = (event) => {
const active =
event?.detail?.active ??
document.body.classList.contains('cockpit-mode');
this.orbit = active && !cockpitCloudsShowFor(event?.detail?.layerId);
if (active && this.enabled) this.start();
else this.stop();
};
@@ -344,7 +358,8 @@ class CockpitCloudEffectsController {
this.destroyed ||
!this.enabled ||
this.frame !== null ||
this.suspended
this.suspended ||
this.orbit
)
return;
if (!this.gl) {
+8
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
cockpitCloudRenderSize,
cockpitCloudsShowFor,
cockpitWeatherRefreshDue,
cockpitWeatherEnabledFromStoredValue,
} from './cockpitCloudEffects.js';
@@ -23,6 +24,13 @@ test('cockpit weather defaults off and enables only from an explicit saved opt-i
assert.equal(cockpitWeatherEnabledFromStoredValue('1'), true);
});
test('the cloud pass stays off in orbit and runs for aircraft and bridges', () => {
assert.equal(cockpitCloudsShowFor('satellites'), false);
for (const layerId of ['flights', 'military', 'ais-live-vessels', null]) {
assert.equal(cockpitCloudsShowFor(layerId), true, String(layerId));
}
});
test('cockpit weather refreshes after time or meaningful movement', () => {
const anchor = { latitude: 30, longitude: -97 };
assert.equal(cockpitWeatherRefreshDue({
+244
View File
@@ -0,0 +1,244 @@
/**
* Bridge look-around policy: how the arrow keys turn the bridge camera away
* from the bow and back, and how a turned view is described. Yaw is relative
* to the ship's heading; pitch is the view's absolute pitch. Pure: no DOM, no
* Cesium and no translated text; the Cockpit words the readout.
*/
/** One arrow press turns the view this far. */
export const LOOK_STEP_DEG = 5;
/** One Shift+arrow press turns the view this far: two reach either beam. */
export const LOOK_COARSE_STEP_DEG = 45;
/** The view pitches no further than straight up or straight down. */
export const LOOK_PITCH_LIMIT_DEG = 90;
/**
* The eased view's time constant. Three of them (about 180 ms) close 95% of
* a step, so a press glides rather than snaps and a held key turns smoothly.
*/
export const LOOK_EASE_TAU_MS = 60;
/** Closer than this the eased view lands on its target. */
const LOOK_SETTLE_DEG = 0.05;
/** Arrow key → the axis it turns and the direction it turns it. */
const LOOK_ARROWS = Object.freeze({
ArrowLeft: Object.freeze({ axis: 'yaw', sign: -1 }),
ArrowRight: Object.freeze({ axis: 'yaw', sign: 1 }),
ArrowUp: Object.freeze({ axis: 'pitch', sign: 1 }),
ArrowDown: Object.freeze({ axis: 'pitch', sign: -1 }),
});
/**
* Wrap a yaw to (-180, 180]: negative to port, positive to starboard.
* @param {number} deg Yaw in degrees; a non-finite yaw reads as ahead.
* @returns {number}
*/
export function wrapLookYaw(deg) {
if (!Number.isFinite(deg)) return 0;
const wrapped = (((deg % 360) + 360) % 360) - 360;
const yaw = wrapped <= -180 ? wrapped + 360 : wrapped;
return yaw === 0 ? 0 : yaw;
}
/**
* Clamp a view pitch to [-90, 90].
* @param {number} deg Pitch in degrees; a non-finite pitch reads as level.
* @returns {number}
*/
export function clampLookPitch(deg) {
if (!Number.isFinite(deg)) return 0;
const pitch = Math.min(
LOOK_PITCH_LIMIT_DEG,
Math.max(-LOOK_PITCH_LIMIT_DEG, deg),
);
return pitch === 0 ? 0 : pitch;
}
/**
* The step an arrow key asks for, or null for any other key.
* @param {string} key `KeyboardEvent.key`.
* @param {boolean} [coarse] Shift is held: step 45° instead of 5°.
* @returns {{axis: 'yaw'|'pitch', sign: 1|-1, stepDeg: number}|null}
*/
export function lookKeyStep(key, coarse = false) {
const arrow = LOOK_ARROWS[key];
if (!arrow) return null;
return {
...arrow,
stepDeg: coarse ? LOOK_COARSE_STEP_DEG : LOOK_STEP_DEG,
};
}
/**
* Move one value to the next multiple of `stepDeg` in the direction `sign`.
* A value off the grid (the bridge's -2° rest pitch) lands on it first, so
* the readout shows round angles.
*/
function nextOnGrid(value, sign, stepDeg) {
const cells = value / stepDeg;
const next =
sign > 0 ? Math.floor(cells + 1e-9) + 1 : Math.ceil(cells - 1e-9) - 1;
return next * stepDeg;
}
/**
* Apply one key step to a look target.
* @param {{yawDeg: number, pitchDeg: number}} target Current look target.
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From
* {@link lookKeyStep}.
* @returns {{yawDeg: number, pitchDeg: number}} The new target: yaw wrapped
* to (-180, 180], pitch clamped to [-90, 90].
*/
export function stepLook(target, step) {
const yawDeg = wrapLookYaw(target?.yawDeg ?? 0);
const pitchDeg = clampLookPitch(target?.pitchDeg ?? 0);
if (!step) return { yawDeg, pitchDeg };
if (step.axis === 'yaw')
return {
yawDeg: wrapLookYaw(nextOnGrid(yawDeg, step.sign, step.stepDeg)),
pitchDeg,
};
return {
yawDeg,
pitchDeg: clampLookPitch(nextOnGrid(pitchDeg, step.sign, step.stepDeg)),
};
}
/**
* Advance the eased view toward its target. Yaw takes the shorter way
* round; with `immediate` (reduced motion) the view jumps to the target.
* @param {{yawDeg: number, pitchDeg: number}} current Eased view now.
* @param {{yawDeg: number, pitchDeg: number}} target Look target.
* @param {number} dtMs Milliseconds since the previous step.
* @param {{immediate?: boolean}} [options]
* @returns {{yawDeg: number, pitchDeg: number}}
*/
export function easeLook(current, target, dtMs, { immediate = false } = {}) {
const goalYaw = wrapLookYaw(target?.yawDeg ?? 0);
const goalPitch = clampLookPitch(target?.pitchDeg ?? 0);
if (immediate || !current) return { yawDeg: goalYaw, pitchDeg: goalPitch };
const fraction =
Number.isFinite(dtMs) && dtMs > 0
? 1 - Math.exp(-dtMs / LOOK_EASE_TAU_MS)
: 0;
const fromYaw = wrapLookYaw(current.yawDeg);
const yawError = wrapLookYaw(goalYaw - fromYaw);
const pitchError = goalPitch - clampLookPitch(current.pitchDeg);
const yawDeg =
Math.abs(yawError * (1 - fraction)) < LOOK_SETTLE_DEG
? goalYaw
: wrapLookYaw(fromYaw + yawError * fraction);
const pitchDeg =
Math.abs(pitchError * (1 - fraction)) < LOOK_SETTLE_DEG
? goalPitch
: clampLookPitch(current.pitchDeg + pitchError * fraction);
return { yawDeg, pitchDeg };
}
/**
* Whether a look differs from the rest view (ahead at `restPitchDeg`).
* @param {{yawDeg: number, pitchDeg: number}} look
* @param {number} restPitchDeg The camera profile's pitch.
* @returns {boolean}
*/
export function lookIsOffset(look, restPitchDeg) {
return (
wrapLookYaw(look?.yawDeg ?? 0) !== 0 ||
clampLookPitch(look?.pitchDeg ?? restPitchDeg) !==
clampLookPitch(restPitchDeg)
);
}
/**
* The parts of a look readout, for the Cockpit to word. A part is null when
* that axis rests: yaw ahead, or pitch at the rest pitch.
* @param {{yawDeg: number, pitchDeg: number}} look Look target.
* @param {number} restPitchDeg The camera profile's pitch.
* @returns {{side: 'port'|'starboard'|'astern'|null, bearingDeg: number,
* elevation: 'up'|'down'|'level'|null, angleDeg: number}} `bearingDeg` is
* the angle off the bow (0–180) and `angleDeg` the angle off level (0–90),
* both rounded to whole degrees.
*/
export function lookReadoutParts(look, restPitchDeg) {
const yaw = Math.round(wrapLookYaw(look?.yawDeg ?? 0));
const pitch = Math.round(clampLookPitch(look?.pitchDeg ?? restPitchDeg));
const pitchOffset =
clampLookPitch(look?.pitchDeg ?? restPitchDeg) !==
clampLookPitch(restPitchDeg);
let side = null;
if (Math.abs(yaw) >= 180) side = 'astern';
else if (yaw > 0) side = 'starboard';
else if (yaw < 0) side = 'port';
let elevation = null;
if (pitchOffset) elevation = pitch > 0 ? 'up' : pitch < 0 ? 'down' : 'level';
return {
side,
bearingDeg: Math.abs(yaw),
elevation,
angleDeg: Math.abs(pitch),
};
}
/**
* The orbital view's off-nadir limit on each axis. The orbital look reuses
* the bridge's `{yawDeg, pitchDeg}` shape over a fixed nadir frame: yaw tilts
* the view east (positive) or west, pitch tilts it north (positive) or
* south. Single 5° steps only; the bridge keeps its own limits.
*/
export const ORBITAL_LOOK_LIMIT_DEG = 45;
/** Clamp one orbital look axis to ±ORBITAL_LOOK_LIMIT_DEG; NaN rests. */
function clampOrbitalAxis(deg) {
if (!Number.isFinite(deg)) return 0;
const value = Math.min(
ORBITAL_LOOK_LIMIT_DEG,
Math.max(-ORBITAL_LOOK_LIMIT_DEG, deg),
);
return value === 0 ? 0 : value;
}
/**
* Clamp an orbital look to the off-nadir limit on both axes.
* @param {{yawDeg: number, pitchDeg: number}} look East and north tilt.
* @returns {{yawDeg: number, pitchDeg: number}}
*/
export function clampOrbitalLook(look) {
return {
yawDeg: clampOrbitalAxis(look?.yawDeg),
pitchDeg: clampOrbitalAxis(look?.pitchDeg),
};
}
/**
* Apply one arrow step to an orbital look: the bridge's stepping on the
* same 5° grid, then the off-nadir clamp.
* @param {{yawDeg: number, pitchDeg: number}} target Current look target.
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From
* {@link lookKeyStep}.
* @returns {{yawDeg: number, pitchDeg: number}}
*/
export function stepOrbitalLook(target, step) {
return clampOrbitalLook(stepLook(clampOrbitalLook(target), step));
}
/**
* The parts of an off-nadir readout, for the Cockpit to word: the north or
* south tilt and the east or west tilt, each null at rest, in whole degrees.
* @param {{yawDeg: number, pitchDeg: number}} look Orbital look target.
* @returns {{northSouth: 'N'|'S'|null, northSouthDeg: number,
* eastWest: 'E'|'W'|null, eastWestDeg: number}}
*/
export function orbitalLookReadoutParts(look) {
const { yawDeg, pitchDeg } = clampOrbitalLook(look);
const north = Math.round(pitchDeg);
const east = Math.round(yawDeg);
return {
northSouth: north > 0 ? 'N' : north < 0 ? 'S' : null,
northSouthDeg: Math.abs(north),
eastWest: east > 0 ? 'E' : east < 0 ? 'W' : null,
eastWestDeg: Math.abs(east),
};
}
+195
View File
@@ -0,0 +1,195 @@
// src/cockpitLook.test.mjs — the bridge look-around policy: arrow steps,
// yaw wrapping, pitch limits, easing and the readout's parts.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
LOOK_COARSE_STEP_DEG,
LOOK_STEP_DEG,
ORBITAL_LOOK_LIMIT_DEG,
clampLookPitch,
clampOrbitalLook,
easeLook,
lookIsOffset,
lookKeyStep,
lookReadoutParts,
orbitalLookReadoutParts,
stepLook,
stepOrbitalLook,
wrapLookYaw,
} from './cockpitLook.js';
const REST = { yawDeg: 0, pitchDeg: -2 };
/** Press one key `count` times from `start`. */
function press(start, key, count = 1, coarse = false) {
let look = start;
for (let index = 0; index < count; index += 1)
look = stepLook(look, lookKeyStep(key, coarse));
return look;
}
test('yaw wraps to (-180, 180] and pitch clamps to [-90, 90]', () => {
assert.equal(wrapLookYaw(0), 0);
assert.equal(wrapLookYaw(-0), 0);
assert.equal(wrapLookYaw(180), 180);
assert.equal(wrapLookYaw(-180), 180);
assert.equal(wrapLookYaw(185), -175);
assert.equal(wrapLookYaw(-185), 175);
assert.equal(wrapLookYaw(720 + 45), 45);
assert.equal(wrapLookYaw(NaN), 0);
assert.equal(clampLookPitch(120), 90);
assert.equal(clampLookPitch(-120), -90);
assert.equal(clampLookPitch(-0), 0);
assert.equal(clampLookPitch(Infinity), 0);
});
test('only the arrows step, 5° plain and 45° with Shift', () => {
assert.deepEqual(lookKeyStep('ArrowLeft'), {
axis: 'yaw',
sign: -1,
stepDeg: LOOK_STEP_DEG,
});
assert.deepEqual(lookKeyStep('ArrowUp', true), {
axis: 'pitch',
sign: 1,
stepDeg: LOOK_COARSE_STEP_DEG,
});
assert.equal(lookKeyStep('ArrowDown').sign, -1);
assert.equal(lookKeyStep('ArrowRight').sign, 1);
for (const key of ['Home', '0', 'a', 'Escape', undefined])
assert.equal(lookKeyStep(key), null);
});
test('arrows turn the yaw in 5° steps and Shift reaches either beam in two', () => {
assert.deepEqual(press(REST, 'ArrowRight'), { yawDeg: 5, pitchDeg: -2 });
assert.deepEqual(press(REST, 'ArrowLeft', 3), { yawDeg: -15, pitchDeg: -2 });
assert.equal(press(REST, 'ArrowRight', 2, true).yawDeg, 90);
assert.equal(press(REST, 'ArrowLeft', 2, true).yawDeg, -90);
// A coarse step from off the 45° grid lands on it.
assert.equal(press({ ...REST, yawDeg: 5 }, 'ArrowRight', 1, true).yawDeg, 45);
// Past astern the yaw wraps to the other side.
assert.equal(press(REST, 'ArrowRight', 4, true).yawDeg, 180);
assert.equal(press(REST, 'ArrowRight', 5, true).yawDeg, -135);
assert.equal(press(REST, 'ArrowLeft', 37).yawDeg, 175);
});
test('pitch steps onto the 5° grid from the rest pitch and stops at ±90°', () => {
assert.equal(press(REST, 'ArrowUp').pitchDeg, 0);
assert.equal(press(REST, 'ArrowUp', 3).pitchDeg, 10);
assert.equal(press(REST, 'ArrowDown').pitchDeg, -5);
assert.equal(press(REST, 'ArrowUp', 40).pitchDeg, 90);
assert.equal(press(REST, 'ArrowDown', 40).pitchDeg, -90);
assert.equal(press(REST, 'ArrowUp', 3, true).pitchDeg, 90);
assert.equal(press(REST, 'ArrowDown', 2, true).pitchDeg, -90);
assert.equal(press(REST, 'ArrowUp').yawDeg, 0, 'pitch leaves the yaw');
});
test('the eased view closes most of a step in about 180 ms', () => {
const target = { yawDeg: 90, pitchDeg: -2 };
let view = { ...REST };
view = easeLook(view, target, 180);
assert.ok(view.yawDeg > 85 && view.yawDeg < 90, `yaw ${view.yawDeg}`);
for (let step = 0; step < 20; step += 1) view = easeLook(view, target, 50);
assert.deepEqual(view, target, 'it lands exactly on the target');
assert.deepEqual(easeLook(REST, target, 0), REST, 'no time, no motion');
});
test('the eased yaw takes the short way across astern', () => {
const view = easeLook(
{ yawDeg: 170, pitchDeg: 0 },
{ yawDeg: -170, pitchDeg: 0 },
30,
);
assert.ok(view.yawDeg > 170 || view.yawDeg < -170, `yaw ${view.yawDeg}`);
});
test('reduced motion jumps straight to the target', () => {
const target = { yawDeg: -45, pitchDeg: 30 };
assert.deepEqual(easeLook(REST, target, 1, { immediate: true }), target);
});
test('the readout names the side, the bearing and the elevation', () => {
assert.equal(lookIsOffset(REST, -2), false);
assert.equal(lookIsOffset({ yawDeg: 0, pitchDeg: 0 }, -2), true);
assert.equal(lookIsOffset({ yawDeg: 5, pitchDeg: -2 }, -2), true);
assert.deepEqual(lookReadoutParts({ yawDeg: 45, pitchDeg: 10 }, -2), {
side: 'starboard',
bearingDeg: 45,
elevation: 'up',
angleDeg: 10,
});
assert.deepEqual(lookReadoutParts({ yawDeg: -90, pitchDeg: -2 }, -2), {
side: 'port',
bearingDeg: 90,
elevation: null,
angleDeg: 2,
});
assert.deepEqual(lookReadoutParts({ yawDeg: 0, pitchDeg: -30 }, -2), {
side: null,
bearingDeg: 0,
elevation: 'down',
angleDeg: 30,
});
assert.equal(
lookReadoutParts({ yawDeg: 0, pitchDeg: 0 }, -2).elevation,
'level',
);
assert.equal(
lookReadoutParts({ yawDeg: 180, pitchDeg: -2 }, -2).side,
'astern',
);
});
test('an orbital look steps 5° per arrow and stops at 45° on each axis', () => {
let look = { yawDeg: 0, pitchDeg: 0 };
for (let index = 0; index < 3; index += 1)
look = stepOrbitalLook(look, lookKeyStep('ArrowUp'));
assert.deepEqual(look, { yawDeg: 0, pitchDeg: 15 });
look = stepOrbitalLook(look, lookKeyStep('ArrowRight'));
assert.deepEqual(look, { yawDeg: 5, pitchDeg: 15 });
for (let index = 0; index < 20; index += 1) {
look = stepOrbitalLook(look, lookKeyStep('ArrowDown'));
look = stepOrbitalLook(look, lookKeyStep('ArrowLeft'));
}
assert.deepEqual(look, {
yawDeg: -ORBITAL_LOOK_LIMIT_DEG,
pitchDeg: -ORBITAL_LOOK_LIMIT_DEG,
});
assert.deepEqual(clampOrbitalLook({ yawDeg: 90, pitchDeg: NaN }), {
yawDeg: 45,
pitchDeg: 0,
});
assert.deepEqual(clampOrbitalLook({ yawDeg: 180, pitchDeg: -60 }), {
yawDeg: 45,
pitchDeg: -45,
});
});
test('the orbital readout names north or south and east or west', () => {
assert.deepEqual(orbitalLookReadoutParts({ yawDeg: 15, pitchDeg: 10 }), {
northSouth: 'N',
northSouthDeg: 10,
eastWest: 'E',
eastWestDeg: 15,
});
assert.deepEqual(orbitalLookReadoutParts({ yawDeg: -5, pitchDeg: 0 }), {
northSouth: null,
northSouthDeg: 0,
eastWest: 'W',
eastWestDeg: 5,
});
assert.equal(
orbitalLookReadoutParts({ yawDeg: 0, pitchDeg: -45 }).northSouth,
'S',
);
});
test('the bridge look keeps its own limits beside the orbital clamp', () => {
const look = stepLook({ yawDeg: 45, pitchDeg: 45 }, lookKeyStep('ArrowUp'));
assert.deepEqual(look, { yawDeg: 45, pitchDeg: 50 });
assert.equal(
stepLook({ yawDeg: 45, pitchDeg: 0 }, lookKeyStep('ArrowRight', true))
.yawDeg,
90,
);
});
+25 -13
View File
@@ -79,7 +79,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => {
assert.doesNotMatch(html, /id="cockpit-exit"/);
assert.match(
html,
/id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape C"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/,
/id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/,
);
const topCenterActions = html.match(
/<nav id="top-center-actions"[\s\S]*?<\/nav>/,
@@ -163,7 +163,8 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
assert.match(
cockpitEnter.toString(),
/this\.setVisionMode\(this\.getInitialVisionMode\(\)\)/,
// Orbit alone opens optical (src/ui/cockpitOrbitalView.test.mjs).
/this\.setVisionMode\(\s*satellite \? 'optical' : this\.getInitialVisionMode\(\)\s*,?\s*\)/,
'Cockpit must enter on the mode matching the active map preset',
);
assert.match(
@@ -232,14 +233,12 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
);
});
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
test('open utilities own the first Escape and Cockpit leaves C alone', () => {
const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing');
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
assert.match(
keydown,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
);
// C is the app-wide CCTV shortcut; Cockpit never claims it.
assert.doesNotMatch(keydown, /=== 'c'/);
});
test('the Contact panel never hides itself out from under its own NEXT button', () => {
@@ -1306,12 +1305,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 +1361,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 +1393,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'/);
});
+40
View File
@@ -176,6 +176,46 @@ export function compassDivisions(heading) {
);
}
/**
* A heading tape that scrolls continuously: the 30-degree divisions within
* `reach` divisions of `heading`, each placed by its offset from the exact
* heading in division slots, so the centre caret reads the heading itself
* rather than the nearest division. `nearest` marks the division closest to
* the caret.
* @param {number} heading Degrees true.
* @param {number} [reach] Divisions each side of the caret.
* @returns {Array<{heading: number, slot: number, nearest: boolean}>}
*/
export function scrollingCompassDivisions(heading, reach = 4) {
if (!Number.isFinite(heading)) return [];
const exact = normalizeHeading(heading);
const centre = Math.round(exact / 30) * 30;
const divisions = [];
for (let step = -reach; step <= reach; step += 1) {
const division = normalizeHeading(centre + step * 30);
divisions.push({
heading: division,
slot: Number((relativeBearing(division, exact) / 30).toFixed(4)),
nearest: step === 0,
});
}
return divisions;
}
/**
* Where a heading falls on the `scrollingCompassDivisions(tapeHeading)` tape:
* its offset from the caret (the tape's exact heading) in division slots of
* 30 degrees, or null when it lies more than three divisions out.
* @param {number} heading Degrees true.
* @param {number} tapeHeading The heading under the tape's caret.
* @returns {?number}
*/
export function compassMarkerSlot(heading, tapeHeading) {
if (!Number.isFinite(heading) || !Number.isFinite(tapeHeading)) return null;
const slot = Number((relativeBearing(heading, tapeHeading) / 30).toFixed(4));
return Math.abs(slot) <= 3 ? slot : null;
}
/** Format a compass division as a cardinal/intercardinal label or degrees. */
export function formatCompassDivision(heading) {
const normalized = normalizeHeading(heading);
+42
View File
@@ -11,6 +11,8 @@ import {
cockpitSurfaceWaitExpired,
cockpitUiUpdateDue,
compassDivisions,
compassMarkerSlot,
scrollingCompassDivisions,
formatAltitudeRulerTick,
formatCockpitContextScope,
formatCockpitEmergency,
@@ -151,6 +153,46 @@ test('compass divisions remain ordered across north', () => {
assert.deepEqual(compassDivisions(359), [270, 300, 330, 0, 30, 60, 90]);
});
test('compass marks sit in division slots off the caret, or off the tape', () => {
// Measured from the exact heading under the caret, not the nearest division.
assert.equal(compassMarkerSlot(350, 350), 0);
assert.equal(compassMarkerSlot(35, 350), 1.5);
assert.equal(compassMarkerSlot(260, 350), -3);
assert.equal(compassMarkerSlot(80, 350), 3);
assert.equal(compassMarkerSlot(90, 350), null);
assert.equal(compassMarkerSlot(180, 350), null);
assert.equal(compassMarkerSlot(Number.NaN, 0), null);
});
test('a scrolling tape puts the exact heading under the caret', () => {
// At 100° the divisions sit a third of a slot off their snapped places,
// so the bow mark (also 100°) lines up with the caret, not 10° beside it.
const tape = scrollingCompassDivisions(100);
assert.deepEqual(
tape.map(({ heading }) => heading),
[330, 0, 30, 60, 90, 120, 150, 180, 210],
);
const nearest = tape.find((division) => division.nearest);
assert.equal(nearest.heading, 90);
assert.equal(nearest.slot, -0.3333);
assert.equal(tape.find(({ heading }) => heading === 120).slot, 0.6667);
assert.equal(compassMarkerSlot(100, 100), 0);
assert.equal(compassMarkerSlot(90, 100), nearest.slot);
// Across north the slots stay continuous.
assert.deepEqual(
scrollingCompassDivisions(355, 1).map(({ heading, slot }) => [
heading,
slot,
]),
[
[330, -0.8333],
[0, 0.1667],
[30, 1.1667],
],
);
assert.deepEqual(scrollingCompassDivisions(Number.NaN), []);
});
test('compass labels use cardinals where exact and degrees elsewhere', () => {
assert.equal(formatCompassDivision(270), 'W');
assert.equal(formatCompassDivision(30), '030');
+808
View File
@@ -0,0 +1,808 @@
/**
* Camera policy for the orbital (satellite) view: the eye rides the tracked
* satellite and looks straight down at the Earth's centre. Satellites publish
* no attitude, so the picture is turned to the ground track (track-up; see
* nadirOrientation's `headingDeg`), or north-up for a satellite with none
* (geostationary). This module holds the zoom steps,
* the field of view, the nadir orientation, the ground footprint and the zoom
* transition curves. No DOM, Cesium or i18n dependencies: vectors are plain
* `{x, y, z}` objects in Earth-fixed (ECEF) metres, so the policy is
* unit-testable and the Cockpit camera copies them into its own scratch.
*/
/** Mean Earth radius used for footprint and framing geometry, metres. */
export const ORBITAL_EARTH_RADIUS_M = 6371008.8;
/**
* The whole zoom ladder. Steps past ORBITAL_BASE_MAX_ZOOM belong only to
* imaging satellites with a known sensor, each up to the step its sensor's
* swath allows (`orbitalMaxZoom`).
*/
export const ORBITAL_ZOOM_LADDER = Object.freeze([1, 2, 4, 8, 16, 32, 64, 128]);
/** The tightest step for a satellite without sensor data. */
export const ORBITAL_BASE_MAX_ZOOM = 8;
/** The zoom an orbital view opens at. */
export const ORBITAL_DEFAULT_ZOOM = 1;
/**
* Vertical field of view at 1x for low orbits. From the ISS (~420 km) a 60°
* frame spans roughly 490 km of ground top to bottom: a regional picture
* with the curvature just out of frame, which 8x narrows to ~60 km.
*/
export const ORBITAL_BASE_FOV_LEO_RAD = (60 * Math.PI) / 180;
/**
* How much wider than the Earth's disk a high orbit's 1x frame is. A fixed
* 60° frame from GEO (~35,786 km) would show a 17° Earth floating in black;
* above about 8,000 km the base frame instead fits the whole disk with this
* margin, so 1x is "the whole Earth" and the steps zoom into it.
*/
export const ORBITAL_DISK_MARGIN = 1.15;
/** Duration of one zoom step's field-of-view change, ms. */
export const ORBITAL_ZOOM_TRANSITION_MS = 400;
/** How long the reticle shows its lock tick after a step settles, ms. */
export const ORBITAL_LOCK_TICK_MS = 220;
/** Peak focus-pull blur at the middle of a zoom step, CSS pixels. */
export const ORBITAL_MAX_BLUR_PX = 6;
/**
* Longest a zoom step holds its focus pull for the new field of view's
* imagery, ms. The camera moves at orbital speed, so the tiles may never
* all arrive; the hold gives them a start, then the reticle locks anyway.
*/
export const ORBITAL_SETTLE_MAX_MS = 2500;
/** The soft blur held while a step waits for its imagery, CSS pixels. */
export const ORBITAL_SETTLE_BLUR_PX = 2.5;
/** Relative slack so a zoom computed in floating point still meets its step. */
const ZOOM_EPSILON = 1e-6;
/**
* The next tighter ladder step, up to `maxZoom`; at the cap the zoom stays.
* A zoom between steps (the SENSOR step) moves to the step above it.
* @param {number} zoom Current zoom.
* @param {number} [maxZoom] The satellite's tightest step.
* @returns {number}
*/
export function nextOrbitalZoom(zoom, maxZoom = ORBITAL_BASE_MAX_ZOOM) {
const current = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
const next = ORBITAL_ZOOM_LADDER.find(
(step) => step > current * (1 + ZOOM_EPSILON) && step <= maxZoom,
);
return next ?? current;
}
/**
* The next wider ladder step; 1x stays where it is. A zoom between steps
* moves to the step below it.
* @param {number} zoom Current zoom.
* @returns {number}
*/
export function previousOrbitalZoom(zoom) {
const current = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
const wider = ORBITAL_ZOOM_LADDER.filter(
(step) => step < current * (1 - ZOOM_EPSILON),
);
return wider.length ? wider[wider.length - 1] : ORBITAL_DEFAULT_ZOOM;
}
/**
* The ground width the view spans at a zoom: what the SWATH readout shows.
* @param {number} altitudeM Satellite altitude, metres.
* @param {number} zoom Magnification.
* @param {number} aspect Canvas width / height.
* @returns {number|null} Kilometres, or null for an unusable altitude.
*/
export function viewWidthKm(altitudeM, zoom, aspect) {
return footprintWidthKm(
altitudeM,
fovForZoom(orbitalBaseFovRad(altitudeM), zoom),
aspect,
);
}
/**
* A satellite's tightest zoom step at its live altitude: the tightest ladder
* step whose view is still at least as wide as its sensor's narrowest
* standard swath, so the view never shows finer ground than the sensor
* images. Never below ORBITAL_BASE_MAX_ZOOM; no sensor swath, no extra steps.
* @param {number} altitudeM Live altitude, metres.
* @param {number|null|undefined} sensorSwathKm Narrowest standard swath.
* @param {number} aspect Canvas width / height.
* @returns {number} One of ORBITAL_ZOOM_LADDER.
*/
export function orbitalMaxZoom(altitudeM, sensorSwathKm, aspect) {
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0)
return ORBITAL_BASE_MAX_ZOOM;
let max = ORBITAL_BASE_MAX_ZOOM;
for (const step of ORBITAL_ZOOM_LADDER) {
if (step <= ORBITAL_BASE_MAX_ZOOM) continue;
const width = viewWidthKm(altitudeM, step, aspect);
if (!Number.isFinite(width)) break;
if (width * (1 + ZOOM_EPSILON) < sensorSwathKm) break;
max = step;
}
return max;
}
/**
* The zoom at which the view is exactly as wide as the sensor's swath, for
* the SENSOR step; null when that lies outside 1x..`maxZoom` (a swath wider
* than the 1x view, or narrower than the tightest allowed step).
* @param {number} altitudeM Live altitude, metres.
* @param {number|null|undefined} sensorSwathKm
* @param {number} aspect Canvas width / height.
* @param {number} maxZoom The satellite's tightest step.
* @returns {number|null}
*/
export function sensorSwathZoom(altitudeM, sensorSwathKm, aspect, maxZoom) {
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
const widest = viewWidthKm(altitudeM, 1, aspect);
const tightest = viewWidthKm(altitudeM, maxZoom, aspect);
if (!Number.isFinite(widest) || !Number.isFinite(tightest)) return null;
if (sensorSwathKm > widest * (1 + ZOOM_EPSILON)) return null;
if (sensorSwathKm < tightest * (1 - ZOOM_EPSILON)) return null;
// The width falls monotonically with zoom: bisect in log space.
let low = 0;
let high = Math.log2(maxZoom);
for (let i = 0; i < 48; i += 1) {
const mid = (low + high) / 2;
if (viewWidthKm(altitudeM, 2 ** mid, aspect) > sensorSwathKm) low = mid;
else high = mid;
}
return Math.round(2 ** ((low + high) / 2) * 100) / 100;
}
/**
* How much of the view's width a sensor swath covers, for the strip drawn
* to scale in the reticle: 0..1, 1 when the swath fills the view or more.
* @param {number|null|undefined} sensorSwathKm
* @param {number|null|undefined} viewKm The view's current ground width.
* @returns {number|null} Null without both widths.
*/
export function sensorStripFraction(sensorSwathKm, viewKm) {
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
if (!Number.isFinite(viewKm) || viewKm <= 0) return null;
return Math.min(1, sensorSwathKm / viewKm);
}
/**
* Angular radius of the Earth's disk seen from an altitude, radians.
* @param {number} altitudeM Height above the mean sphere, metres.
* @returns {number} Half-angle from nadir to the limb (π/2 at the surface).
*/
export function earthAngularRadiusRad(altitudeM) {
const h = Math.max(0, Number.isFinite(altitudeM) ? altitudeM : 0);
return Math.asin(ORBITAL_EARTH_RADIUS_M / (ORBITAL_EARTH_RADIUS_M + h));
}
/**
* Vertical field of view at 1x for an altitude, radians.
*
* Low orbits get the fixed 60° regional frame. Higher orbits, where 60° would
* leave the Earth small in a black frame, get the Earth's disk plus
* ORBITAL_DISK_MARGIN. The two meet near 8,100 km, so the base never jumps
* as an eccentric orbit climbs: GPS (~20,200 km) opens at ~32°, GEO at ~20°.
* @param {number} altitudeM Satellite altitude, metres.
* @returns {number} Vertical field of view, radians.
*/
export function orbitalBaseFovRad(altitudeM) {
const diskFov = 2 * earthAngularRadiusRad(altitudeM) * ORBITAL_DISK_MARGIN;
return Math.min(ORBITAL_BASE_FOV_LEO_RAD, diskFov);
}
/**
* Field of view at an optical zoom: magnification divides the tangent of
* the half-angle, as a lens's focal length does.
* @param {number} baseFovRad Field of view at 1x, radians.
* @param {number} zoom Magnification (1 leaves the base unchanged).
* @returns {number} Field of view, radians.
*/
export function fovForZoom(baseFovRad, zoom) {
const magnification = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
return 2 * Math.atan(Math.tan(baseFovRad / 2) / magnification);
}
/**
* Cesium's `PerspectiveFrustum.fov` is horizontal on a landscape canvas and
* vertical on a portrait one; convert a vertical field of view to it.
* @param {number} verticalFovRad Vertical field of view, radians.
* @param {number} aspect Canvas width / height.
* @returns {number} The value to assign to `frustum.fov`, radians.
*/
export function cesiumFrustumFovRad(verticalFovRad, aspect) {
if (!(aspect > 1)) return verticalFovRad;
return 2 * Math.atan(Math.tan(verticalFovRad / 2) * aspect);
}
/**
* Ground width covered across the frame, along a great circle through the
* sub-satellite point, km. A ray past the limb ends at the horizon, so a
* frame wider than the disk reports the visible cap's width.
* @param {number} altitudeM Satellite altitude, metres.
* @param {number} fovRad Vertical field of view, radians.
* @param {number} [aspect=1] Frame width / height; the width is measured
* across the wider horizontal extent.
* @returns {number|null} Width in kilometres, or null without a usable input.
*/
export function footprintWidthKm(altitudeM, fovRad, aspect = 1) {
if (!Number.isFinite(altitudeM) || altitudeM <= 0) return null;
if (!Number.isFinite(fovRad) || fovRad <= 0) return null;
const ratio = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
const halfAngle = Math.atan(Math.tan(fovRad / 2) * ratio);
const R = ORBITAL_EARTH_RADIUS_M;
const sinGround = ((R + altitudeM) / R) * Math.sin(halfAngle);
// Central angle from nadir to where the ray meets the sphere, or the limb.
const central =
sinGround >= 1
? Math.acos(R / (R + altitudeM))
: Math.asin(sinGround) - halfAngle;
return (2 * R * central) / 1000;
}
function length(v) {
return Math.hypot(v.x, v.y, v.z);
}
function scale(v, s) {
return { x: v.x * s, y: v.y * s, z: v.z * s };
}
function dot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
function cross(a, b) {
return {
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x,
};
}
/** `v` with its component along unit `d` removed, or null when degenerate. */
function perpendicularUnit(v, d) {
const along = dot(v, d);
const p = {
x: v.x - along * d.x,
y: v.y - along * d.y,
z: v.z - along * d.z,
};
const n = length(p);
return n > 1e-6 ? scale(p, 1 / n) : null;
}
const NORTH_AXIS = Object.freeze({ x: 0, y: 0, z: 1 });
/**
* Camera orientation looking from an Earth-fixed position at the Earth's
* centre, with north at the top of the frame.
*
* `up` is the Earth's rotation axis projected perpendicular to the view, which
* is local north at the sub-satellite point. Directly over a pole north is
* undefined; the previous frame's `up` is kept when given (so a polar pass
* does not spin the picture), otherwise the limit along the prime meridian
* is used: over the north pole the top of the frame faces 180°E, over the
* south pole 0°E.
*
* With `headingDeg` the frame turns so that bearing (degrees clockwise from
* north) is at the top: the ground track, for a view that scrolls along it.
* @param {{x: number, y: number, z: number}} position ECEF position, metres.
* @param {{previousUp?: {x: number, y: number, z: number}|null,
* headingDeg?: number|null}} [options] `previousUp` is a previous frame's
* `north`, not its turned `up`.
* @returns {{direction: {x: number, y: number, z: number},
* up: {x: number, y: number, z: number},
* right: {x: number, y: number, z: number},
* north: {x: number, y: number, z: number}}|null} Unit vectors (right =
* direction × up, as Cesium's camera keeps them), or null at the centre.
*/
export function nadirOrientation(
position,
{ previousUp = null, headingDeg = null } = {},
) {
if (!position) return null;
const r = length(position);
if (!Number.isFinite(r) || r < 1) return null;
const direction = scale(position, -1 / r);
let up = perpendicularUnit(NORTH_AXIS, direction);
if (!up && previousUp && Number.isFinite(length(previousUp)))
up = perpendicularUnit(previousUp, direction);
if (!up) {
// Looking down -z (north pole): north at longitude 0 points along -x.
up = perpendicularUnit(
{ x: direction.z < 0 ? -1 : 1, y: 0, z: 0 },
direction,
);
}
const north = up;
if (Number.isFinite(headingDeg) && headingDeg % 360 !== 0) {
// Looking down, right = direction × north is east: the bearing turns
// north toward it.
const east = cross(direction, north);
const θ = (headingDeg * Math.PI) / 180;
up = {
x: north.x * Math.cos(θ) + east.x * Math.sin(θ),
y: north.y * Math.cos(θ) + east.y * Math.sin(θ),
z: north.z * Math.cos(θ) + east.z * Math.sin(θ),
};
}
return { direction, up, right: cross(direction, up), north };
}
/**
* Ease-in-out progress of a zoom step (cubic), clamped to 0..1.
* @param {number} elapsedMs Time since the step began.
* @param {{durationMs?: number, reducedMotion?: boolean}} [options]
* Reduced motion cuts straight to the end.
* @returns {number} Eased progress, 0..1.
*/
export function zoomTransitionProgress(
elapsedMs,
{ durationMs = ORBITAL_ZOOM_TRANSITION_MS, reducedMotion = false } = {},
) {
if (reducedMotion || !(durationMs > 0)) return 1;
const linear = Math.min(1, Math.max(0, (elapsedMs || 0) / durationMs));
return linear < 0.5 ? 4 * linear ** 3 : 1 - (-2 * linear + 2) ** 3 / 2;
}
/**
* Focus-pull blur for a zoom step: zero at both ends, peaking mid-step.
* @param {number} progress Step progress, 0..1 (linear or eased).
* @param {{maxBlurPx?: number, reducedMotion?: boolean}} [options]
* Reduced motion never blurs.
* @returns {number} Blur radius, CSS pixels.
*/
export function zoomTransitionBlurPx(
progress,
{ maxBlurPx = ORBITAL_MAX_BLUR_PX, reducedMotion = false } = {},
) {
if (reducedMotion || !Number.isFinite(progress)) return 0;
const p = Math.min(1, Math.max(0, progress));
return Math.max(0, maxBlurPx) * Math.sin(Math.PI * p);
}
/**
* Field of view part-way through a zoom step. Interpolates the tangent of
* the half-angle geometrically, so each moment of the step magnifies by the
* same ratio and 1x→2x feels like 4x→8x.
* @param {number} fromFovRad Field of view the step starts at.
* @param {number} toFovRad Field of view the step ends at.
* @param {number} progress Eased progress, 0..1.
* @returns {number} Field of view, radians.
*/
export function interpolateZoomFov(fromFovRad, toFovRad, progress) {
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1));
const a = Math.log(Math.tan(fromFovRad / 2));
const b = Math.log(Math.tan(toFovRad / 2));
return 2 * Math.atan(Math.exp(a + (b - a) * p));
}
/**
* The HUD's transition state at a moment of a zoom step, in the store shape
* `{ active, blurPx, lockTick }`: blur while the field of view moves, then a
* brief reticle lock tick once it settles. Reduced motion skips the blur and
* the tick (the cut is instant).
* @param {number} elapsedMs Time since the step began.
* @param {{durationMs?: number, lockTickMs?: number, maxBlurPx?: number,
* reducedMotion?: boolean}} [options]
* @returns {{active: boolean, progress: number, blurPx: number,
* lockTick: boolean}}
*/
export function zoomTransitionState(
elapsedMs,
{
durationMs = ORBITAL_ZOOM_TRANSITION_MS,
lockTickMs = ORBITAL_LOCK_TICK_MS,
maxBlurPx = ORBITAL_MAX_BLUR_PX,
reducedMotion = false,
} = {},
) {
const progress = zoomTransitionProgress(elapsedMs, {
durationMs,
reducedMotion,
});
if (reducedMotion)
return { active: false, progress: 1, blurPx: 0, lockTick: false };
const elapsed = Math.max(0, elapsedMs || 0);
const linear = durationMs > 0 ? Math.min(1, elapsed / durationMs) : 1;
return {
active: elapsed < durationMs,
progress,
blurPx: zoomTransitionBlurPx(linear, { maxBlurPx }),
lockTick: elapsed >= durationMs && elapsed < durationMs + lockTickMs,
};
}
/**
* Whether a zoom step whose field of view has landed still holds its focus
* pull: until the imagery has loaded, at most `maxHoldMs`.
* @param {number} sinceLandedMs Time since the field of view stopped moving.
* @param {boolean} tilesLoaded Whether the scene's imagery has loaded.
* @param {{maxHoldMs?: number}} [options]
*/
export function zoomSettleHolding(
sinceLandedMs,
tilesLoaded,
{ maxHoldMs = ORBITAL_SETTLE_MAX_MS } = {},
) {
return !tilesLoaded && Math.max(0, sinceLandedMs || 0) < maxHoldMs;
}
/**
* The total angle between a tilted view and nadir, degrees:
* acos(cos north · cos east), as `offNadirOrientation` tilts.
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees.
* @returns {number}
*/
export function offNadirAngleDeg({ northDeg = 0, eastDeg = 0 } = {}) {
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
const cosine = Math.min(1, Math.max(-1, Math.cos(n) * Math.cos(e)));
return (Math.acos(cosine) * 180) / Math.PI;
}
/**
* Whether the view's centre, tilted off nadir, still meets the Earth: its
* angle from nadir (`offNadirAngleDeg`) is within the Earth's angular radius
* from the satellite, asin(R / (R + altitude)).
* @param {number} altitudeM
* @param {{northDeg?: number, eastDeg?: number}} [offset]
* @returns {boolean}
*/
export function offNadirCentreMeetsEarth(altitudeM, offset = {}) {
if (!Number.isFinite(altitudeM) || altitudeM <= 0) return false;
const R = ORBITAL_EARTH_RADIUS_M;
const limbDeg = (Math.asin(R / (R + altitudeM)) * 180) / Math.PI;
return offNadirAngleDeg(offset) < limbDeg;
}
/**
* Roughly how much wider the ground across the frame is at the centre of a
* tilted view than at nadir, on a flat Earth: tilting across the frame (east,
* the frame's right) stretches its width by 1/cos², tilting along it (north,
* the frame's top) by 1/cos. 1 at nadir; an approximation, so the HUD marks
* the width it scales with ≈.
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees.
* @returns {number} At least 1.
*/
export function offNadirSwathScale({ northDeg = 0, eastDeg = 0 } = {}) {
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
const along = Math.abs(Math.cos(n));
const across = Math.cos(e) ** 2;
if (!(along > 1e-6) || !(across > 1e-6)) return 1;
return Math.max(1, 1 / (along * across));
}
/**
* Tilt a nadir orientation off nadir: first toward north (a rotation about
* the nadir frame's right axis), then toward east (about the tilted up
* axis). The base frame is always the nadir one, so a tilt never drifts with
* the orbit; the angle from nadir is acos(cos north · cos east).
* @param {{direction: object, up: object, right: object}} nadir From
* {@link nadirOrientation}.
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees;
* negative is south or west.
* @returns {{direction: object, up: object, right: object}} Unit vectors,
* right = direction × up.
*/
export function offNadirOrientation(nadir, { northDeg = 0, eastDeg = 0 } = {}) {
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
if (n === 0 && e === 0) return nadir;
const { direction: d, up: u, right: r } = nadir;
const combine = (a, ka, b, kb) => ({
x: a.x * ka + b.x * kb,
y: a.y * ka + b.y * kb,
z: a.z * ka + b.z * kb,
});
// Toward north: the view swings onto the up axis, up swings off it.
const d1 = combine(d, Math.cos(n), u, Math.sin(n));
const u1 = combine(u, Math.cos(n), d, -Math.sin(n));
// Toward east: the view swings onto the right axis.
const direction = combine(d1, Math.cos(e), r, Math.sin(e));
return { direction, up: u1, right: cross(direction, u1) };
}
/** Scroll distance, in pixels, that makes one zoom step. A mouse notch is ~100. */
export const ORBITAL_WHEEL_PIXELS_PER_STEP = 100;
/**
* After a step, the rest of that gesture is absorbed for this long, so one
* physical notch (which some browsers deliver as several events) is one step.
*/
export const ORBITAL_WHEEL_COOLDOWN_MS = 200;
/**
* A pause this long ends a scroll gesture: a partial sum left from an earlier
* gesture never adds to a later one.
*/
export const ORBITAL_WHEEL_GESTURE_GAP_MS = 300;
/** `WheelEvent.deltaMode` line and page units, in pixels. */
const WHEEL_LINE_PX = 16;
const WHEEL_PAGE_PX = 800;
/**
* Turn wheel events into zoom steps. Trackpads send many small pixel deltas,
* so they are summed until they reach a step; a change of direction, or a
* pause of ORBITAL_WHEEL_GESTURE_GAP_MS, starts over. Wheeling away from the
* viewer (negative deltaY) zooms in.
* @param {{sum: number, lastStepMs: number, lastEventMs?: number}|null}
* accumulator Previous state.
* @param {{deltaY: number, deltaMode?: number}} wheel The event's deltas.
* @param {number} nowMs
* @returns {{accumulator: {sum: number, lastStepMs: number,
* lastEventMs: number}, step: -1|0|1}} The new state and the step to take
* (1 = zoom in).
*/
export function wheelZoomStep(accumulator, wheel, nowMs) {
const held = accumulator || { sum: 0, lastStepMs: -Infinity };
const previous =
nowMs - (held.lastEventMs ?? -Infinity) > ORBITAL_WHEEL_GESTURE_GAP_MS
? { ...held, sum: 0 }
: held;
const unit =
wheel?.deltaMode === 1
? WHEEL_LINE_PX
: wheel?.deltaMode === 2
? WHEEL_PAGE_PX
: 1;
const delta = (Number.isFinite(wheel?.deltaY) ? wheel.deltaY : 0) * unit;
if (!delta) return { accumulator: held, step: 0 };
if (nowMs - previous.lastStepMs < ORBITAL_WHEEL_COOLDOWN_MS)
return {
accumulator: { ...previous, sum: 0, lastEventMs: nowMs },
step: 0,
};
const sum =
Math.sign(previous.sum) === -Math.sign(delta)
? delta
: previous.sum + delta;
if (Math.abs(sum) < ORBITAL_WHEEL_PIXELS_PER_STEP)
return { accumulator: { ...previous, sum, lastEventMs: nowMs }, step: 0 };
return {
accumulator: { sum: 0, lastStepMs: nowMs, lastEventMs: nowMs },
step: sum < 0 ? 1 : -1,
};
}
/** From this zoom the view pre-loads imagery ahead along the ground track. */
export const ORBITAL_PREFETCH_MIN_ZOOM = 16;
/** How often the pre-load looks ahead, ms. */
export const ORBITAL_PREFETCH_INTERVAL_MS = 2000;
/** How far ahead it looks, seconds of ground-track travel, nearest first. */
export const ORBITAL_PREFETCH_LEAD_S = Object.freeze([5, 10, 15, 20]);
/**
* Where across the track it looks, as fractions of the view's width: the
* centre line, a third out and the frame's edges each side, so a row of
* tiles a fifth of the view wide or more spans the frame.
*/
export const ORBITAL_PREFETCH_ACROSS = Object.freeze([
0,
-1 / 3,
1 / 3,
-1 / 2,
1 / 2,
]);
/**
* The points ahead, regrouped by their place across the track: every lead's
* centre point first, then each lead's next pair out, so the tile cap trims
* the frame's edges before it trims the far leads.
* @param {Array<{lat: number, lon: number}>} points From `groundTrackAhead`,
* lead by lead.
* @param {number} [across] Points per lead.
* @returns {Array<{lat: number, lon: number}>}
*/
export function centreLinesFirst(
points,
across = ORBITAL_PREFETCH_ACROSS.length,
) {
const out = [];
for (let k = 0; k < across; k += 1)
for (let i = k; i < points.length; i += across) out.push(points[i]);
return out;
}
/** At most this many tile requests per look-ahead. */
export const ORBITAL_PREFETCH_MAX_TILES = 12;
const toRadians = (deg) => (deg * Math.PI) / 180;
const toDegrees = (rad) => (rad * 180) / Math.PI;
/**
* The point `distanceM` from a point along an initial bearing, on a sphere.
* @returns {{lat: number, lon: number}} Degrees, longitude in -180..180.
*/
export function destinationPoint(latDeg, lonDeg, bearingDeg, distanceM) {
const delta = distanceM / ORBITAL_EARTH_RADIUS_M;
const lat1 = toRadians(latDeg);
const theta = toRadians(bearingDeg);
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(delta) +
Math.cos(lat1) * Math.sin(delta) * Math.cos(theta),
);
const lon2 =
toRadians(lonDeg) +
Math.atan2(
Math.sin(theta) * Math.sin(delta) * Math.cos(lat1),
Math.cos(delta) - Math.sin(lat1) * Math.sin(lat2),
);
const lon = ((((toDegrees(lon2) + 180) % 360) + 360) % 360) - 180;
return { lat: toDegrees(lat2), lon };
}
/**
* Points the view will pass over: for each lead time, the sub-satellite
* point that far along the ground track (orbital speed scaled down to the
* ground), and points across the track at `across` fractions of the view's
* width. Nearest lead first. Empty without a track, speed or width.
* @param {{latitude: number, longitude: number, groundTrackDeg: number,
* velocityMps: number, altitudeM: number}} info Satellite subject.
* @param {number} viewKm The view's ground width, km.
* @returns {Array<{lat: number, lon: number}>}
*/
export function groundTrackAhead(
info,
viewKm,
{ leadS = ORBITAL_PREFETCH_LEAD_S, across = ORBITAL_PREFETCH_ACROSS } = {},
) {
const { latitude, longitude, groundTrackDeg, velocityMps, altitudeM } =
info || {};
if (
![latitude, longitude, groundTrackDeg, velocityMps, altitudeM].every(
Number.isFinite,
) ||
velocityMps <= 0 ||
altitudeM <= 0 ||
!(viewKm > 0)
)
return [];
const R = ORBITAL_EARTH_RADIUS_M;
const groundMps = (velocityMps * R) / (R + altitudeM);
const points = [];
for (const seconds of leadS) {
const centre = destinationPoint(
latitude,
longitude,
groundTrackDeg,
groundMps * seconds,
);
for (const fraction of across) {
points.push(
fraction === 0
? centre
: destinationPoint(
centre.lat,
centre.lon,
groundTrackDeg + (fraction > 0 ? 90 : -90),
Math.abs(fraction) * viewKm * 1000,
),
);
}
}
return points;
}
/** WGS84's equatorial radius: Cesium sizes terrain and imagery tiles on it. */
const WGS84_MAXIMUM_RADIUS_M = 6378137;
/**
* Cesium's geometric error for a heightmap terrain tile at `level`, metres:
* its level-zero estimate (TerrainProvider's
* getEstimatedLevelZeroGeometricErrorForAHeightmap, heightmap quality 0.25)
* for the ellipsoid terrain's 64-sample tiles on two root tiles, halved per
* level.
* @param {number} level
* @returns {number}
*/
export function ellipsoidTerrainLevelErrorM(level) {
return (WGS84_MAXIMUM_RADIUS_M * 2 * Math.PI * 0.25) / (64 * 2) / 2 ** level;
}
/** The deepest terrain level considered. */
const MAX_TERRAIN_LEVEL = 30;
/**
* The imagery level Cesium draws under the view, and so the one to pre-load.
* Cesium refines the terrain tile under the view while its geometric error
* spans more than `maximumScreenSpaceError` pixels (QuadtreePrimitive's
* screenSpaceError: error over the ground a pixel covers), then drapes the
* imagery level whose texel spacing is nearest that error (ImageryLayer's
* getLevelWithMaximumTexelSpacing, rounded): about one to two screen pixels
* per texel, not one. Fog, which below 800 km can only coarsen the choice,
* is left out. Clamped to the provider's levels; null without a usable input.
* @param {{getNumberOfXTilesAtLevel?: (level: number) => number,
* rectangle?: {width: number}}} tilingScheme The imagery tiling scheme.
* @param {{metresPerPixel: number, latitudeDeg: number,
* geographic?: boolean, tileWidthPx?: number, minimumLevel?: number,
* maximumLevel?: number, maximumScreenSpaceError?: number,
* terrainLevelErrorM?: (level: number) => number}} view `metresPerPixel`
* is the ground one CSS pixel covers at the view's centre; `geographic`
* marks a scheme in the geographic projection, whose texels do not narrow
* with latitude as Web Mercator's do; `terrainLevelErrorM` is the terrain
* provider's getLevelMaximumGeometricError.
* @returns {number|null}
*/
export function imageryLevelForView(
tilingScheme,
{
metresPerPixel,
latitudeDeg,
geographic = false,
tileWidthPx = 256,
minimumLevel = 0,
maximumLevel = 30,
maximumScreenSpaceError = 2,
terrainLevelErrorM = ellipsoidTerrainLevelErrorM,
},
) {
if (!(metresPerPixel > 0) || !Number.isFinite(latitudeDeg)) return null;
const sse = maximumScreenSpaceError > 0 ? maximumScreenSpaceError : 2;
let terrainLevel = 0;
while (
terrainLevel < MAX_TERRAIN_LEVEL &&
!(terrainLevelErrorM(terrainLevel) / metresPerPixel <= sse)
)
terrainLevel += 1;
const errorM = terrainLevelErrorM(terrainLevel);
if (!(errorM > 0)) return null;
const rootTiles = tilingScheme?.getNumberOfXTilesAtLevel?.(0) || 1;
const widthRad = tilingScheme?.rectangle?.width || 2 * Math.PI;
const latitudeFactor = geographic
? 1
: Math.cos(toRadians(Math.min(89, Math.abs(latitudeDeg))));
const levelZeroTexelM =
(WGS84_MAXIMUM_RADIUS_M * widthRad * latitudeFactor) /
((tileWidthPx || 256) * rootTiles);
const level = Math.max(0, Math.round(Math.log2(levelZeroTexelM / errorM)));
if (!Number.isFinite(level)) return null;
const top = Number.isFinite(maximumLevel) ? maximumLevel : 30;
const bottom = Number.isFinite(minimumLevel) ? minimumLevel : 0;
return Math.min(top, Math.max(bottom, level));
}
/**
* The tiles at `level` holding `points`, in order, without repeats or tiles
* `skip` reports as already asked for, at most `maxTiles`.
* @param {{positionToTileXY: Function}} tilingScheme Cesium tiling scheme.
* @param {Array<{lat: number, lon: number}>} points Degrees.
* @param {number} level
* @param {{maxTiles?: number, skip?: (key: string) => boolean}} [options]
* @returns {Array<{x: number, y: number, level: number, key: string}>}
*/
export function tilesForPoints(
tilingScheme,
points,
level,
{ maxTiles = ORBITAL_PREFETCH_MAX_TILES, skip = () => false } = {},
) {
const tiles = [];
if (typeof tilingScheme?.positionToTileXY !== 'function') return tiles;
if (!Number.isInteger(level) || level < 0) return tiles;
const seen = new Set();
for (const point of points) {
if (tiles.length >= maxTiles) break;
const xy = tilingScheme.positionToTileXY(
{ longitude: toRadians(point.lon), latitude: toRadians(point.lat) },
level,
);
if (!xy) continue;
const key = `${level}/${xy.x}/${xy.y}`;
if (seen.has(key) || skip(key)) continue;
seen.add(key);
tiles.push({ x: xy.x, y: xy.y, level, key });
}
return tiles;
}
+618
View File
@@ -0,0 +1,618 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ORBITAL_ZOOM_LADDER,
ORBITAL_BASE_FOV_LEO_RAD,
ORBITAL_EARTH_RADIUS_M,
ORBITAL_SETTLE_MAX_MS,
ORBITAL_WHEEL_COOLDOWN_MS,
cesiumFrustumFovRad,
earthAngularRadiusRad,
footprintWidthKm,
fovForZoom,
interpolateZoomFov,
nadirOrientation,
nextOrbitalZoom,
offNadirAngleDeg,
offNadirCentreMeetsEarth,
centreLinesFirst,
offNadirSwathScale,
orbitalMaxZoom,
sensorStripFraction,
sensorSwathZoom,
viewWidthKm,
wheelZoomStep,
ORBITAL_WHEEL_GESTURE_GAP_MS,
offNadirOrientation,
orbitalBaseFovRad,
previousOrbitalZoom,
zoomSettleHolding,
zoomTransitionBlurPx,
zoomTransitionProgress,
zoomTransitionState,
ORBITAL_PREFETCH_ACROSS,
ORBITAL_PREFETCH_LEAD_S,
ORBITAL_PREFETCH_MAX_TILES,
destinationPoint,
groundTrackAhead,
imageryLevelForView,
tilesForPoints,
} from './cockpitOrbital.js';
import * as Cesium from 'cesium';
import { greatCircleKm } from './geoDistance.js';
const RAD = Math.PI / 180;
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
const norm = (v) => Math.hypot(v.x, v.y, v.z);
/** ECEF position on the mean sphere at a latitude, longitude and altitude. */
function ecef(latDeg, lonDeg, altitudeM) {
const r = ORBITAL_EARTH_RADIUS_M + altitudeM;
const lat = latDeg * RAD;
const lon = lonDeg * RAD;
return {
x: r * Math.cos(lat) * Math.cos(lon),
y: r * Math.cos(lat) * Math.sin(lon),
z: r * Math.sin(lat),
};
}
/** Local north unit vector at a latitude and longitude. */
function localNorth(latDeg, lonDeg) {
const lat = latDeg * RAD;
const lon = lonDeg * RAD;
return {
x: -Math.sin(lat) * Math.cos(lon),
y: -Math.sin(lat) * Math.sin(lon),
z: Math.cos(lat),
};
}
function assertOrthonormal(orientation) {
const { direction, up, right } = orientation;
for (const v of [direction, up, right])
assert.ok(Math.abs(norm(v) - 1) < 1e-9);
assert.ok(Math.abs(dot(direction, up)) < 1e-9);
assert.ok(Math.abs(dot(direction, right)) < 1e-9);
assert.ok(Math.abs(dot(up, right)) < 1e-9);
}
test('the nadir view points at the Earth centre with north up', () => {
for (const [lat, lon, alt] of [
[0, 0, 420e3],
[51.6, -97.3, 420e3],
[-33.9, 151.2, 20.2e6],
[0.05, 75, 35.786e6],
]) {
const position = ecef(lat, lon, alt);
const orientation = nadirOrientation(position);
const toCentre = {
x: -position.x / norm(position),
y: -position.y / norm(position),
z: -position.z / norm(position),
};
assert.ok(dot(orientation.direction, toCentre) > 1 - 1e-12);
assert.ok(dot(orientation.up, localNorth(lat, lon)) > 1 - 1e-9);
assertOrthonormal(orientation);
// North up puts east on the right of a picture looking down.
const east = { x: -Math.sin(lon * RAD), y: Math.cos(lon * RAD), z: 0 };
assert.ok(dot(orientation.right, east) > 1 - 1e-9);
}
});
test('over a pole the orientation stays valid and keeps the previous up', () => {
for (const z of [1, -1]) {
const position = { x: 0, y: 0, z: z * (ORBITAL_EARTH_RADIUS_M + 800e3) };
const fallback = nadirOrientation(position);
assertOrthonormal(fallback);
assert.equal(fallback.direction.z, -z);
// The prime-meridian limit of north.
assert.ok(Math.abs(fallback.up.x - -z) < 1e-9);
const kept = nadirOrientation(position, {
previousUp: { x: 0, y: 1, z: 0 },
});
assertOrthonormal(kept);
assert.ok(kept.up.y > 1 - 1e-9);
}
// Just off the pole, north still points over it.
const nearPole = nadirOrientation(ecef(89.9999, 30, 500e3));
assertOrthonormal(nearPole);
assert.ok(dot(nearPole.up, localNorth(89.9999, 30)) > 1 - 1e-6);
});
test('a degenerate position has no orientation', () => {
assert.equal(nadirOrientation(null), null);
assert.equal(nadirOrientation({ x: 0, y: 0, z: 0 }), null);
assert.equal(nadirOrientation({ x: NaN, y: 0, z: 0 }), null);
});
test('each zoom step divides the tangent of the half-angle', () => {
const base = ORBITAL_BASE_FOV_LEO_RAD;
for (const zoom of ORBITAL_ZOOM_LADDER) {
const fov = fovForZoom(base, zoom);
assert.ok(Math.abs(Math.tan(fov / 2) - Math.tan(base / 2) / zoom) < 1e-12);
}
assert.ok(
Math.abs(
Math.tan(fovForZoom(base, 4) / 2) / Math.tan(fovForZoom(base, 2) / 2) -
0.5,
) < 1e-12,
);
assert.equal(fovForZoom(base, 1), base);
assert.equal(fovForZoom(base, 0), base);
});
test('zoom steps clamp at both ends without wrapping', () => {
assert.equal(nextOrbitalZoom(1), 2);
assert.equal(nextOrbitalZoom(4), 8);
assert.equal(nextOrbitalZoom(8), 8);
assert.equal(previousOrbitalZoom(8), 4);
assert.equal(previousOrbitalZoom(2), 1);
assert.equal(previousOrbitalZoom(1), 1);
assert.equal(nextOrbitalZoom(undefined), 2);
});
test('the base frame is regional in low orbit and fits the disk from GEO', () => {
assert.equal(orbitalBaseFovRad(420e3), ORBITAL_BASE_FOV_LEO_RAD);
const geo = orbitalBaseFovRad(35.786e6);
const disk = 2 * earthAngularRadiusRad(35.786e6);
assert.ok(geo > disk && geo < ORBITAL_BASE_FOV_LEO_RAD);
// Continuous across the crossover: no jump as an orbit climbs.
let previous = orbitalBaseFovRad(1e6);
for (let alt = 1e6; alt <= 40e6; alt += 50e3) {
const fov = orbitalBaseFovRad(alt);
assert.ok(fov <= previous + 1e-12);
assert.ok(previous - fov < 0.01);
previous = fov;
}
});
test('the footprint shrinks with zoom and stops at the limb', () => {
const alt = 420e3;
const widths = [1, 2, 4, 8].map((zoom) =>
footprintWidthKm(alt, fovForZoom(orbitalBaseFovRad(alt), zoom)),
);
for (let i = 1; i < widths.length; i++) assert.ok(widths[i] < widths[i - 1]);
assert.ok(widths[0] > 400 && widths[0] < 600, `1x ${widths[0]} km`);
assert.ok(widths[3] > 40 && widths[3] < 80, `8x ${widths[3]} km`);
// A wider frame covers more ground across.
assert.ok(
footprintWidthKm(alt, ORBITAL_BASE_FOV_LEO_RAD, 16 / 9) > widths[0],
);
// From GEO a frame wider than the disk spans the visible cap only.
const cap =
(2 *
ORBITAL_EARTH_RADIUS_M *
Math.acos(ORBITAL_EARTH_RADIUS_M / (ORBITAL_EARTH_RADIUS_M + 35.786e6))) /
1000;
assert.ok(Math.abs(footprintWidthKm(35.786e6, 40 * RAD) - cap) < 1e-6);
assert.equal(footprintWidthKm(0, 1), null);
assert.equal(footprintWidthKm(420e3, NaN), null);
});
test('Cesium frustum fov widens to the horizontal on a landscape canvas', () => {
const vertical = 30 * RAD;
assert.equal(cesiumFrustumFovRad(vertical, 0.75), vertical);
const horizontal = cesiumFrustumFovRad(vertical, 2);
assert.ok(
Math.abs(Math.tan(horizontal / 2) - 2 * Math.tan(vertical / 2)) < 1e-12,
);
});
test('zoom transition curves stay bounded and settle', () => {
assert.equal(zoomTransitionProgress(0), 0);
assert.equal(zoomTransitionProgress(400), 1);
assert.equal(zoomTransitionProgress(9999), 1);
assert.equal(zoomTransitionProgress(-50), 0);
assert.equal(zoomTransitionProgress(0, { reducedMotion: true }), 1);
let previous = 0;
for (let ms = 0; ms <= 500; ms += 10) {
const p = zoomTransitionProgress(ms);
assert.ok(p >= previous && p >= 0 && p <= 1);
previous = p;
const blur = zoomTransitionBlurPx(ms / 400);
assert.ok(blur >= 0 && blur <= 6);
}
assert.equal(zoomTransitionBlurPx(0), 0);
assert.ok(zoomTransitionBlurPx(1) < 1e-9);
assert.ok(Math.abs(zoomTransitionBlurPx(0.5) - 6) < 1e-9);
assert.equal(zoomTransitionBlurPx(0.5, { reducedMotion: true }), 0);
const from = fovForZoom(1, 1);
const to = fovForZoom(1, 2);
assert.equal(interpolateZoomFov(from, to, 0).toFixed(12), from.toFixed(12));
assert.equal(interpolateZoomFov(from, to, 1).toFixed(12), to.toFixed(12));
const mid = interpolateZoomFov(from, to, 0.5);
assert.ok(mid < from && mid > to);
});
test('the transition state blurs, then ticks the lock, then rests', () => {
const moving = zoomTransitionState(200);
assert.equal(moving.active, true);
assert.ok(moving.blurPx > 5);
assert.equal(moving.lockTick, false);
const settled = zoomTransitionState(450);
assert.equal(settled.active, false);
assert.equal(settled.lockTick, true);
assert.equal(settled.progress, 1);
assert.ok(settled.blurPx < 1e-9);
const rest = zoomTransitionState(2000);
assert.equal(rest.lockTick, false);
assert.deepEqual(zoomTransitionState(100, { reducedMotion: true }), {
active: false,
progress: 1,
blurPx: 0,
lockTick: false,
});
});
test('an off-nadir tilt turns the view by its angle from the nadir frame', () => {
const nadir = nadirOrientation(ecef(30, -97, 420_000));
assert.equal(offNadirOrientation(nadir), nadir, 'no tilt is nadir itself');
const angle = (v) => Math.acos(Math.min(1, dot(v, nadir.direction))) / RAD;
const north = offNadirOrientation(nadir, { northDeg: 10 });
assert.ok(Math.abs(angle(north.direction) - 10) < 1e-9);
assert.ok(dot(north.direction, nadir.up) > 0, 'toward north');
assert.ok(Math.abs(dot(north.direction, nadir.right)) < 1e-12);
const west = offNadirOrientation(nadir, { eastDeg: -20 });
assert.ok(Math.abs(angle(west.direction) - 20) < 1e-9);
assert.ok(dot(west.direction, nadir.right) < 0, 'toward west');
const both = offNadirOrientation(nadir, { northDeg: 45, eastDeg: 45 });
const expected = Math.acos(Math.cos(45 * RAD) ** 2) / RAD;
assert.ok(Math.abs(angle(both.direction) - expected) < 1e-9);
for (const view of [north, west, both]) {
assert.ok(Math.abs(norm(view.direction) - 1) < 1e-12);
assert.ok(Math.abs(norm(view.up) - 1) < 1e-12);
assert.ok(Math.abs(dot(view.direction, view.up)) < 1e-12);
}
});
test('a landed zoom step holds for its imagery, but never past the cap', () => {
assert.equal(zoomSettleHolding(0, false), true, 'still loading');
assert.equal(zoomSettleHolding(ORBITAL_SETTLE_MAX_MS - 1, false), true);
assert.equal(zoomSettleHolding(ORBITAL_SETTLE_MAX_MS, false), false, 'cap');
assert.equal(zoomSettleHolding(0, true), false, 'loaded: lock at once');
assert.equal(zoomSettleHolding(Number.NaN, false), true);
});
const WIDE = 16 / 9;
test("the zoom ladder climbs to a satellite's own cap and steps off a SENSOR zoom", () => {
assert.equal(nextOrbitalZoom(8), 8, 'no sensor: 8x is the top');
assert.equal(nextOrbitalZoom(8, 64), 16);
assert.equal(nextOrbitalZoom(32, 64), 64);
assert.equal(nextOrbitalZoom(64, 64), 64, 'the cap holds');
assert.equal(nextOrbitalZoom(5.7, 64), 8, 'between steps: the step above');
assert.equal(previousOrbitalZoom(5.7), 4, 'between steps: the step below');
assert.equal(previousOrbitalZoom(128), 64);
assert.equal(previousOrbitalZoom(1), 1);
});
test('a sensor caps the zoom at its swath for the live altitude', () => {
// WorldView-3 at 617 km, 13.1 km swath: 64x spans ~20 km, 128x ~10 km.
assert.ok(viewWidthKm(617e3, 64, WIDE) >= 13.1);
assert.ok(viewWidthKm(617e3, 128, WIDE) < 13.1);
assert.equal(orbitalMaxZoom(617e3, 13.1, WIDE), 64);
// ICEYE spotlight, 5 km at 570 km.
assert.equal(orbitalMaxZoom(570e3, 5, WIDE), 128);
// PlanetScope SuperDove, 32.5 km: 32x at 525 km but 16x at 470 km.
assert.equal(orbitalMaxZoom(525e3, 32.5, WIDE), 32);
assert.equal(orbitalMaxZoom(470e3, 32.5, WIDE), 16);
// Landsat's 185 km swath fits inside 8x: no extra steps, never fewer.
assert.equal(orbitalMaxZoom(705e3, 185, WIDE), 8);
assert.equal(orbitalMaxZoom(705e3, 2330, WIDE), 8);
// No sensor, or an unusable swath.
assert.equal(orbitalMaxZoom(617e3, null, WIDE), 8);
assert.equal(orbitalMaxZoom(617e3, 0, WIDE), 8);
// A swath exactly as wide as the 16x view allows 16x.
assert.equal(orbitalMaxZoom(617e3, viewWidthKm(617e3, 16, WIDE), WIDE), 16);
});
test('the SENSOR step frames the swath when it lies in range', () => {
const landsat = sensorSwathZoom(705e3, 185, WIDE, 8);
assert.ok(landsat > 4 && landsat < 8, String(landsat));
assert.ok(Math.abs(viewWidthKm(705e3, landsat, WIDE) - 185) < 1);
const worldview = sensorSwathZoom(617e3, 13.1, WIDE, 64);
assert.equal(worldview, null, 'narrower than the 64x cap allows');
assert.equal(sensorSwathZoom(705e3, 2330, WIDE, 8), null, 'wider than 1x');
assert.equal(sensorSwathZoom(705e3, null, WIDE, 8), null);
});
test('the sensor strip spans its share of the view', () => {
assert.equal(sensorStripFraction(13, 130), 0.1);
assert.equal(sensorStripFraction(500, 131), 1, 'clipped to the view');
assert.equal(sensorStripFraction(null, 131), null);
assert.equal(sensorStripFraction(13.1, null), null);
});
test('the wheel steps once per notch and sums a trackpad swipe', () => {
// A mouse notch away from the viewer zooms in.
let r = wheelZoomStep(null, { deltaY: -100, deltaMode: 0 }, 1000);
assert.equal(r.step, 1);
// The rest of that notch, inside the cooldown, is absorbed.
r = wheelZoomStep(r.accumulator, { deltaY: -100, deltaMode: 0 }, 1050);
assert.equal(r.step, 0);
// A notch toward the viewer after the cooldown zooms out.
r = wheelZoomStep(
r.accumulator,
{ deltaY: 120, deltaMode: 0 },
1050 + ORBITAL_WHEEL_COOLDOWN_MS + 1,
);
assert.equal(r.step, -1);
// A trackpad's small deltas add up before they step.
let pad = null;
const steps = [];
for (let i = 0; i < 4; i += 1) {
const out = wheelZoomStep(
pad,
{ deltaY: -30, deltaMode: 0 },
5000 + i * 16,
);
pad = out.accumulator;
steps.push(out.step);
}
assert.deepEqual(steps, [0, 0, 0, 1]);
// Turning back starts the sum over.
const turned = wheelZoomStep(
{ sum: -90, lastStepMs: -Infinity },
{ deltaY: 30, deltaMode: 0 },
9000,
);
assert.equal(turned.step, 0);
assert.equal(turned.accumulator.sum, 30);
// Line-mode wheels (Firefox): three lines make a notch.
assert.equal(wheelZoomStep(null, { deltaY: -3, deltaMode: 1 }, 0).step, 0);
assert.equal(wheelZoomStep(null, { deltaY: -7, deltaMode: 1 }, 0).step, 1);
assert.equal(wheelZoomStep(null, { deltaY: 0 }, 0).step, 0);
});
test('a heading turns the nadir frame, keeping north to fall back on', () => {
// Over the equator at 0°E: north is +z, east is +y.
const position = { x: ORBITAL_EARTH_RADIUS_M + 500_000, y: 0, z: 0 };
const northUp = nadirOrientation(position);
const east = nadirOrientation(position, { headingDeg: 90 });
const close = (a, b) =>
Math.abs(a.x - b.x) < 1e-9 &&
Math.abs(a.y - b.y) < 1e-9 &&
Math.abs(a.z - b.z) < 1e-9;
assert.ok(close(northUp.up, { x: 0, y: 0, z: 1 }));
assert.ok(close(east.up, { x: 0, y: 1, z: 0 }), 'east at the top');
assert.ok(close(east.north, northUp.up), 'north kept for the next frame');
assert.ok(
close(nadirOrientation(position, { headingDeg: 0 }).up, northUp.up),
);
});
test('a partial scroll does not carry over into a later gesture', () => {
// 90 px: short of a step.
let r = wheelZoomStep(null, { deltaY: -90, deltaMode: 0 }, 1000);
assert.equal(r.step, 0);
// Still the same gesture: 10 px more completes the step.
const same = wheelZoomStep(
r.accumulator,
{ deltaY: -10, deltaMode: 0 },
1100,
);
assert.equal(same.step, 1);
// Minutes later a 10 px nudge is a new gesture, not the rest of the old.
r = wheelZoomStep(
r.accumulator,
{ deltaY: -10, deltaMode: 0 },
1000 + ORBITAL_WHEEL_GESTURE_GAP_MS + 60_000,
);
assert.equal(r.step, 0);
});
test('the total off-nadir angle combines both tilts', () => {
assert.equal(offNadirAngleDeg(), 0);
assert.ok(Math.abs(offNadirAngleDeg({ northDeg: 10 }) - 10) < 1e-9);
// FWD 15° and RIGHT 10° read OFF-NADIR 18.0°.
const total = offNadirAngleDeg({ northDeg: 15, eastDeg: 10 });
assert.equal(total.toFixed(1), '18.0');
assert.equal(offNadirAngleDeg({ northDeg: -15, eastDeg: -10 }), total);
});
test("a tilted view's width grows faster across the frame than along it", () => {
assert.equal(offNadirSwathScale(), 1, 'nadir');
const cos = (deg) => Math.cos((deg * Math.PI) / 180);
assert.ok(
Math.abs(offNadirSwathScale({ northDeg: 30 }) - 1 / cos(30)) < 1e-9,
);
assert.ok(
Math.abs(offNadirSwathScale({ eastDeg: 30 }) - 1 / cos(30) ** 2) < 1e-9,
);
let last = 1;
for (const deg of [5, 15, 25, 35, 45]) {
const scale = offNadirSwathScale({ northDeg: deg, eastDeg: deg });
assert.ok(scale > last, `${deg}°`);
last = scale;
}
assert.equal(offNadirSwathScale({ northDeg: Number.NaN }), 1);
});
// ── imagery pre-load ahead of the ground track ─────────────────────────
const PASS = Object.freeze({
latitude: 30,
longitude: -97,
groundTrackDeg: 45,
velocityMps: 7_600,
altitudeM: 500_000,
});
test('destinationPoint walks a great circle along a bearing', () => {
const east = destinationPoint(0, 179.9, 90, 111_195 * 0.2);
assert.ok(Math.abs(east.lat) < 1e-9);
assert.ok(Math.abs(east.lon - -179.9) < 1e-3, 'wraps the antimeridian');
const north = destinationPoint(10, 20, 0, 50_000);
assert.ok(Math.abs(greatCircleKm({ lat: 10, lon: 20 }, north) - 50) < 0.1);
assert.ok(north.lat > 10 && Math.abs(north.lon - 20) < 1e-9);
});
test('the points ahead run along the ground track at ground speed', () => {
const points = groundTrackAhead(PASS, 20);
const across = ORBITAL_PREFETCH_ACROSS.length;
assert.equal(points.length, ORBITAL_PREFETCH_LEAD_S.length * across);
// Ground speed is the orbital speed scaled to the surface: ~7.0 km/s.
const groundKmPerS = (7.6 * 6371) / (6371 + 500);
const start = { lat: PASS.latitude, lon: PASS.longitude };
ORBITAL_PREFETCH_LEAD_S.forEach((seconds, i) => {
const centre = points[i * across];
const km = greatCircleKm(start, centre);
assert.ok(Math.abs(km - groundKmPerS * seconds) < 0.5, `${seconds} s`);
// North-east of the start, on a 45° track.
assert.ok(centre.lat > PASS.latitude && centre.lon > PASS.longitude);
// The side points sit their fraction of the view off the centre line.
points.slice(i * across + 1, (i + 1) * across).forEach((side, k) => {
const offKm = 20 * Math.abs(ORBITAL_PREFETCH_ACROSS[k + 1]);
assert.ok(Math.abs(greatCircleKm(centre, side) - offKm) < 0.05);
});
});
assert.deepEqual(groundTrackAhead({ ...PASS, groundTrackDeg: null }, 20), []);
assert.deepEqual(groundTrackAhead(PASS, null), []);
});
test('the tile level is the one Cesium draws for the view', () => {
const mercator = new Cesium.WebMercatorTilingScheme();
// Worked case: 10 m per CSS pixel at the equator. Cesium refines the
// ellipsoid terrain until a tile's error is at most 2 px, 20 m: level 12,
// 78,271.5 m / 2^12 = 19.11 m. The Web Mercator texel nearest that is
// level 13's (156,543 m / 2^13 = 19.11 m), about 1.9 px each, where a
// one-pixel texel would have asked for level 14.
assert.equal(
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 0 }),
13,
);
// 5 m/px: terrain level 13 (9.55 m), imagery level 14.
assert.equal(
imageryLevelForView(mercator, { metresPerPixel: 5, latitudeDeg: 0 }),
14,
);
// 7 m/px still takes terrain level 13 (9.55 m <= 14 m): level 14, so a
// texel spans 1.4 px.
assert.equal(
imageryLevelForView(mercator, { metresPerPixel: 7, latitudeDeg: 0 }),
14,
);
// At 60° a Mercator texel covers half the ground: one level coarser.
assert.equal(
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 60 }),
12,
);
// A geographic scheme starts from two root tiles and keeps its texels at
// every latitude.
const geographic = new Cesium.GeographicTilingScheme();
for (const latitudeDeg of [0, 60])
assert.equal(
imageryLevelForView(geographic, {
metresPerPixel: 10,
latitudeDeg,
geographic: true,
}),
12,
`${latitudeDeg}°`,
);
// A terrain provider's own errors (Cesium World Terrain: 65-sample tiles)
// and a looser screen-space error are honoured.
const worldTerrain = (level) =>
(6378137 * 2 * Math.PI * 0.25) / (65 * 2) / 2 ** level;
assert.equal(
imageryLevelForView(mercator, {
metresPerPixel: 10,
latitudeDeg: 0,
terrainLevelErrorM: worldTerrain,
}),
13,
);
assert.equal(
imageryLevelForView(mercator, {
metresPerPixel: 10,
latitudeDeg: 0,
maximumScreenSpaceError: 4,
}),
12,
);
// Clamped to the provider's levels.
assert.equal(
imageryLevelForView(mercator, {
metresPerPixel: 5,
latitudeDeg: 0,
maximumLevel: 12,
}),
12,
);
assert.equal(
imageryLevelForView(mercator, { metresPerPixel: 0, latitudeDeg: 0 }),
null,
);
});
test('the tiles ahead are the ones the points fall in, bounded and new', () => {
const mercator = new Cesium.WebMercatorTilingScheme();
const points = groundTrackAhead(PASS, 20);
const tiles = tilesForPoints(mercator, points, 13);
assert.ok(tiles.length > 0 && tiles.length <= ORBITAL_PREFETCH_MAX_TILES);
assert.equal(new Set(tiles.map((tile) => tile.key)).size, tiles.length);
// Each tile holds its point: the first is the centre line 5 s ahead.
const first = mercator.tileXYToRectangle(tiles[0].x, tiles[0].y, 13);
assert.ok(
Cesium.Rectangle.contains(
first,
Cesium.Cartographic.fromDegrees(points[0].lon, points[0].lat),
),
);
// Ahead along a north-east track: rows north (smaller y), columns east.
const here = mercator.positionToTileXY(
Cesium.Cartographic.fromDegrees(PASS.longitude, PASS.latitude),
13,
);
assert.ok(tiles.every((tile) => tile.x >= here.x && tile.y <= here.y));
// Bounded, and a tile already asked for is skipped.
assert.equal(tilesForPoints(mercator, points, 15, { maxTiles: 3 }).length, 3);
const asked = new Set(
tilesForPoints(mercator, points, 13, { maxTiles: Infinity }).map(
(tile) => tile.key,
),
);
assert.deepEqual(
tilesForPoints(mercator, points, 13, { skip: (key) => asked.has(key) }),
[],
);
assert.deepEqual(tilesForPoints({}, points, 13), []);
});
test('a view tilted past the limb no longer meets the Earth', () => {
// GEO: the Earth spans about 8.7 degrees either side of nadir.
assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 5 }), true);
assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 15 }), false);
// LEO: a 30 degree tilt still lands on the ground.
assert.equal(
offNadirCentreMeetsEarth(420_000, { northDeg: 20, eastDeg: 20 }),
true,
);
assert.equal(offNadirCentreMeetsEarth(420_000, { eastDeg: 80 }), false);
assert.equal(offNadirCentreMeetsEarth(NaN, {}), false);
});
test('the centre line of every lead comes before the frame edges', () => {
const points = groundTrackAhead(PASS, 20);
const across = ORBITAL_PREFETCH_ACROSS.length;
const ordered = centreLinesFirst(points);
assert.equal(ordered.length, points.length);
const leads = ORBITAL_PREFETCH_LEAD_S.length;
assert.deepEqual(
ordered.slice(0, leads),
points.filter((_, i) => i % across === 0),
);
// The 12-tile cap at a level of small tiles still reaches the far lead.
const mercator = new Cesium.WebMercatorTilingScheme();
const tiles = tilesForPoints(mercator, ordered, 17);
const far = mercator.positionToTileXY(
Cesium.Cartographic.fromDegrees(
ordered[leads - 1].lon,
ordered[leads - 1].lat,
),
17,
);
assert.ok(tiles.some((tile) => tile.x === far.x && tile.y === far.y));
});
+163
View File
@@ -0,0 +1,163 @@
/**
* Ground focus for the orbital view: the look that keeps a point on the
* ground at the centre of the picture as the satellite moves, whether that
* look is within the off-nadir reach, and which mapped points the view can
* show or be turned to. The look is the orbital view's own
* `{yawDeg, pitchDeg}` over its nadir frame (see `offNadirOrientation` in
* `src/cockpitOrbital.js`), so a focused view is a tilt like any other.
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
* in Earth-fixed (ECEF) metres.
*/
import {
LOOK_EASE_TAU_MS,
ORBITAL_LOOK_LIMIT_DEG,
easeLook,
} from './cockpitLook.js';
/**
* How long a focus takes to swing the view onto its target, or back to
* nadir when it ends: the look's own easing curve, on a slower clock. Three
* of them (about 0.75 s) close 95% of the turn.
*/
export const ORBITAL_FOCUS_EASE_TAU_MS = 250;
/** How often the orbital Live Signals rows are rebuilt, ms. */
export const ORBITAL_REACH_REFRESH_MS = 1000;
/** WGS84 semi-axes squared, m²: the surface normal for the horizon test. */
const WGS84_A2 = 6378137 ** 2;
const WGS84_B2 = 6356752.314245179 ** 2;
/** Slack on the reach limit so a target held exactly at 45° stays. */
const REACH_EPSILON_DEG = 1e-6;
const toDegrees = (rad) => (rad * 180) / Math.PI;
function dot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
function finiteVector(v) {
return (
Boolean(v) &&
Number.isFinite(v.x) &&
Number.isFinite(v.y) &&
Number.isFinite(v.z)
);
}
/**
* The look that centres `target` in the picture, in the nadir frame's tilt
* (north first, then east; see `offNadirOrientation`): east is the angle off
* the frame's top-bottom plane, north the angle along it. Also the single
* angle between nadir and the line of sight, the image's off-nadir angle,
* and whether the target faces the satellite (above its horizon).
* @param {{direction: object, up: object, right: object}} nadir From
* `nadirOrientation`.
* @param {{x: number, y: number, z: number}} satellite ECEF, metres.
* @param {{x: number, y: number, z: number}} target ECEF, metres.
* @returns {{yawDeg: number, pitchDeg: number, offNadirDeg: number,
* visible: boolean}|null} Null without usable positions.
*/
export function focusLook(nadir, satellite, target) {
if (!nadir || !finiteVector(satellite) || !finiteVector(target)) return null;
const line = {
x: target.x - satellite.x,
y: target.y - satellite.y,
z: target.z - satellite.z,
};
const range = Math.hypot(line.x, line.y, line.z);
if (!(range > 1)) return null;
const v = { x: line.x / range, y: line.y / range, z: line.z / range };
const along = dot(v, nadir.direction);
const up = dot(v, nadir.up);
const right = Math.min(1, Math.max(-1, dot(v, nadir.right)));
// The target's own zenith, the WGS84 surface normal there: the satellite
// is above its horizon when the line from the target to it points up.
const normal = {
x: target.x / WGS84_A2,
y: target.y / WGS84_A2,
z: target.z / WGS84_B2,
};
const visible =
Math.hypot(normal.x, normal.y, normal.z) > 0 && -dot(line, normal) > 0;
const yawDeg = toDegrees(Math.asin(right));
const pitchDeg = toDegrees(Math.atan2(up, along));
return {
yawDeg: yawDeg === 0 ? 0 : yawDeg,
pitchDeg: pitchDeg === 0 ? 0 : pitchDeg,
offNadirDeg: toDegrees(Math.acos(Math.min(1, Math.max(-1, along)))),
visible,
};
}
/**
* Whether a focus look is within the off-nadir reach: the target faces the
* satellite and each axis is within the arrows' ±45° clamp.
* @param {{yawDeg: number, pitchDeg: number, visible?: boolean}|null} look
* @returns {boolean}
*/
export function focusInReach(look) {
if (!look || look.visible === false) return false;
const limit = ORBITAL_LOOK_LIMIT_DEG + REACH_EPSILON_DEG;
return (
Number.isFinite(look.yawDeg) &&
Number.isFinite(look.pitchDeg) &&
Math.abs(look.yawDeg) <= limit &&
Math.abs(look.pitchDeg) <= limit
);
}
/**
* Ease what is left of the turn onto a focus target toward nothing: the
* look's easing (`easeLook`) on the focus's slower clock. The view is the
* live target's look plus this remainder, so once it lands the view holds
* the target exactly, without the lag of chasing it. Reduced motion cuts.
* @param {{yawDeg: number, pitchDeg: number}} remainder View minus target.
* @param {number} dtMs Milliseconds since the previous frame.
* @param {{immediate?: boolean}} [options]
* @returns {{yawDeg: number, pitchDeg: number}}
*/
export function easeFocusRemainder(
remainder,
dtMs,
{ immediate = false } = {},
) {
return easeLook(remainder, { yawDeg: 0, pitchDeg: 0 }, focusEaseDtMs(dtMs), {
immediate,
});
}
/**
* A frame's time on the focus's clock, for `easeLook`: its fixed time
* constant scaled to ORBITAL_FOCUS_EASE_TAU_MS.
* @param {number} dtMs
* @returns {number}
*/
export function focusEaseDtMs(dtMs) {
return Number.isFinite(dtMs) && dtMs > 0
? (dtMs * LOOK_EASE_TAU_MS) / ORBITAL_FOCUS_EASE_TAU_MS
: 0;
}
/**
* The mapped points the view can be turned to: those facing the satellite
* and within the off-nadir reach, nearest to nadir first.
* @param {Iterable<{key: string, position: {x: number, y: number,
* z: number}}>} candidates
* @param {{nadir: object, satellite: object}} frame
* @returns {Array<{key: string, offNadirDeg: number, look: object}>}
*/
export function pointsInReach(candidates, { nadir, satellite }) {
const entries = [];
if (!nadir || !finiteVector(satellite)) return entries;
for (const candidate of candidates) {
const look = focusLook(nadir, satellite, candidate?.position);
if (!focusInReach(look)) continue;
entries.push({ key: candidate.key, offNadirDeg: look.offNadirDeg, look });
}
return entries.sort(
(a, b) =>
a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key),
);
}
+227
View File
@@ -0,0 +1,227 @@
// src/cockpitOrbitalFocus.test.mjs — the geometry of a ground focus in the
// orbital view: the look that centres a target, its off-nadir angle, the
// reach, the easing onto and off a target, and the reach list.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
ORBITAL_FOCUS_EASE_TAU_MS,
easeFocusRemainder,
focusEaseDtMs,
focusInReach,
focusLook,
pointsInReach,
} from './cockpitOrbitalFocus.js';
import {
ORBITAL_EARTH_RADIUS_M,
nadirOrientation,
offNadirAngleDeg,
offNadirOrientation,
} from './cockpitOrbital.js';
import { LOOK_EASE_TAU_MS } from './cockpitLook.js';
const near = (actual, expected, label, tolerance = 1e-9) =>
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: ${actual} not within ${tolerance} of ${expected}`,
);
const unit = (v) => {
const n = Math.hypot(v.x, v.y, v.z);
return { x: v.x / n, y: v.y / n, z: v.z / n };
};
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
const angleBetweenDeg = (a, b) => {
const u = unit(a);
const v = unit(b);
const cosine = Math.min(1, Math.max(-1, u.x * v.x + u.y * v.y + u.z * v.z));
return (Math.acos(cosine) * 180) / Math.PI;
};
/** A point on a sphere of radius `radiusM`, ECEF. */
const onSphere = (latDeg, lonDeg, radiusM) => {
const lat = (latDeg * Math.PI) / 180;
const lon = (lonDeg * Math.PI) / 180;
return {
x: radiusM * Math.cos(lat) * Math.cos(lon),
y: radiusM * Math.cos(lat) * Math.sin(lon),
z: radiusM * Math.sin(lat),
};
};
/** A point on the WGS84 ellipsoid, as the globe draws it. */
const geodeticToEcef = (latDeg, lonDeg, heightM = 0) =>
Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, heightM);
const ISS_ALT_M = 420_000;
const satelliteAt = (latDeg, lonDeg, altitudeM = ISS_ALT_M) =>
geodeticToEcef(latDeg, lonDeg, altitudeM);
test('the focus look turns the nadir frame onto the target exactly', () => {
const satellite = satelliteAt(30, -97);
for (const heading of [null, 0, 51.6, 200]) {
const nadir = nadirOrientation(satellite, { headingDeg: heading });
for (const [lat, lon] of [
[31.5, -96],
[28.2, -99.4],
[30, -97],
[32.9, -97.1],
]) {
const target = geodeticToEcef(lat, lon, 300);
const look = focusLook(nadir, satellite, target);
const view = offNadirOrientation(nadir, {
northDeg: look.pitchDeg,
eastDeg: look.yawDeg,
});
near(
angleBetweenDeg(view.direction, sub(target, satellite)),
0,
`centred at ${lat},${lon} heading ${heading}`,
1e-5,
);
// The single angle the readout gives is the tilt's total angle.
near(
look.offNadirDeg,
offNadirAngleDeg({ northDeg: look.pitchDeg, eastDeg: look.yawDeg }),
'one off-nadir angle',
1e-9,
);
assert.equal(look.visible, true);
}
}
});
test('the off-nadir angle is the angle between nadir and the line of sight', () => {
// On a sphere: a target `central` radians from the sub-satellite point is
// seen atan(R sin γ / (R + h − R cos γ)) off nadir.
const R = ORBITAL_EARTH_RADIUS_M;
const satellite = onSphere(10, 20, R + ISS_ALT_M);
const nadir = nadirOrientation(satellite);
for (const centralDeg of [0, 0.5, 2, 5, 12]) {
const target = onSphere(10 + centralDeg, 20, R);
const γ = (centralDeg * Math.PI) / 180;
const expected =
(Math.atan2(R * Math.sin(γ), R + ISS_ALT_M - R * Math.cos(γ)) * 180) /
Math.PI;
const look = focusLook(nadir, satellite, target);
near(look.offNadirDeg, expected, `${centralDeg}° away`, 1e-9);
}
// Straight below reads zero, a real angle and not a missing one.
const below = focusLook(nadir, satellite, onSphere(10, 20, R));
near(below.offNadirDeg, 0, 'straight below', 1e-9);
near(below.yawDeg, 0, 'no east', 1e-9);
near(below.pitchDeg, 0, 'no north', 1e-9);
assert.equal(focusInReach(below), true);
});
test('the reach is the arrows’ ±45° on each axis, facing the satellite', () => {
assert.equal(focusInReach({ yawDeg: 45, pitchDeg: -45, visible: true }), true);
assert.equal(focusInReach({ yawDeg: 45.01, pitchDeg: 0, visible: true }), false);
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: -45.01, visible: true }), false);
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: 0, visible: false }), false);
assert.equal(focusInReach(null), false);
assert.equal(focusInReach({ yawDeg: Number.NaN, pitchDeg: 0 }), false);
// From geostationary height the far side of the Earth lies within a few
// degrees of nadir, but it faces away.
const satellite = satelliteAt(0, 0, 35_786_000);
const nadir = nadirOrientation(satellite);
const farSide = geodeticToEcef(0, 100, 0);
const look = focusLook(nadir, satellite, farSide);
assert.ok(Math.abs(look.yawDeg) < 45 && Math.abs(look.pitchDeg) < 45);
assert.equal(look.visible, false);
assert.equal(focusInReach(look), false);
// A point near the sub-satellite point faces it.
assert.equal(focusInReach(focusLook(nadir, satellite, geodeticToEcef(5, 5))), true);
});
test('a target slides out of reach as the satellite passes it', () => {
const target = geodeticToEcef(0, 10, 0);
const inReach = [];
for (let lon = 10; lon >= -5; lon -= 1) {
const satellite = satelliteAt(0, lon);
inReach.push(focusInReach(focusLook(nadirOrientation(satellite), satellite, target)));
}
assert.equal(inReach[0], true, 'overhead');
assert.equal(inReach.at(-1), false, '15° of longitude behind');
// Once out, it stays out: the reach ends once, not on and off.
const firstOut = inReach.indexOf(false);
assert.ok(firstOut > 0);
assert.ok(inReach.slice(firstOut).every((value) => value === false));
});
test('the turn onto a target eases on the slower focus clock and lands', () => {
assert.equal(
focusEaseDtMs(ORBITAL_FOCUS_EASE_TAU_MS),
LOOK_EASE_TAU_MS,
'one focus time constant is one look time constant',
);
assert.equal(focusEaseDtMs(Number.NaN), 0);
assert.equal(focusEaseDtMs(-5), 0);
let remainder = { yawDeg: -30, pitchDeg: 20 };
let previous = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
let frames = 0;
while ((remainder.yawDeg || remainder.pitchDeg) && frames < 500) {
const before = remainder;
remainder = easeFocusRemainder(remainder, 16);
const size = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
assert.ok(size < previous, 'always closer');
// No axis moves more than a tenth of the way in a frame, but for the
// last few hundredths of a degree it lands on: a turn, not a cut.
for (const axis of ['yawDeg', 'pitchDeg']) {
const step = Math.abs(before[axis] - remainder[axis]);
assert.ok(
step <= Math.max(Math.abs(before[axis]) * 0.1, 0.06),
`frame ${frames} ${axis}`,
);
}
previous = size;
frames += 1;
}
assert.deepEqual(remainder, { yawDeg: 0, pitchDeg: 0 }, 'lands exactly');
// About three time constants close 95%; landing takes a little longer.
assert.ok(frames * 16 > 2 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
assert.ok(frames * 16 < 12 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
assert.deepEqual(
easeFocusRemainder({ yawDeg: 10, pitchDeg: -4 }, 16, { immediate: true }),
{ yawDeg: 0, pitchDeg: 0 },
'reduced motion cuts',
);
});
test('the reach list keeps points in reach or in frame, nearest first, bounded', () => {
const satellite = satelliteAt(0, 0);
const nadir = nadirOrientation(satellite);
const candidates = [];
// A row of points eastward, 0.1° apart: those up to ~3.7° are within the
// 45° tilt, the rest beyond it.
for (let i = 0; i < 80; i += 1)
candidates.push({ key: `east-${i}`, position: geodeticToEcef(0, i * 0.1) });
candidates.push({ key: 'far-side', position: geodeticToEcef(0, 180) });
const entries = pointsInReach(candidates, { nadir, satellite });
assert.ok(entries.every((entry) => entry.key !== 'far-side'));
const angles = entries.map((entry) => entry.offNadirDeg);
assert.deepEqual(angles, [...angles].sort((a, b) => a - b), 'nearest first');
assert.equal(entries[0].key, 'east-0');
assert.equal(entries[0].offNadirDeg, 0);
// Only those within the 45° tilt are listed.
assert.ok(entries.length > 0 && entries.length < 80);
for (const entry of entries) assert.ok(Math.abs(entry.look.yawDeg) <= 45);
const beyond = focusLook(nadir, satellite, candidates[entries.length].position);
assert.ok(Math.abs(beyond.yawDeg) > 45, 'the next one east is past the reach');
assert.deepEqual(pointsInReach([], { nadir, satellite }), []);
});
test('the horizon is the ground’s own: the WGS84 surface normal, not the centre line', () => {
const target = geodeticToEcef(45, 0);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(target);
const satelliteAtElevation = (elevationDeg) => {
const e = Cesium.Math.toRadians(elevationDeg);
const local = new Cesium.Cartesian3(0, -Math.cos(e) * 4e7, Math.sin(e) * 4e7);
return Cesium.Matrix4.multiplyByPoint(enu, local, new Cesium.Cartesian3());
};
const below = satelliteAtElevation(-0.1);
assert.equal(focusLook(nadirOrientation(below), below, target).visible, false);
const above = satelliteAtElevation(0.1);
assert.equal(focusLook(nadirOrientation(above), above, target).visible, true);
});
+74
View File
@@ -0,0 +1,74 @@
/**
* The camera's hop from the satellite the orbital view rides to another.
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
* in Earth-fixed (ECEF) metres.
*/
/** The most nearby satellites asked for the orbital rows, nearest first. */
export const ORBITAL_SATELLITES_MAX = 8;
/** How long the camera takes to hop to another satellite, ms. */
export const ORBITAL_HOP_MS = 1200;
function finiteVector(v) {
return (
Boolean(v) &&
Number.isFinite(v.x) &&
Number.isFinite(v.y) &&
Number.isFinite(v.z)
);
}
/**
* Where the camera is part-way through a hop: along the great circle
* between the two positions (never through the Earth), its distance from the
* centre interpolated linearly. The circle is the one through both ends, so
* the path moves smoothly as the destination does, however far round it
* lies; only exactly opposite ends, which no single circle joins, turn about
* a fixed axis square to them.
* @param {{x: number, y: number, z: number}} from
* @param {{x: number, y: number, z: number}} to
* @param {number} progress Eased, 0..1.
* @returns {{x: number, y: number, z: number}|null}
*/
export function hopPosition(from, to, progress) {
if (!finiteVector(from) || !finiteVector(to)) return null;
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1));
const ra = Math.hypot(from.x, from.y, from.z);
const rb = Math.hypot(to.x, to.y, to.z);
if (!(ra > 0) || !(rb > 0)) return null;
const a = { x: from.x / ra, y: from.y / ra, z: from.z / ra };
const b = { x: to.x / rb, y: to.y / rb, z: to.z / rb };
const cosine = a.x * b.x + a.y * b.y + a.z * b.z;
// The direction the circle leaves `a` in: `b` less its part along `a`.
let c = {
x: b.x - cosine * a.x,
y: b.y - cosine * a.y,
z: b.z - cosine * a.z,
};
let rc = Math.hypot(c.x, c.y, c.z);
const r = ra + (rb - ra) * p;
const omega = Math.atan2(rc, cosine);
if (!(rc > 1e-12)) {
if (cosine > 0) return { x: a.x * r, y: a.y * r, z: a.z * r };
// Opposite ends: set off square to `a`, northward (or, near a pole,
// toward the prime meridian).
const pole =
Math.abs(a.z) < 0.9 ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 };
const along = pole.x * a.x + pole.y * a.y + pole.z * a.z;
c = {
x: pole.x - along * a.x,
y: pole.y - along * a.y,
z: pole.z - along * a.z,
};
rc = Math.hypot(c.x, c.y, c.z);
}
const angle = p * omega;
const cos = Math.cos(angle);
const sin = Math.sin(angle) / rc;
return {
x: (a.x * cos + c.x * sin) * r,
y: (a.y * cos + c.y * sin) * r,
z: (a.z * cos + c.z * sin) * r,
};
}
+73
View File
@@ -0,0 +1,73 @@
// src/cockpitOrbitalSatellites.test.mjs — the camera's hop from one ridden
// satellite to another.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { hopPosition } from './cockpitOrbitalSatellites.js';
const at = (lat, lon, alt) => Cesium.Cartesian3.fromDegrees(lon, lat, alt);
test('the hop follows the great circle and lands on the satellite', () => {
const from = at(0, 0, 400_000);
const to = at(0, 90, 800_000);
const start = hopPosition(from, to, 0);
for (const axis of ['x', 'y', 'z'])
assert.ok(Math.abs(start[axis] - from[axis]) < 1e-6, axis);
const end = hopPosition(from, to, 1);
for (const axis of ['x', 'y', 'z'])
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-6, axis);
// Half-way it is above the Earth, between the two heights, not on the
// chord through it.
const mid = hopPosition(from, to, 0.5);
const radius = Math.hypot(mid.x, mid.y, mid.z);
const ra = Cesium.Cartesian3.magnitude(from);
const rb = Cesium.Cartesian3.magnitude(to);
assert.ok(Math.abs(radius - (ra + rb) / 2) < 1e-6);
assert.ok(Math.abs(Math.atan2(mid.y, mid.x) - Math.PI / 4) < 1e-9);
assert.equal(hopPosition(null, to, 0.5), null);
});
test('a hop to the opposite side goes round the Earth, not through it', () => {
const from = at(0, 0, 400_000);
const to = at(0, 180, 400_000);
const radius = Cesium.Cartesian3.magnitude(from);
for (const p of [0.25, 0.5, 0.75]) {
const point = hopPosition(from, to, p);
assert.ok(
Math.abs(Math.hypot(point.x, point.y, point.z) - radius) < 1e-3,
`p=${p}`,
);
}
const end = hopPosition(from, to, 1);
for (const axis of ['x', 'y', 'z'])
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-3, axis);
});
test('a hop to nearly the opposite side moves smoothly as the destination does', () => {
const r = 6_778_137;
const from = { x: r, y: 0, z: 0 };
const near = (delta) => ({
x: -r * Math.cos(delta),
y: r * Math.sin(delta),
z: 0,
});
const mid = (delta) => hopPosition(from, near(delta), 0.5);
const [a, b] = [mid(0.999e-6), mid(1.001e-6)];
assert.ok(
Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) < 1,
'no jump between neighbours',
);
for (const delta of [1e-3, 1e-6, 1e-9]) {
const to = near(delta);
const end = hopPosition(from, to, 1);
assert.ok(
Math.hypot(end.x - to.x, end.y - to.y, end.z - to.z) < 1e-3,
`lands at ${delta}`,
);
const half = mid(delta);
assert.ok(
Math.abs(Math.hypot(half.x, half.y, half.z) - r) < 1e-3,
`round at ${delta}`,
);
}
});
+166
View File
@@ -0,0 +1,166 @@
/**
* Bridge radar policy: the range steps, the range rings and the heading-up
* projection of nearby AIS vessels onto the radar's range circle. Pure: no
* DOM, no Cesium and no translated text; the Cockpit words the labels.
*/
import {
bearingBetweenCoordinates,
normalizeHeading,
relativeBearing,
} from './cockpitMath.js';
import { greatCircleMeters } from './geoDistance.js';
/** Metres in one international nautical mile. */
export const METRES_PER_NM = 1852;
/** The radar's range steps in nautical miles, shortest first. */
export const RADAR_RANGES_NM = Object.freeze([3, 6, 12, 24]);
/** The range the radar opens on. */
export const DEFAULT_RADAR_RANGE_NM = 3;
/** At most this many contacts are drawn, nearest first. */
export const RADAR_MAX_CONTACTS = 60;
/** Range rings divide the range into this many equal steps. */
export const RADAR_RING_COUNT = 4;
/**
* Normalize a range to one of the radar's steps.
* @param {number} rangeNm Requested range in nautical miles.
* @returns {number} The range when it is a step, else the default range.
*/
export function radarRangeNm(rangeNm) {
return RADAR_RANGES_NM.includes(rangeNm) ? rangeNm : DEFAULT_RADAR_RANGE_NM;
}
/**
* The range after this one; the longest range wraps to the shortest.
* @param {number} rangeNm Current range in nautical miles.
* @returns {number} The next range step.
*/
export function nextRadarRange(rangeNm) {
const index = RADAR_RANGES_NM.indexOf(radarRangeNm(rangeNm));
return RADAR_RANGES_NM[(index + 1) % RADAR_RANGES_NM.length];
}
/**
* Range ring distances at each quarter of the range, innermost first.
* @param {number} rangeNm Range in nautical miles.
* @returns {number[]} Ring distances in nautical miles; the last is the range.
*/
export function radarRingsNm(rangeNm) {
const range = radarRangeNm(rangeNm);
return Array.from(
{ length: RADAR_RING_COUNT },
(_, index) => (range * (index + 1)) / RADAR_RING_COUNT,
);
}
/**
* The search radius for a range, in metres.
* @param {number} rangeNm Range in nautical miles.
* @returns {number}
*/
export function radarRangeM(rangeNm) {
return radarRangeNm(rangeNm) * METRES_PER_NM;
}
const round4 = (value) => Math.round(value * 1e4) / 1e4;
/**
* Project vessel contacts onto a heading-up radar.
*
* Positions are fractions of the range circle: x to the right (starboard),
* y up (ahead), both in [-1, 1]. Contacts outside the range, or without a
* position, are left out; the rest come nearest first, at most `maxCount`.
* @param {{latitude: number, longitude: number, headingDeg: number}} own
* Own ship position and the heading the radar is drawn up from.
* @param {Array<{id: string, name?: string, latitude: number,
* longitude: number, course?: number|null, color?: string,
* stale?: boolean}>} contacts Nearby vessels.
* @param {{rangeNm?: number, maxCount?: number}} [options]
* @returns {Array<{id: string, name: string, x: number, y: number,
* distanceNm: number, relativeBearingDeg: number,
* relativeCourseDeg: number|null, color: string, stale: boolean}>}
* relativeBearingDeg and relativeCourseDeg are clockwise from the bow in
* [-180, 180).
*/
export function projectRadarContacts(own, contacts, options = {}) {
const rangeNm = radarRangeNm(options.rangeNm ?? DEFAULT_RADAR_RANGE_NM);
const cap =
Number.isFinite(options.maxCount) && options.maxCount > 0
? Math.floor(options.maxCount)
: RADAR_MAX_CONTACTS;
const lat = Number(own?.latitude);
const lon = Number(own?.longitude);
if (
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
!Array.isArray(contacts)
)
return [];
const heading = normalizeHeading(own.headingDeg ?? 0);
const projected = [];
for (const contact of contacts) {
const toLat = Number(contact?.latitude);
const toLon = Number(contact?.longitude);
const distanceNm =
greatCircleMeters({ lat, lon }, { lat: toLat, lon: toLon }) /
METRES_PER_NM;
if (!Number.isFinite(distanceNm) || distanceNm > rangeNm) continue;
const bearing = bearingBetweenCoordinates(lat, lon, toLat, toLon);
const relativeBearingDeg =
bearing === null ? 0 : relativeBearing(bearing, heading);
const radians = (relativeBearingDeg * Math.PI) / 180;
const fraction = distanceNm / rangeNm;
const course =
contact.course === null || contact.course === undefined
? NaN
: Number(contact.course);
projected.push({
id: String(contact.id),
name: String(contact.name || contact.id),
x: round4(Math.sin(radians) * fraction),
y: round4(Math.cos(radians) * fraction),
distanceNm,
relativeBearingDeg,
relativeCourseDeg: Number.isFinite(course)
? relativeBearing(course, heading)
: null,
color: contact.color || '',
stale: contact.stale === true,
});
}
projected.sort((a, b) => a.distanceNm - b.distanceNm);
return projected.slice(0, cap);
}
/**
* The radar presentation without its worded labels.
* @param {{latitude: number, longitude: number, headingDeg: number,
* courseDeg?: number|null}} own Own ship; `courseDeg` is course over ground.
* @param {Array<object>} contacts See {@link projectRadarContacts}.
* @param {number} rangeNm Range in nautical miles.
* @returns {{rangeNm: number, ringsNm: number[], headingDeg: number,
* ownRelativeCourseDeg: number|null, contacts: Array<object>}}
*/
export function radarPicture(own, contacts, rangeNm) {
const range = radarRangeNm(rangeNm);
const headingDeg = normalizeHeading(own?.headingDeg ?? 0);
const course =
own?.courseDeg === null || own?.courseDeg === undefined
? NaN
: Number(own.courseDeg);
return {
rangeNm: range,
ringsNm: radarRingsNm(range),
headingDeg,
ownRelativeCourseDeg: Number.isFinite(course)
? relativeBearing(course, headingDeg)
: null,
contacts: projectRadarContacts({ ...own, headingDeg }, contacts, {
rangeNm: range,
}),
};
}
+140
View File
@@ -0,0 +1,140 @@
// src/cockpitRadar.test.mjs — the bridge radar's range steps, rings and
// heading-up contact projection.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_RADAR_RANGE_NM,
METRES_PER_NM,
RADAR_MAX_CONTACTS,
RADAR_RANGES_NM,
nextRadarRange,
projectRadarContacts,
radarPicture,
radarRangeM,
radarRingsNm,
} from './cockpitRadar.js';
const OWN = Object.freeze({ latitude: 54.5, longitude: 10.2, headingDeg: 0 });
/** One nautical mile of latitude, in degrees. */
const NM_LAT = 1 / 60;
/** One nautical mile of longitude at 54.5°N, in degrees. */
const NM_LON = 1 / (60 * Math.cos((54.5 * Math.PI) / 180));
function contact(id, northNm, eastNm, extra = {}) {
return {
id,
name: id,
latitude: OWN.latitude + northNm * NM_LAT,
longitude: OWN.longitude + eastNm * NM_LON,
course: null,
color: '#fff',
stale: false,
...extra,
};
}
test('the range steps 3, 6, 12, 24 NM, opens on 3 and wraps', () => {
assert.deepEqual(RADAR_RANGES_NM, [3, 6, 12, 24]);
assert.equal(DEFAULT_RADAR_RANGE_NM, 3);
assert.equal(nextRadarRange(3), 6);
assert.equal(nextRadarRange(6), 12);
assert.equal(nextRadarRange(12), 24);
assert.equal(nextRadarRange(24), 3);
assert.equal(nextRadarRange(7), 6, 'an unknown range steps from the default');
assert.equal(radarRangeM(6), 6 * METRES_PER_NM);
});
test('range rings sit at each quarter of the range', () => {
assert.deepEqual(radarRingsNm(6), [1.5, 3, 4.5, 6]);
assert.deepEqual(radarRingsNm(24), [6, 12, 18, 24]);
});
test('a contact dead ahead is straight up the scope', () => {
const [ahead] = projectRadarContacts(OWN, [contact('A', 3, 0)], {
rangeNm: 6,
});
assert.ok(Math.abs(ahead.x) < 0.01, `x ${ahead.x}`);
assert.ok(Math.abs(ahead.y - 0.5) < 0.01, `y ${ahead.y}`);
assert.ok(Math.abs(ahead.distanceNm - 3) < 0.02);
assert.ok(Math.abs(ahead.relativeBearingDeg) < 0.5);
});
test('heading-up turns the picture with the bow', () => {
// Heading east: a contact to the north is on the port beam, one to the
// south on the starboard beam, one to the east dead ahead.
const own = { ...OWN, headingDeg: 90 };
const byId = Object.fromEntries(
projectRadarContacts(own, [
contact('N', 2, 0),
contact('S', -2, 0),
contact('E', 0, 2),
]).map((entry) => [entry.id, entry]),
);
assert.ok(byId.N.x < -0.3 && Math.abs(byId.N.y) < 0.02, 'north is to port');
assert.ok(
byId.S.x > 0.3 && Math.abs(byId.S.y) < 0.02,
'south is to starboard',
);
assert.ok(Math.abs(byId.E.x) < 0.02 && byId.E.y > 0.3, 'east is ahead');
assert.ok(Math.abs(byId.S.relativeBearingDeg - 90) < 1);
assert.ok(Math.abs(byId.N.relativeBearingDeg + 90) < 1);
});
test('a contact to starboard is right of the centre', () => {
const [starboard] = projectRadarContacts(OWN, [contact('B', 0, 1.5)]);
assert.ok(starboard.x > 0.2, `x ${starboard.x}`);
assert.ok(Math.abs(starboard.y) < 0.01);
});
test('course is drawn relative to the bow and stays null when unknown', () => {
const own = { ...OWN, headingDeg: 90 };
const [crossing, drifting] = projectRadarContacts(own, [
contact('A', 1, 0, { course: 180 }),
contact('B', 2, 0, { course: null }),
]);
assert.equal(crossing.relativeCourseDeg, 90);
assert.equal(drifting.relativeCourseDeg, null);
});
test('contacts beyond the range are left out', () => {
const contacts = projectRadarContacts(
OWN,
[contact('in', 5.9, 0), contact('out', 6.2, 0), contact('far', 0, 30)],
{ rangeNm: 6 },
);
assert.deepEqual(
contacts.map((entry) => entry.id),
['in'],
);
assert.ok(contacts.every((entry) => Math.hypot(entry.x, entry.y) <= 1));
});
test('contacts come nearest first, capped', () => {
const many = Array.from({ length: RADAR_MAX_CONTACTS + 15 }, (_, index) =>
contact(`V${index}`, 0, 5.5 - index * 0.07),
);
const contacts = projectRadarContacts(OWN, many, { rangeNm: 6 });
assert.equal(contacts.length, RADAR_MAX_CONTACTS);
for (let index = 1; index < contacts.length; index += 1)
assert.ok(contacts[index - 1].distanceNm <= contacts[index].distanceNm);
assert.equal(
projectRadarContacts(OWN, many, { rangeNm: 6, maxCount: 3 }).length,
3,
'a smaller cap is honoured',
);
});
test('a picture carries the range, rings, heading and own course over ground', () => {
const picture = radarPicture(
{ ...OWN, headingDeg: 350, courseDeg: 10 },
[contact('A', 1, 0)],
12,
);
assert.equal(picture.rangeNm, 12);
assert.deepEqual(picture.ringsNm, [3, 6, 9, 12]);
assert.equal(picture.headingDeg, 350);
assert.equal(picture.ownRelativeCourseDeg, 20);
assert.equal(picture.contacts.length, 1);
assert.ok(picture.contacts[0].x > 0, 'north is to starboard of 350°');
assert.equal(radarPicture(OWN, [], 6).ownRelativeCourseDeg, null);
});
+34
View File
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
],
});
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
const f = fixture(t);
const crowded = {
...snapshot(),
cohorts: [
{
id: 'flights',
count: 6,
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
id: `n${n}`,
label: `N${n}`,
distanceM: n * 1000,
})),
},
],
};
updateCockpitSignals.call(f.owner, crowded, 0, {
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
});
await nextTick();
assert.equal(f.owner.signalItems.length, 5);
assert.equal(f.owner.signalItems[0].key, 'feed-status');
assert.equal(f.owner.signalItems[0].tone, 'warning');
assert.equal(f.owner.signalItems[0].target, null);
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
updateCockpitSignals.call(f.owner, crowded, 0);
assert.equal(
f.owner.signalItems.some((item) => item.key === 'feed-status'),
false,
'with the feed back, the row goes',
);
});
test('rows render the signals the controller presents', async (t) => {
const f = fixture(t);
await render(f, [
+170
View File
@@ -0,0 +1,170 @@
/**
* Camera policy for first-person subjects other than aircraft: where a
* vessel's bridge eye sits and how its reported fix advances between feed
* refreshes. No DOM or i18n dependencies, so the policy is unit-testable.
*/
import { projectGroundArcLatLon } from './data/motionModel.js';
/** Bridge cameras look along the horizon; a ship's eye is already high. */
export const VESSEL_BRIDGE_PITCH_DEG = -2;
/** The AIS antenna usually sits on the bridge, so the eye is not moved forward. */
export const VESSEL_BRIDGE_FORWARD_OFFSET_M = 0;
/** Clearance over the shared ground floor for a camera that floats on water. */
export const VESSEL_BRIDGE_MIN_CLEARANCE_M = 2;
/** Eye height used when the vessel layer knows no height for the type. */
export const VESSEL_DEFAULT_EYE_HEIGHT_M = 12;
/**
* A reported fix older than this stops advancing: the vessel holds where the
* last projection left it instead of sailing on through a feed outage.
*/
export const VESSEL_DEAD_RECKON_MAX_S = 180;
/**
* The vessel layer floats every ship on the geoid (mean sea level), but a
* river or lake can sit well above it: the Seine through Paris is ~27 m up,
* which put a bridge eye under the water. The bridge reads the rendered
* surface around its ship instead: probes on a ring this far out, plus one
* at the ship itself.
*/
export const VESSEL_WATERLINE_PROBE_RADIUS_M = 30;
/** Probes on the ring around the ship. */
export const VESSEL_WATERLINE_PROBE_COUNT = 8;
/** How often the bridge reads the water under its ship again, ms. */
export const VESSEL_WATERLINE_REFRESH_MS = 5000;
/** A surface less than this above sea level is the sea itself, metres. */
export const VESSEL_WATERLINE_MIN_RISE_M = 2;
/** Highest navigable water trusted from a sample (Lake Titicaca ~3,810 m). */
export const VESSEL_WATERLINE_MAX_RISE_M = 4000;
/**
* Where to read the surface around a ship: the ship's own position, then a
* ring of probes around it.
* @param {number} latitude
* @param {number} longitude
* @returns {Array<{lat: number, lon: number}>}
*/
export function waterlineProbePoints(
latitude,
longitude,
radiusM = VESSEL_WATERLINE_PROBE_RADIUS_M,
count = VESSEL_WATERLINE_PROBE_COUNT,
) {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
const points = [{ lat: latitude, lon: longitude }];
const metresPerDegLat = 111_320;
const metresPerDegLon =
metresPerDegLat * Math.max(0.01, Math.cos((latitude * Math.PI) / 180));
for (let i = 0; i < count; i += 1) {
const bearing = (2 * Math.PI * i) / count;
points.push({
lat: latitude + (radiusM * Math.cos(bearing)) / metresPerDegLat,
lon: longitude + (radiusM * Math.sin(bearing)) / metresPerDegLon,
});
}
return points;
}
/**
* The water surface a bridge floats on, ellipsoidal metres. Quays, bridges,
* moored boats and the ship's own model all stand above the water, so the
* lowest probe is the water. A lowest probe barely above sea level is the
* sea; one implausibly high is a bad sample. Either way the geoid stands.
* @param {number} seaSurfaceM The geoid sea surface the layer floats ships on.
* @param {Array<number|null|undefined>} sampleHeightsM Rendered-surface probes.
* @returns {number}
*/
export function vesselWaterlineM(seaSurfaceM, sampleHeightsM) {
const heights = (sampleHeightsM || []).filter(Number.isFinite);
if (!Number.isFinite(seaSurfaceM) || !heights.length) return seaSurfaceM;
const lowest = Math.min(...heights);
const rise = lowest - seaSurfaceM;
return rise >= VESSEL_WATERLINE_MIN_RISE_M &&
rise <= VESSEL_WATERLINE_MAX_RISE_M
? lowest
: seaSurfaceM;
}
/**
* Camera placement for a vessel subject, in the shape the Cockpit camera
* reads for every subject kind.
* @param {{eyeHeightM?: number|null}} info Vessel subject info.
* @returns {{forwardM: number, upM: number, pitchDeg: number, minClearanceM: number}}
*/
export function vesselCameraProfile(info) {
return {
forwardM: VESSEL_BRIDGE_FORWARD_OFFSET_M,
upM: Number.isFinite(info?.eyeHeightM)
? info.eyeHeightM
: VESSEL_DEFAULT_EYE_HEIGHT_M,
pitchDeg: VESSEL_BRIDGE_PITCH_DEG,
minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M,
};
}
/**
* How old a vessel's fix is now, seconds, or null without a fix time.
*
* The fix time is on the AIS feed's clock and `nowMs` on this client's, so
* comparing them directly would turn a client clock running minutes fast
* into minutes of age (every bridge projecting its full limit and reading
* STALE FEED). With the time this client first received the fix (client
* clock) and the newest fix in that refresh (feed clock), the age is the
* time since the fix arrived plus how far it trailed that newest fix; each
* part is read on one clock. A fix the feed keeps re-serving keeps its first
* receipt, so it ages into STALE FEED. The age leaves out the newest fix's
* own delay before the refresh, typically seconds: a feed that was already
* stalled when the bridge first saw it ages from then. Without them it falls
* back to the clock difference, never negative.
* @param {{fixEpochMs: number|null, receivedAtMs?: number|null,
* feedEpochMs?: number|null}} fix
* @param {number} nowMs Wall-clock time, ms.
* @returns {number|null}
*/
export function vesselFixAgeSec(fix, nowMs) {
const { fixEpochMs, receivedAtMs, feedEpochMs } = fix || {};
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(nowMs)) return null;
if (Number.isFinite(receivedAtMs) && Number.isFinite(feedEpochMs))
return (
Math.max(0, (nowMs - receivedAtMs) / 1000) +
Math.max(0, (feedEpochMs - fixEpochMs) / 1000)
);
return Math.max(0, (nowMs - fixEpochMs) / 1000);
}
/**
* Advance a vessel's last reported fix along its course over ground.
*
* The vessel layer redraws a ship only when the feed refreshes, so its icon
* stands still between fixes. A camera converging on that still point would
* sail backwards against its own motion. Projecting the fix forward gives the
* Cockpit anchor a moving target, and the bounded age keeps a silent feed from
* carrying the ship away indefinitely.
* @param {{latitude: number, longitude: number, courseDeg: number|null,
* speedMps: number|null, fixEpochMs: number|null,
* receivedAtMs?: number|null, feedEpochMs?: number|null}} fix Last
* report; its age is `vesselFixAgeSec`.
* @param {number} nowMs Wall-clock time, ms.
* @returns {{latitude: number, longitude: number, ageSec: number|null}}
*/
export function deadReckonVesselFix(fix, nowMs) {
const { latitude, longitude, courseDeg, speedMps } = fix || {};
const ageSec = vesselFixAgeSec(fix, nowMs);
if (ageSec === null) return { latitude, longitude, ageSec: null };
const { lat, lon } = projectGroundArcLatLon(
latitude,
longitude,
courseDeg,
speedMps,
0,
Math.min(ageSec, VESSEL_DEAD_RECKON_MAX_S),
);
return { latitude: lat, longitude: lon, ageSec };
}
+137
View File
@@ -0,0 +1,137 @@
// src/cockpitSubjects.test.mjs — the vessel camera profile and dead
// reckoning of AIS fixes between feed refreshes.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
VESSEL_BRIDGE_MIN_CLEARANCE_M,
VESSEL_BRIDGE_PITCH_DEG,
VESSEL_DEAD_RECKON_MAX_S,
VESSEL_DEFAULT_EYE_HEIGHT_M,
VESSEL_WATERLINE_PROBE_RADIUS_M,
deadReckonVesselFix,
vesselFixAgeSec,
vesselCameraProfile,
vesselWaterlineM,
waterlineProbePoints,
} from './cockpitSubjects.js';
const EARTH_M_PER_DEG_LAT = 111_195;
test('the vessel camera profile puts the eye at the bridge, looking at the horizon', () => {
assert.deepEqual(vesselCameraProfile({ eyeHeightM: 30 }), {
forwardM: 0,
upM: 30,
pitchDeg: VESSEL_BRIDGE_PITCH_DEG,
minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M,
});
});
test('a vessel with no known eye height uses the default', () => {
for (const info of [{}, { eyeHeightM: null }, null]) {
assert.equal(vesselCameraProfile(info).upM, VESSEL_DEFAULT_EYE_HEIGHT_M);
}
});
test('a fix advances along course over ground for its age', () => {
const fixEpochMs = 1_000_000;
const advanced = deadReckonVesselFix(
{
latitude: 0,
longitude: 0,
courseDeg: 0,
speedMps: 10,
fixEpochMs,
},
fixEpochMs + 60_000,
);
assert.equal(advanced.ageSec, 60);
// Sixty seconds north at 10 m/s.
assert.ok(
Math.abs(advanced.latitude * EARTH_M_PER_DEG_LAT - 600) < 2,
`expected ~600 m north, got ${advanced.latitude * EARTH_M_PER_DEG_LAT}`,
);
assert.ok(Math.abs(advanced.longitude) < 1e-9);
});
test('projection stops at the dead-reckoning limit', () => {
const fixEpochMs = 0;
const at = (ageSec) =>
deadReckonVesselFix(
{ latitude: 0, longitude: 0, courseDeg: 90, speedMps: 5, fixEpochMs },
fixEpochMs + ageSec * 1000,
);
const limit = at(VESSEL_DEAD_RECKON_MAX_S);
const later = at(VESSEL_DEAD_RECKON_MAX_S * 4);
assert.equal(later.longitude, limit.longitude);
assert.equal(later.ageSec, VESSEL_DEAD_RECKON_MAX_S * 4);
});
test('a fix without time, course or speed stays where it was reported', () => {
const fix = {
latitude: 54.3,
longitude: 10.1,
courseDeg: null,
speedMps: 6,
fixEpochMs: 0,
};
assert.deepEqual(deadReckonVesselFix(fix, 30_000), {
latitude: 54.3,
longitude: 10.1,
ageSec: 30,
});
assert.deepEqual(
deadReckonVesselFix({ ...fix, courseDeg: 90, fixEpochMs: null }, 30_000),
{ latitude: 54.3, longitude: 10.1, ageSec: null },
);
});
test('waterline probes ring the ship at the probe radius', () => {
const points = waterlineProbePoints(48.859, 2.293);
assert.equal(points.length, 9);
assert.deepEqual(points[0], { lat: 48.859, lon: 2.293 });
for (const point of points.slice(1)) {
const northM = (point.lat - 48.859) * 111_320;
const eastM =
(point.lon - 2.293) * 111_320 * Math.cos((48.859 * Math.PI) / 180);
assert.ok(
Math.abs(Math.hypot(northM, eastM) - VESSEL_WATERLINE_PROBE_RADIUS_M) <
0.01,
);
}
assert.deepEqual(waterlineProbePoints(Number.NaN, 2), []);
});
test('the waterline is the lowest probe when it stands clear of the sea', () => {
// The Seine at the Eiffel Tower: geoid 44.6 m, water about 71 m.
assert.equal(vesselWaterlineM(44.6, [80, 71.2, 75, null, 90]), 71.2);
// Swell and tile noise are the sea itself.
assert.equal(vesselWaterlineM(44.6, [45.5, 46]), 44.6);
// Under the sea surface (a dock, a bad tile) never sinks the ship.
assert.equal(vesselWaterlineM(44.6, [30, 71]), 44.6);
// Nothing sampled, or implausibly high.
assert.equal(vesselWaterlineM(44.6, [undefined, Number.NaN]), 44.6);
assert.equal(vesselWaterlineM(44.6, [9000]), 44.6);
});
test("a fix's age is read on each clock, so a fast client clock adds none", () => {
const fixEpochMs = 1_800_000_000_000;
// The refresh's newest fix is 10 s newer than this one; the client, its
// clock five minutes fast, received the refresh 5 s ago.
const receivedAtMs = fixEpochMs + 12_000 + 300_000;
const fix = {
latitude: 0,
longitude: 0,
courseDeg: 90,
speedMps: 5,
fixEpochMs,
receivedAtMs,
feedEpochMs: fixEpochMs + 10_000,
};
assert.equal(vesselFixAgeSec(fix, receivedAtMs + 5000), 15);
assert.equal(deadReckonVesselFix(fix, receivedAtMs + 5000).ageSec, 15);
// Without receive data the clocks are compared, never below zero.
const bare = { fixEpochMs };
assert.equal(vesselFixAgeSec(bare, fixEpochMs + 60_000), 60);
assert.equal(vesselFixAgeSec(bare, fixEpochMs - 60_000), 0);
assert.equal(vesselFixAgeSec({ fixEpochMs: null }, fixEpochMs), null);
});
+2 -1
View File
@@ -65,7 +65,8 @@ export function enterCockpitWithTracking({
);
}
}
if (!entryError) entered = Boolean(cockpitView.enter());
// This transaction selects aircraft; a selected vessel must not stand in.
if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' }));
} catch (error) {
entryThrew = true;
entryError = error instanceof Error ? error : new Error(String(error));
+58 -15
View File
@@ -411,30 +411,73 @@ 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' &&
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
Boolean(subjectLayerEnabled) &&
!contextModeChanging
);
}
/**
* Whether the selected vessel's bridge view can be entered. Like aircraft
* Cockpit it needs only its own layer, Live AIS Vessels, with a vessel
* selected. A Context mode change in flight still owns the camera, so entry
* waits it out.
* @param {{vesselsEnabled: boolean, contextModeChanging: boolean}} state
* @returns {boolean}
*/
export function bridgeEntryAllowed({ vesselsEnabled, contextModeChanging }) {
return Boolean(vesselsEnabled) && !contextModeChanging;
}
/**
* Whether the tracked satellite's orbital view can be entered. Like the
* bridge it needs only its own layer, Satellites, with a satellite tracked.
* A Context mode change in flight still owns the camera, so entry waits it
* out. A subject the satellites layer marks as not enterable (debris:
* rocket bodies, fragments, dead satellites; `satelliteClassEnterable` in
* src/layers/satellites/classes.js) can be tracked but is never ridden: it is
* no mission and has no view to offer.
* @param {{satellitesEnabled: boolean, contextModeChanging: boolean,
* subjectEnterable?: boolean}} state
* @returns {boolean}
*/
export function orbitEntryAllowed({
satellitesEnabled,
contextModeChanging,
subjectEnterable = true,
}) {
return (
Boolean(satellitesEnabled) &&
!contextModeChanging &&
Boolean(flightsEnabled) &&
Boolean(militaryEnabled)
subjectEnterable !== false
);
}
+42 -21
View File
@@ -1,6 +1,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
bridgeEntryAllowed,
orbitEntryAllowed,
cockpitEntryAllowed,
contextAllowedLayerIds,
contextLayerEnableBlockReason,
@@ -400,37 +402,56 @@ 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');
}), false, 'there is no aircraft to ride');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: 'ais-live-vessels',
subjectLayerEnabled: true,
contextModeChanging: false,
flightsEnabled: false,
militaryEnabled: true,
}), false, 'the civilian contact feed is required');
}), false, "a vessel is the bridge gate's subject, not Cockpit's");
});
test('bridge entry needs only Live AIS Vessels', () => {
assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: false }), true);
assert.equal(bridgeEntryAllowed({ vesselsEnabled: false, contextModeChanging: false }), false);
assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: true }), false);
});
test('orbital entry needs only Satellites', () => {
assert.equal(orbitEntryAllowed({ satellitesEnabled: true, contextModeChanging: false }), true);
assert.equal(orbitEntryAllowed({ satellitesEnabled: false, contextModeChanging: false }), false);
assert.equal(orbitEntryAllowed({ satellitesEnabled: true, contextModeChanging: true }), false);
});
test('debris can be tracked but never ridden in orbit', () => {
const on = { satellitesEnabled: true, contextModeChanging: false };
assert.equal(orbitEntryAllowed({ ...on, subjectEnterable: false }), false);
assert.equal(orbitEntryAllowed({ ...on, subjectEnterable: true }), true);
assert.equal(orbitEntryAllowed(on), true, 'no subject read yet');
});
test('context teardown guard restores coordination state after success and failure', async () => {
+90
View File
@@ -2,9 +2,13 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import { geminiLiveProxy } from '../server/providers/gemini.js';
import { GEMINI_DEFAULT_PER_MIN } from '../server/providers/gemini/chat.js';
import {
makeCostRateLimiter,
resolvePerMinuteCap,
@@ -168,6 +172,92 @@ test('the realtime token route throttles by default, and says so in a 429', asyn
);
});
function chatRequest(handler, input) {
const req = Readable.from([Buffer.from(JSON.stringify(input))]);
Object.assign(req, {
method: 'POST',
url: '/',
headers: {
host: 'localhost:4173',
origin: 'http://localhost:4173',
'content-type': 'application/json',
},
socket: { remoteAddress: '127.0.0.1' },
});
const headers = {};
const res = Object.assign(new EventEmitter(), {
statusCode: 200,
setHeader(name, value) {
headers[name.toLowerCase()] = value;
},
});
return new Promise((resolve, reject) => {
res.end = (body = '') => {
res.writableEnded = true;
resolve({ status: res.statusCode, headers, body: String(body) });
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
test('typed chat follows each provider throttle by default, before upstream', async (t) => {
for (const name of [
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
'GEV_RATELIMIT_OPENAI_PER_MIN',
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
'GEV_RATELIMIT_GEMINI_PER_MIN',
'GEV_PREFER_CODEX_OAUTH',
])
env(t, name, undefined);
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEMINI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
upstreamCalls += 1;
return Response.json(
String(url).includes('openai')
? { choices: [{ message: { role: 'assistant', content: 'Hi' } }] }
: {
candidates: [
{ content: { role: 'model', parts: [{ text: 'Hi' }] } },
],
},
);
});
// The chat fallback must track the Gemini token route's own default.
const tokenDefault = readFileSync(
new URL('../server/providers/gemini/realtime.js', import.meta.url),
'utf8',
).match(/GEV_RATELIMIT_GEMINI_PER_MIN \?\? '(\d+)'/)?.[1];
assert.equal(Number(tokenDefault), GEMINI_DEFAULT_PER_MIN);
for (const [route, handler, cap, input] of [
[
'/api/openai/chat',
install(openAiRealtimeProxy({})).get('/api/openai/chat'),
OPENAI_DEFAULT_PER_MIN,
{ messages: [{ role: 'user', content: 'Hello' }] },
],
[
'/api/gemini/chat',
install(geminiLiveProxy({})).get('/api/gemini/chat'),
GEMINI_DEFAULT_PER_MIN,
{ contents: [{ role: 'user', parts: [{ text: 'Hello' }] }] },
],
]) {
upstreamCalls = 0;
const statuses = [];
for (let i = 0; i < cap; i += 1)
statuses.push((await chatRequest(handler, input)).status);
assert.deepEqual(statuses, Array(cap).fill(200), route);
const overLimit = await chatRequest(handler, input);
assert.equal(overLimit.status, 429, route);
assert.equal(overLimit.headers['retry-after'], '60');
assert.equal(overLimit.body.includes('fixture-upstream-secret'), false);
assert.equal(upstreamCalls, cap, `${route} refuses before upstream`);
}
});
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
+41 -17
View File
@@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([
// Custom properties allowed inside a modelled offset. Each is a pinned-tray
// stack height, proven non-negative below, so evaluating them at 0 is a
// conservative floor rather than a guess.
// conservative floor rather than a guess. The world timeline's lift raises
// the dock, its trays and the credit together, so it moves no gap either.
const VETTED_VARS = new Set([
'--dock-pinned-stack-height',
'--dock-lower-pinned-height',
'--dock-location-pinned-height',
'--dock-presets-pinned-height',
'--world-timeline-lift',
]);
// Custom properties CSS sets to a fixed length besides their 0 default. The
// clearance checks run at each length CSS gives them, not only at 0.
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
const LIFTS_PX = [0, 66, 80];
let liftPx = 0;
function eachLift(check) {
for (const lift of LIFTS_PX) {
liftPx = lift;
try {
check(lift);
} finally {
liftPx = 0;
}
}
}
/** Rules whose final compound targets a modelled element (pseudo-elements aside). */
function ownBoxEntries() {
@@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) {
if (term.startsWith('var(')) {
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`);
if (name === '--world-timeline-lift') total += liftPx;
continue; // proven non-negative; 0 is the conservative floor
}
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`);
@@ -444,7 +462,8 @@ test('custom properties inside modelled offsets are provably non-negative', () =
assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
for (const decl of declared) {
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/;
assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`);
}
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
const start = layout.indexOf('_updateCommandDockTrayStack() {');
@@ -517,16 +536,18 @@ test('every open dock tray clears the required credit at every modelled viewport
// number: the dock is anchored at 2vh down to 721px and re-anchors to a flat
// 8px at 720px, while the credit keeps its 2vh base throughout.
const failures = [];
for (const scenario of TRAY_SCENARIOS) {
for (const width of WIDTHS.filter((w) => w <= 900)) {
for (const height of HEIGHTS) {
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) {
failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`);
eachLift((lift) => {
for (const scenario of TRAY_SCENARIOS) {
for (const width of WIDTHS.filter((w) => w <= 900)) {
for (const height of HEIGHTS) {
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) {
failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
}
}
}
}
}
});
assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`);
});
@@ -566,21 +587,24 @@ test('the full-width context rail clears the required credit at every modelled v
'none',
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
);
for (const height of HEIGHTS) {
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`);
}
eachLift((lift) => {
for (const height of HEIGHTS) {
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
}
});
}
assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`);
});
test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh');
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px');
const lift = 'var(--world-timeline-lift, 0px)';
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`);
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`);
assert.equal(
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
'calc(2vh + 5rem)',
`calc(2vh + 5rem + ${lift})`,
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
);
});
+72
View File
@@ -421,6 +421,49 @@ const COUNTRY_TARGET_ALIASES = new Map([
['drc', 'democratic republic of the congo'],
]);
/**
* Country aliases that are another name for the very same unit. "Great
* Britain" and "Britain" are islands, "America" is also two continents and
* "Korea" two countries, so those find a country without naming it.
*/
const COUNTRY_IDENTITY_ALIASES = new Set([
'usa',
'us',
'u s',
'u s a',
'united states',
'uk',
'u k',
'russian federation',
'czechia',
'tanzania',
'drc',
]);
/**
* The unit an ask names in the matcher's own terms, when its words differ
* from the unit's name: "State of Texas" names Texas at admin level 1, and
* "USA" or "Country of USA" names United States of America. Null when the
* ask is just a name. Names are folded with `normalizeAdminName`.
* @param {string} text
* @returns {{name: string, level: 'country'|'admin1'}|null}
*/
export function adminQueryIdentity(text) {
const parsed = parseAdminQuery(text);
if (!parsed?.name) return null;
if (parsed.kind === 'state' && parsed.form === 'prefix')
return { name: parsed.name, level: 'admin1' };
if (parsed.kind !== 'country' && parsed.kind !== 'any') return null;
if (COUNTRY_IDENTITY_ALIASES.has(parsed.name))
return {
name: COUNTRY_TARGET_ALIASES.get(parsed.name),
level: 'country',
};
return parsed.kind === 'country'
? { name: parsed.name, level: 'country' }
: null;
}
const COUNTRY_ALIASES = new Map([
...COUNTRY_TARGET_ALIASES,
['england', 'united kingdom'],
@@ -796,6 +839,35 @@ export async function findAdminArea(query, { near = null } = {}) {
return toResult(ranked[0], candidates.length);
}
/**
* Every prominent unit an ask's words could mean — countries, and states or
* provinces that would answer a bare name (see `stateCandidates`) — for a
* caller that asks the user which one ("Georgia", "Punjab") instead of
* letting the camera decide. Qualifiers narrow the list as in
* `findAdminArea`; minor units ("Kent", "Santa Barbara") are left out.
*
* @param {string} query
* @returns {Promise<AdminArea[]>} Largest first; empty when none.
*/
export async function findAdminCandidates(query) {
const parsed = parseAdminQuery(query);
if (!parsed?.name || parsed.kind === 'county') return [];
const prominent = ({ entry }) =>
entry.kind === 'country' ||
(entry.feature.rank ?? Infinity) <= BARE_NAME_MAX_RANK ||
Boolean(entry.feature.cityState);
const candidates = [
...(await countryCandidates(parsed)),
...(parsed.kind === 'country'
? []
: await stateCandidates(parsed, { allowAmbiguous: true })),
].filter(prominent);
const seen = new Set();
return rank(candidates, null)
.filter(({ entry }) => !seen.has(entry) && seen.add(entry))
.map((scored) => toResult(scored, candidates.length));
}
/**
* Geocoder-confirmed lookup: the geocoder typed a country, state or
* county, so the unit must carry one of `names` AND contain the geocoded
+2
View File
@@ -7,6 +7,7 @@ import {
decodeRing,
findAdminArea,
findAdminAreaAt,
findAdminCandidates,
normalizeAdminName,
packLoads,
parseAdminQuery,
@@ -247,6 +248,7 @@ test('geocoder-confirmed lookup: the named unit that contains the point', async
),
null,
);
assert.deepEqual(await findAdminCandidates('Bagmati Province'), []);
// A county the geocoder typed, named without the county word.
const travis = await findAdminAreaAt(
['Travis', 'Travis County'],
+16
View File
@@ -989,6 +989,22 @@ test('vessel interaction wire: trail pick does not deselect', () => {
}
});
test('vessel interaction wire: a click on open water keeps the ship the bridge rides', () => {
const harness = installWireHarness({ id: 'gev-trailing-contact' });
try {
aisLiveVesselsLayer.setCockpitSubject(harness.record.mmsi);
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(harness.trail.clearCalls, 0);
// Released, the same click deselects as it always has.
aisLiveVesselsLayer.setCockpitSubject(null);
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
} finally {
harness.cleanup();
}
});
test('vessel interaction wire: only the gev-trail: namespace receives the trail no-op', () => {
const harness = installWireHarness({ id: 'gev-trailing-contact' });
try {
+230 -47
View File
@@ -18,7 +18,11 @@
*
* Providers (injected — keeps the engine pure and node-testable):
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
* resolveRegionRing(name, signal?) → Promise<{ring, name}|{error:'region-timeout'}|null>
* resolveRegionRing(name, {signal}?) → Promise<{name, geometry|ring, prepared?, source?}
* |{error:'region-timeout'}|{needsClarification, candidates}|null>
* (geometry = MultiPolygon coordinates; islands and holes all count)
* resolveAreaScope(scope) → Promise<{ok:true, area:{areaId, name, prepared,
* source, approximate}}|{ok:false, code, error}> (area/drawn/annotation)
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
*
* @module data/analystEngine
@@ -26,6 +30,7 @@
import { feedProvenanceEnvelope } from './layerSnapshot.js';
import { pointInRing } from './naturalEarthRegions.js';
import { pointInPreparedArea, prepareArea } from './areaGeometry.js';
import { VOICE_LAYER_MANIFEST, voiceLayer } from '../voice/layerManifest.js';
import { greatCircleKm } from '../geoDistance.js';
@@ -67,6 +72,9 @@ const ANALYST_SCOPE_KINDS = Object.freeze([
'region',
'radius',
'anywhere',
'area',
'drawn',
'annotation',
]);
/**
@@ -104,9 +112,14 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */
/** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere'|'area', …}. */
export function applyScope(records, scope, resolved) {
if (!scope || scope.kind === 'anywhere') return records;
if (resolved?.prepared) {
return records.filter((r) =>
pointInPreparedArea(resolved.prepared, r.lat, r.lon),
);
}
if (scope.kind === 'region' && resolved?.ring) {
return records.filter(
(r) =>
@@ -320,6 +333,24 @@ function normalizeSpec(spec, layerKeys) {
return {
refusal: refusal('BAD_SCOPE', 'A region scope needs a place name.'),
};
if (
kind === 'area' &&
(typeof scope.areaId !== 'string' || !scope.areaId.trim())
)
return {
refusal: refusal(
'BAD_SCOPE',
'An area scope needs the areaId resolve_area returned.',
),
};
if (
kind === 'annotation' &&
scope.id !== undefined &&
(typeof scope.id !== 'string' || !scope.id.trim())
)
return {
refusal: refusal('BAD_SCOPE', 'scope.id must be an annotation id.'),
};
if (scope.center !== undefined && scope.center !== null) {
if (!validLat(scope.center.lat) || !validLon(scope.center.lon))
return {
@@ -456,7 +487,16 @@ const ANSWERABLE = new Set(['ok', 'empty']);
/** Rows per scan slice before yielding to the page. */
const SCAN_CHUNK = 20_000;
/**
* A slice also ends after this long: a row inside a large area pays a ray
* cast over every edge (a 6,000-vertex outline takes ~200 ms per 20,000 rows),
* so the row count alone would still make long tasks.
*/
const SCAN_BUDGET_MS = 8;
/** Rows between clock checks. */
const SCAN_CLOCK_EVERY = 256;
const yieldToPage = () => new Promise((resolve) => setTimeout(resolve, 0));
const clockMs = () => globalThis.performance?.now?.() ?? Date.now();
/**
* Compile the filters into one predicate over (layerKey, row).
@@ -597,12 +637,19 @@ export async function selectAnalystRows(
let min = Infinity;
let max = -Infinity;
let seen = 0;
let sliceStart = clockMs();
for (const { key, rows } of groups) {
const kept = [];
for (const row of rows) {
if (++seen % SCAN_CHUNK === 0) {
seen += 1;
if (
seen % SCAN_CHUNK === 0 ||
(seen % SCAN_CLOCK_EVERY === 0 &&
clockMs() - sliceStart >= SCAN_BUDGET_MS)
) {
await yieldToPage();
if (!isCurrent()) return null;
sliceStart = clockMs();
}
if (inScope && !inScope(row)) continue;
if (!passes(key, row)) continue;
@@ -650,9 +697,8 @@ export function createAnalystEngine(providers) {
/**
* Run one query.
* @param {object} spec Query arguments (see the analyst_query tool).
* @param {{isCurrent?: () => boolean, signal?: AbortSignal,
* beforeCommit?: (result: object) => Promise<object|void>}} [options]
* A query that is no longer current
* @param {{signal?: AbortSignal, isCurrent?: () => boolean, beforeCommit?: (result: object) =>
* Promise<object|void>}} [options] A query that is no longer current
* (cancelled or superseded while awaiting a boundary or between scan
* slices) returns CANCELLED and never replaces the follow-up memory.
* `beforeCommit` lets a caller finish required async projection work and
@@ -662,7 +708,7 @@ export function createAnalystEngine(providers) {
*/
async function query(
spec = {},
{ isCurrent = () => true, signal = undefined, beforeCommit = null } = {},
{ signal = null, isCurrent = () => true, beforeCommit = null } = {},
) {
const cancelled = () =>
refusal('CANCELLED', 'This query was superseded.', { cancelled: true });
@@ -673,6 +719,7 @@ export function createAnalystEngine(providers) {
{ coverage: { layersQueried: [], scope: 'none' } },
);
const followUp = Boolean(spec.followUp);
const inheritsRememberedScope = followUp && spec.scope == null;
// A follow-up re-filters the remembered set. Layers named explicitly
// must be that set: "those hospitals" after an aircraft answer is a new
// question, never a filter over the old aircraft.
@@ -815,25 +862,37 @@ export function createAnalystEngine(providers) {
}
// 2) Spatial scope
const rememberedScope =
followUp && !spec.scope ? lastResult.scopePresentation || null : null;
const rememberedDistanceOf =
followUp && !spec.scope ? lastResult.distanceOf || null : null;
const rememberedPresentation = inheritsRememberedScope
? lastResult.presentation || null
: null;
const rememberedCoverageNote =
rememberedPresentation?.coverageNote ||
(inheritsRememberedScope ? lastResult.coverage?.note : null);
const rememberedDistanceOf = inheritsRememberedScope
? lastResult.distanceOf || null
: null;
let resolvedScope = null;
let scopeNote = rememberedScope?.coverage || 'anywhere';
let scopeSource = rememberedScope?.source || null;
let scopeNote =
rememberedPresentation?.coverageScope ||
(inheritsRememberedScope ? lastResult.coverage?.scope : null) ||
'anywhere';
let scopeSource =
rememberedPresentation?.coverageScopeSource ||
(inheritsRememberedScope ? lastResult.coverage?.scopeSource : null) ||
null;
// Human phrasing for the same scope, so every spoken count can name what it
// measured ("8 in view", "about 30 within 250 km of Austin") instead of
// arriving as a bare number that contradicts the panel.
let scopeLabel = rememberedScope?.label || 'anywhere in the loaded data';
let scopeDetail = rememberedScope?.detail || scopeLabel;
let scopeLabel =
rememberedPresentation?.scopeLabel || 'anywhere in the loaded data';
let scopeDetail = rememberedPresentation?.display?.scope || scopeLabel;
// A follow-up re-filters a set that was already scoped, so it only narrows
// further when a new scope is given.
const scope = spec.scope || { kind: followUp ? 'anywhere' : 'view' };
if (scope.kind === 'region') {
let region;
try {
region = await providers.resolveRegionRing(scope.name, signal);
region = await providers.resolveRegionRing(scope.name, { signal });
} catch (error) {
// Cancellation belongs to the query owner, not an error's name: a
// resource-local AbortError from a current lookup still propagates.
@@ -849,7 +908,32 @@ export function createAnalystEngine(providers) {
coverage: { layersQueried, scope: `region:${scope.name}:timeout` },
};
}
if (!region?.ring) {
if (region?.needsClarification) {
return refusal(
'NEEDS_CLARIFICATION',
region.error ||
`"${scope.name}" names more than one place — ask which one.`,
{
needsClarification: true,
query: scope.name,
candidates: region.candidates || [],
// The way back once the user picks: count over that area.
then: 'After the user picks, call resolve_area with that candidateId, then analyst_query with scope {kind:"area", areaId}.',
coverage: {
layersQueried,
scope: `region:${scope.name}:ambiguous`,
},
},
);
}
const prepared =
region?.prepared ||
(region?.geometry
? prepareArea(region.geometry)
: region?.ring
? prepareArea([[region.ring]])
: null);
if (!prepared) {
return {
ok: false,
code: 'REGION_UNRESOLVED',
@@ -857,11 +941,39 @@ export function createAnalystEngine(providers) {
coverage: { layersQueried, scope: `region:${scope.name}:unresolved` },
};
}
resolvedScope = region;
resolvedScope = { ...region, prepared };
scopeNote = `region:${region.name}`;
scopeSource = region.source || null;
scopeLabel = `over ${region.name}`;
scopeDetail = scopeLabel;
const regionSource = region.sourceLabel || region.source;
scopeDetail = `${scopeLabel}${region.approximate ? ' (approximate outline)' : ''}${regionSource ? ` · ${regionSource}` : ''}`;
} else if (
scope.kind === 'area' ||
scope.kind === 'drawn' ||
scope.kind === 'annotation'
) {
const found = providers.resolveAreaScope
? await providers.resolveAreaScope(scope)
: {
ok: false,
code: 'AREA_UNAVAILABLE',
error: 'Area scopes are not available here.',
};
if (!isCurrent()) return cancelled();
if (!found?.ok || !found.area?.prepared) {
return refusal(
found?.code || 'AREA_UNKNOWN',
found?.error || 'That area is not available.',
{ coverage: { layersQueried, scope: `${scope.kind}:unresolved` } },
);
}
const area = found.area;
resolvedScope = { prepared: area.prepared, name: area.name };
scopeNote = `area:${area.areaId || scope.kind}`;
scopeLabel = `inside ${area.name}`;
const sourceLabel = area.sourceLabel || area.source;
scopeDetail = `${scopeLabel}${area.approximate ? ' (approximate outline)' : ''}${sourceLabel ? ` · ${sourceLabel}` : ''}`;
if (area.areaId) resolvedScope.areaId = area.areaId;
} else if (scope.kind === 'radius') {
// An explicit center always wins. Otherwise, when Contacts is active its
// SUBJECT is the centre the operator is actually reasoning about: the
@@ -906,25 +1018,21 @@ export function createAnalystEngine(providers) {
scopeLabel = 'in view';
scopeDetail = `within ${Math.round(view.viewRadiusKm)} km of the view centre`;
}
const inScope =
scope.kind === 'region' && resolvedScope?.ring
const inScope = resolvedScope?.prepared
? (row) => pointInPreparedArea(resolvedScope.prepared, row.lat, row.lon)
: (scope.kind === 'radius' || scope.kind === 'view') &&
resolvedScope?.center &&
Number.isFinite(resolvedScope.km)
? (row) =>
Number.isFinite(row.lat) &&
Number.isFinite(row.lon) &&
pointInRing(resolvedScope.ring, row.lat, row.lon)
: (scope.kind === 'radius' || scope.kind === 'view') &&
resolvedScope?.center &&
Number.isFinite(resolvedScope.km)
? (row) =>
Number.isFinite(row.lat) &&
Number.isFinite(row.lon) &&
haversineKm(
resolvedScope.center.lat,
resolvedScope.center.lon,
row.lat,
row.lon,
) <= resolvedScope.km
: null;
haversineKm(
resolvedScope.center.lat,
resolvedScope.center.lon,
row.lat,
row.lon,
) <= resolvedScope.km
: null;
// 3) Filter, count and rank in one pass, yielding between slices.
const selection = await selectAnalystRows(groups, {
@@ -992,15 +1100,25 @@ export function createAnalystEngine(providers) {
// For on-screen text; the model speaks the count and scopeLabel only.
display: {
scope: scopeDetail,
...(windows.length ? { window: windows.join('; ') } : {}),
...(caveats.length ? { caveat: caveats.join('; ') } : {}),
...(rememberedPresentation?.display?.window || windows.length
? {
window:
rememberedPresentation?.display?.window || windows.join('; '),
}
: {}),
...(rememberedPresentation?.display?.caveat || caveats.length
? {
caveat:
rememberedPresentation?.display?.caveat || caveats.join('; '),
}
: {}),
},
coverage: {
layersQueried,
records: totals,
scope: scopeNote,
...(scopeSource ? { scopeSource } : {}),
...(rememberedScope?.note ? { note: rememberedScope.note } : {}),
...(rememberedCoverageNote ? { note: rememberedCoverageNote } : {}),
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
: {}),
@@ -1010,37 +1128,102 @@ export function createAnalystEngine(providers) {
// than implying a view-centred answer.
...(resolvedScope?.centeredOn
? { centeredOn: resolvedScope.centeredOn }
: rememberedPresentation?.centeredOn
? { centeredOn: rememberedPresentation.centeredOn }
: {}),
...(rememberedPresentation?.window
? { window: structuredClone(rememberedPresentation.window) }
: {}),
...(resolvedScope?.areaId
? { areaId: resolvedScope.areaId }
: rememberedPresentation?.areaId
? { areaId: rememberedPresentation.areaId }
: {}),
};
const memoryOverride =
typeof beforeCommit === 'function'
? (await beforeCommit(result)) || null
: null;
if (!isCurrent()) return cancelled();
const committedCoverage = memoryOverride?.coverage || result.coverage;
lastResult = {
matched: memoryOverride?.matched || selection.matched,
distanceOf: memoryOverride?.distanceOf || rememberedDistanceOf,
layerKeys: [...(memoryOverride?.layerKeys || layers)],
coverage: committedCoverage,
coverage: memoryOverride?.coverage || result.coverage,
unanswered: [
...(memoryOverride?.unanswered === undefined
? unanswered
: memoryOverride.unanswered),
],
scopePresentation: {
label: memoryOverride?.scopeLabel || result.scopeLabel,
detail: memoryOverride?.scopeDetail || result.display.scope,
coverage: committedCoverage.scope,
source: committedCoverage.scopeSource || null,
note: committedCoverage.note || null,
},
presentation: structuredClone(
memoryOverride?.presentation || {
scopeLabel: result.scopeLabel,
display: result.display,
coverageScope: result.coverage?.scope,
...(result.coverage?.scopeSource
? { coverageScopeSource: result.coverage.scopeSource }
: {}),
...(result.coverage?.note
? { coverageNote: result.coverage.note }
: {}),
...(result.centeredOn ? { centeredOn: result.centeredOn } : {}),
...(result.window ? { window: result.window } : {}),
...(result.areaId ? { areaId: result.areaId } : {}),
},
),
};
return result;
}
return {
query,
/**
* Make another tool's result set "the last answer" (osm_query's places),
* so a follow-up filters exactly what the user just heard about.
* @param {string} layerKey Queryable layer the rows belong to.
* @param {Array<object>} rows Records in that layer's analyst shape.
* @param {{scope?: string, presentation?: object|null, truncated?: boolean}} [options]
* `presentation` is how the set was scoped ("inside Kathmandu"), so a
* follow-up names the same scope instead of "anywhere". `truncated`
* says the set is only the first part of a larger one, so follow-ups
* stay incomplete (a count is a floor) rather than claiming the whole.
*/
remember(
layerKey,
rows,
{ scope = 'handoff', presentation = null, truncated = false } = {},
) {
if (!ANALYST_LAYERS[layerKey] || !Array.isArray(rows)) return false;
const capped = truncated === true;
// A capped set's true size is unknown.
const total = capped ? null : rows.length;
lastResult = {
...(presentation
? { presentation: structuredClone(presentation) }
: {}),
matched: [{ key: layerKey, rows: rows.slice() }],
layerKeys: [layerKey],
coverage: {
layersQueried: [
{
layerKey,
status: rows.length ? 'ok' : 'empty',
returned: rows.length,
total,
truncated: capped,
},
],
records: {
returned: rows.length,
total,
truncated: capped,
},
scope,
followUp: false,
},
};
return true;
},
reset() {
lastResult = null;
},
+282 -5
View File
@@ -275,14 +275,18 @@ for (const [label, committedScope, scopeSource] of [
const { scopeSource: _originalSource, ...coverage } = result.coverage;
return {
matched: [{ key: 'flights', rows: [FLIGHTS[1]] }],
scopeLabel: 'the presented cohort',
scopeDetail: 'the presented cohort window',
coverage: {
...coverage,
scope: committedScope,
...(scopeSource ? { scopeSource } : {}),
feedProvenance: { layers: [committedSnapshot] },
},
presentation: {
scopeLabel: 'the presented cohort',
display: { scope: 'the presented cohort window' },
coverageScope: committedScope,
...(scopeSource ? { coverageScopeSource: scopeSource } : {}),
},
};
},
},
@@ -436,6 +440,87 @@ test('analyst: failed pre-commit work cannot replace follow-up memory', async ()
assert.deepEqual(followUp.items.map((item) => item.id), ['SWA1']);
});
test('analyst: follow-ups preserve partial and unanswered coverage', async () => {
const eng = createAnalystEngine({
getRecords: (key) => (key === 'flights' ? FLIGHTS : []),
getLayerSnapshot: (key) => ({
enabled: key === 'flights',
feedState: key === 'flights' ? 'nominal' : 'off',
}),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const first = await eng.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
});
assert.equal(first.partial, true);
assert.deepEqual(first.unanswered, ['military']);
const followUp = await eng.query({ followUp: true, limit: 1 });
assert.equal(followUp.partial, true);
assert.deepEqual(followUp.unanswered, ['military']);
assert.deepEqual(
followUp.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
first.coverage.layersQueried.map(({ layerKey, status }) => [
layerKey,
status,
]),
);
});
test('analyst: cancelled pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
let current = true;
const stale = await eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
isCurrent: () => current,
beforeCommit: async () => {
current = false;
},
},
);
assert.equal(stale.code, 'CANCELLED');
const followUp = await eng.query({ followUp: true });
assert.deepEqual(
followUp.items.map((item) => item.id),
['SWA1'],
);
});
test('analyst: failed pre-commit work cannot replace follow-up memory', async () => {
const eng = makeEngine();
await eng.query({
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'id', op: 'eq', value: 'SWA1' }],
});
await assert.rejects(
eng.query(
{ layers: ['local-firms'], scope: { kind: 'anywhere' } },
{
beforeCommit: async () => {
throw new Error('projection failed');
},
},
),
/projection failed/,
);
const followUp = await eng.query({ followUp: true });
assert.deepEqual(
followUp.items.map((item) => item.id),
['SWA1'],
);
});
test('analyst: unresolved region is an honest failure, not empty success', async () => {
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Atlantis' } });
assert.equal(r.ok, false);
@@ -460,7 +545,7 @@ test('analyst: region lookup receives and obeys the owning action signal', async
let receivedSignal = null;
const eng = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: async (_name, signal) => {
resolveRegionRing: async (_name, { signal }) => {
receivedSignal = signal;
await new Promise((resolve) =>
signal.addEventListener('abort', resolve, { once: true }),
@@ -514,7 +599,7 @@ for (const [label, reason, trackAuthority] of [
};
const engine = createAnalystEngine({
getRecords: () => FLIGHTS,
resolveRegionRing: (name, signal) => resolveRegionRingForQuery(
resolveRegionRing: (name, { signal } = {}) => resolveRegionRingForQuery(
name, signal, placeSearch, { budgetMs: Infinity },
),
});
@@ -720,7 +805,9 @@ test('unknown fields, operators, scopes and wrong-typed values are refused with
assert.equal(num.code, 'BAD_VALUE');
const scope = await engine.query({ layers: ['flights'], scope: { kind: 'polygon' } });
assert.equal(scope.code, 'UNKNOWN_SCOPE');
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere']);
assert.deepEqual(scope.allowed, ['view', 'region', 'radius', 'anywhere', 'area', 'drawn', 'annotation']);
const noId = await engine.query({ layers: ['flights'], scope: { kind: 'area' } });
assert.equal(noId.code, 'BAD_SCOPE', 'an area scope needs its areaId');
const sort = await engine.query({ layers: ['flights'], sortBy: 'loudness' });
assert.equal(sort.code, 'UNKNOWN_FIELD');
assert.equal(engine.hasMemory(), false, 'refusals never become follow-up memory');
@@ -963,3 +1050,193 @@ test('analyst: a follow-up naming other layers than the last answer is refused',
assert.equal(r.code, 'FOLLOW_UP_MISMATCH');
assert.equal(engine.hasMemory(), true, 'the earlier answer is still the last answer');
});
// ── Area handles: region multipolygons and the area / drawn / annotation scopes ──
const box = (w, s, e, n) => [[w, s], [e, s], [e, n], [w, n], [w, s]];
function makeAreaEngine(overrides = {}) {
return createAnalystEngine({
getRecords: (key) => ({ flights: FLIGHTS, 'local-firms': FIRES }[key] || []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
...overrides,
});
}
test('analyst: a region counts every part of a multipolygon and skips its holes', async () => {
// Main part around Austin with a hole over the airport ground traffic, and a
// separate island over Oregon: the old largest-ring shortcut dropped N123.
const engine = makeAreaEngine({
resolveRegionRing: async () => ({
name: 'Archipelago',
geometry: [
[box(-100, 28, -94, 33), box(-97.7, 30.1, -97.6, 30.25)],
[box(-123, 44, -121, 46)],
],
source: 'osm',
}),
});
const r = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Archipelago' }, limit: 10 });
assert.equal(r.ok, true);
assert.deepEqual(r.items.map((i) => i.id).sort(), ['N123', 'RCH01']);
assert.equal(r.count, 2, 'SWA1 and GND1 sit in the hole');
assert.match(r.display.scope, /over Archipelago · osm/);
});
test('analyst: an ambiguous region asks instead of counting', async () => {
const engine = makeAreaEngine({
resolveRegionRing: async () => ({
ok: false,
needsClarification: true,
candidates: [{ candidateId: 'osm:r1', name: 'Georgia', level: 'country' }],
error: '"Georgia" matches 2 places — ask which one.',
}),
});
const r = await engine.query({ layers: ['flights'], scope: { kind: 'region', name: 'Georgia' } });
assert.equal(r.ok, false);
assert.equal(r.code, 'NEEDS_CLARIFICATION');
assert.equal(r.candidates.length, 1);
assert.match(r.then, /resolve_area.*candidateId.*analyst_query.*kind.*area/, 'the model gets a route back');
assert.equal(engine.hasMemory(), false);
});
test('analyst: region lookup receives the caller lifetime signal', async () => {
const controller = new AbortController();
let received;
const engine = makeAreaEngine({
resolveRegionRing: async (_name, options) => {
received = options?.signal;
return { error: 'region-timeout' };
},
});
const result = await engine.query(
{
layers: ['flights'],
scope: { kind: 'region', name: 'Signal Region' },
},
{ signal: controller.signal },
);
assert.equal(received, controller.signal);
assert.equal(result.code, 'region-timeout');
});
test('analyst: an area scope counts inside the stored handle and names it', async () => {
const { prepareArea } = await import('./areaGeometry.js');
const prepared = prepareArea([[box(-99, 30, -97.5, 31)]]);
const seen = [];
const engine = makeAreaEngine({
resolveAreaScope: async (scope) => {
seen.push(scope);
if (scope.kind === 'area' && scope.areaId === 'osm:r42')
return { ok: true, area: { areaId: 'osm:r42', name: 'Hill Country', prepared, source: 'osm' } };
if (scope.kind === 'drawn')
return { ok: true, area: { areaId: 'anno:anno-7', name: 'the drawn area', prepared, source: 'drawn' } };
return { ok: false, code: 'AREA_UNKNOWN', error: 'No such area' };
},
});
const fires = await engine.query({ layers: ['local-firms'], scope: { kind: 'area', areaId: 'osm:r42' } });
assert.equal(fires.ok, true);
assert.equal(fires.count, 2);
assert.equal(fires.scopeLabel, 'inside Hill Country');
assert.equal(fires.areaId, 'osm:r42');
assert.equal(fires.coverage.scope, 'area:osm:r42');
const followUp = await engine.query({ followUp: true, limit: 1 });
assert.equal(followUp.scopeLabel, 'inside Hill Country');
assert.match(followUp.display.scope, /inside Hill Country/);
assert.equal(followUp.coverage.scope, 'area:osm:r42');
assert.equal(followUp.areaId, 'osm:r42');
const drawn = await engine.query({ layers: ['flights'], scope: { kind: 'drawn' } });
assert.equal(drawn.ok, true);
assert.equal(drawn.count, 2, 'SWA1 and GND1 sit inside the drawn box');
const missing = await engine.query({ layers: ['flights'], scope: { kind: 'area', areaId: 'osm:r0' } });
assert.equal(missing.ok, false);
assert.equal(missing.code, 'AREA_UNKNOWN');
assert.deepEqual(seen.map((s) => s.kind), ['area', 'drawn', 'area']);
});
test('analyst: an area scope superseded while resolving is cancelled and leaves no memory', async () => {
const { prepareArea } = await import('./areaGeometry.js');
let current = true;
const engine = makeAreaEngine({
resolveAreaScope: async () => {
current = false; // a newer turn arrived while the area was looked up
return { ok: true, area: { name: 'X', prepared: prepareArea([[box(-100, 28, -94, 33)]]) } };
},
});
const r = await engine.query(
{ layers: ['flights'], scope: { kind: 'annotation' } },
{ isCurrent: () => current },
);
assert.equal(r.code, 'CANCELLED');
assert.equal(engine.hasMemory(), false);
});
test('analyst: without an area provider the area scopes refuse plainly', async () => {
const r = await makeAreaEngine().query({ layers: ['flights'], scope: { kind: 'drawn' } });
assert.equal(r.ok, false);
assert.equal(r.code, 'AREA_UNAVAILABLE');
});
test('a follow-up on handed-off places keeps the area they were found in', async () => {
const rows = [
{ id: 'node/1', lat: 27.7, lon: 85.3, name: 'Bir Hospital', kind: 'hospital', emergency: 'yes' },
{ id: 'node/2', lat: 27.71, lon: 85.31, name: 'Teaching Hospital', kind: 'hospital', emergency: 'no' },
];
const engine = createAnalystEngine({
getRecords: (key) => (key === 'osm-places' ? rows : []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }),
});
assert.equal(
engine.remember('osm-places', rows, {
presentation: { scopeLabel: 'inside Kathmandu', coverageScope: 'area:area-1', areaId: 'area-1' },
}),
true,
);
const result = await engine.query({
layers: ['osm-places'],
followUp: true,
filters: [{ field: 'emergency', op: 'eq', value: 'yes' }],
});
assert.equal(result.count, 1);
assert.equal(result.scopeLabel, 'inside Kathmandu');
assert.equal(result.coverage.scope, 'area:area-1');
});
test('a follow-up on a capped handed-off set is not complete', async () => {
const rows = [
{ id: 'node/1', lat: 27.7, lon: 85.3, name: 'Bir Hospital', kind: 'hospital', emergency: 'yes' },
];
const engine = createAnalystEngine({
getRecords: (key) => (key === 'osm-places' ? rows : []),
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 27.7, lon: 85.3, viewRadiusKm: 20 }),
});
engine.remember('osm-places', rows, { truncated: true });
const result = await engine.query({ layers: ['osm-places'], followUp: true });
assert.equal(result.count, 1);
assert.equal(result.complete, false);
assert.equal(result.coverage.records.truncated, true);
assert.equal(result.coverage.records.total, null, 'the true size is unknown');
});
test('a slow scan yields on a time budget, not only every 20,000 rows', async () => {
// 4,000 rows that each cost ~0.05 ms to read: ~200 ms of work in one slice
// under a row-count budget alone.
const slow = () => {
const until = performance.now() + 0.05;
while (performance.now() < until);
return 5;
};
const rows = Array.from({ length: 4_000 }, (_, i) => ({ id: `S${i}`, lat: 0, lon: 0, get frp() { return slow(); } }));
const engine = createAnalystEngine({ getRecords: () => rows, getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }) });
// Count the scan's yields (other timers run only when it yields), not wall
// gaps, so a loaded machine cannot fail it.
let ticks = 0;
const timer = setInterval(() => ticks++, 0);
const result = await engine.query({ layers: ['local-firms'], scope: { kind: 'anywhere' }, filters: [{ field: 'frp', op: 'gt', value: 1 }] });
clearInterval(timer);
assert.equal(result.count, 4_000);
assert.ok(ticks >= 5, `the scan yielded ${ticks} times in ~200 ms of work`);
});
+397
View File
@@ -0,0 +1,397 @@
/**
* Area geometry for voice area handles: normalization, containment, size and
* display simplification over full MultiPolygons (islands and holes kept).
*
* Coordinates follow GeoJSON: a MultiPolygon is `Array<Polygon>`, a Polygon is
* `Array<Ring>` with the outer ring first and holes after it, a Ring is
* `Array<[lon, lat]>`.
*
* Counting uses the geometry exactly as resolved. Display simplification
* (`displayParts`) makes a separate copy for drawing and never feeds back into
* containment.
*
* Antimeridian: a ring whose consecutive longitudes jump by more than 180° is
* unwrapped into one continuous span (e.g. 170..190) when the jumps cancel
* out. Containment then tests the point at lon, lon + 360 and lon − 360. A ring
* that circles a pole (jumps that do not cancel) is used as given.
*
* Pure: no Cesium, DOM or network.
* @module data/areaGeometry
*/
const EARTH_RADIUS_KM = 6371;
const toRad = (degrees) => (degrees * Math.PI) / 180;
const validPair = (p) =>
Array.isArray(p) &&
Number.isFinite(p[0]) &&
Number.isFinite(p[1]) &&
Math.abs(p[0]) <= 540 &&
Math.abs(p[1]) <= 90;
const samePoint = (a, b, eps = 1e-9) =>
Math.abs(a[0] - b[0]) <= eps && Math.abs(a[1] - b[1]) <= eps;
/** A ring as closed [lon, lat] pairs, or null with fewer than 3 distinct points. */
function cleanRing(ring) {
if (!Array.isArray(ring)) return null;
const out = [];
for (const p of ring) {
if (!validPair(p)) continue;
const pair = [Number(p[0]), Number(p[1])];
if (out.length && samePoint(out[out.length - 1], pair)) continue;
out.push(pair);
}
if (out.length && samePoint(out[0], out[out.length - 1])) out.pop();
if (out.length < 3) return null;
out.push([out[0][0], out[0][1]]);
return out;
}
/**
* GeoJSON Polygon/MultiPolygon geometry (or bare coordinates) → MultiPolygon
* coordinates with closed rings; null when nothing usable remains. Degenerate
* holes are dropped; a polygon whose outer ring is degenerate is dropped whole.
* @param {object|Array} geometry
* @returns {Array|null}
*/
export function normalizeMultiPolygon(geometry) {
let polygons;
if (Array.isArray(geometry)) {
// Bare coordinates: a ring, a polygon or a multipolygon, by nesting depth.
const depth = (value) => (Array.isArray(value) ? 1 + depth(value[0]) : 0);
const d = depth(geometry);
polygons = d === 2 ? [[geometry]] : d === 3 ? [geometry] : geometry;
} else if (geometry?.type === 'Polygon') polygons = [geometry.coordinates];
else if (geometry?.type === 'MultiPolygon') polygons = geometry.coordinates;
else return null;
const out = [];
for (const polygon of Array.isArray(polygons) ? polygons : []) {
if (!Array.isArray(polygon)) continue;
const outer = cleanRing(polygon[0]);
if (!outer) continue;
const holes = polygon.slice(1).map(cleanRing).filter(Boolean);
out.push([outer, ...holes]);
}
return out.length ? out : null;
}
/**
* Longitudes made continuous across the antimeridian. Returns the ring as
* given when it has no jump, or when its jumps do not cancel (a ring that
* circles a pole).
*/
export function unwrapRing(ring) {
let shift = 0;
let jumped = false;
const out = [[ring[0][0], ring[0][1]]];
for (let i = 1; i < ring.length; i += 1) {
const delta = ring[i][0] - ring[i - 1][0];
if (delta > 180) {
shift -= 360;
jumped = true;
} else if (delta < -180) {
shift += 360;
jumped = true;
}
out.push([ring[i][0] + shift, ring[i][1]]);
}
if (!jumped) return ring;
return shift === 0 ? out : ring;
}
function ringBbox(ring) {
let w = Infinity;
let s = Infinity;
let e = -Infinity;
let n = -Infinity;
for (const [lon, lat] of ring) {
if (lon < w) w = lon;
if (lon > e) e = lon;
if (lat < s) s = lat;
if (lat > n) n = lat;
}
return [w, s, e, n];
}
/** Even-odd ray cast. */
function pointInRingXY(ring, lon, lat) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
if (
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
)
inside = !inside;
}
return inside;
}
const wrap180 = (lon) => ((((lon + 180) % 360) + 360) % 360) - 180;
const wrap360 = (lon) => {
const wrapped = lon % 360;
return wrapped < 0 ? wrapped + 360 : wrapped;
};
/** The shortest circular longitude interval containing every outer-ring vertex. */
function longitudeEnvelope(parts) {
const values = parts
.flatMap(({ outer }) => outer.map(([lon]) => wrap360(lon)))
.sort((a, b) => a - b);
if (values.length === 0) return null;
let gapIndex = values.length - 1;
let largestGap = values[0] + 360 - values.at(-1);
for (let i = 0; i < values.length - 1; i += 1) {
const gap = values[i + 1] - values[i];
if (gap > largestGap) {
largestGap = gap;
gapIndex = i;
}
}
const start = values[(gapIndex + 1) % values.length];
let end = values[gapIndex];
if (end < start) end += 360;
const span = end - start;
if (span >= 360 - 1e-9) return { west: -180, east: 180, crosses: false };
const west = start > 180 ? start - 360 : start;
const endOnCircle = end >= 360 ? end - 360 : end;
const east = endOnCircle > 180 ? endOnCircle - 360 : endOnCircle;
return { west, east, crosses: west > east };
}
/**
* Index a MultiPolygon for repeated containment tests: unwrapped rings with
* per-part bounding boxes, plus the overall bbox as [west, south, east, north]
* in −180..180 (west > east when it crosses the antimeridian).
* @param {Array} coordinates MultiPolygon coordinates.
* @returns {{parts: Array, bbox: number[], vertexCount: number, crossesAntimeridian: boolean}|null}
*/
export function prepareArea(coordinates) {
const multi = normalizeMultiPolygon(coordinates);
if (!multi) return null;
let vertexCount = 0;
let crosses = false;
const parts = multi.map((polygon) => {
const outer = unwrapRing(polygon[0]);
if (outer !== polygon[0]) crosses = true;
const holes = polygon.slice(1).map((ring) => {
let hole = unwrapRing(ring);
// Keep a hole in the same longitude frame as its outer ring.
const hb = ringBbox(hole);
const ob = ringBbox(outer);
const mid = (hb[0] + hb[2]) / 2;
if (mid < ob[0]) hole = hole.map(([x, y]) => [x + 360, y]);
else if (mid > ob[2]) hole = hole.map(([x, y]) => [x - 360, y]);
return hole;
});
for (const ring of polygon) vertexCount += ring.length;
return { outer, holes, bbox: ringBbox(outer) };
});
let south = Infinity;
let north = -Infinity;
for (const { bbox } of parts) {
south = Math.min(south, bbox[1]);
north = Math.max(north, bbox[3]);
}
const envelope = longitudeEnvelope(parts);
const bbox = [envelope.west, south, envelope.east, north];
crosses ||= envelope.crosses;
return { parts, bbox, vertexCount, crossesAntimeridian: crosses };
}
function inPart(part, lon, lat) {
const [w, s, e, n] = part.bbox;
if (lat < s || lat > n || lon < w || lon > e) return false;
if (!pointInRingXY(part.outer, lon, lat)) return false;
for (const hole of part.holes)
if (pointInRingXY(hole, lon, lat)) return false;
return true;
}
/**
* Whether a point lies inside the area: inside some part's outer ring and in
* none of that part's holes. Points exactly on an edge follow the ray-cast
* convention and may land either side.
* @param {object} prepared From prepareArea.
* @param {number} lat
* @param {number} lon
* @returns {boolean}
*/
export function pointInPreparedArea(prepared, lat, lon) {
if (!prepared || !Number.isFinite(lat) || !Number.isFinite(lon)) return false;
for (const part of prepared.parts) {
if (inPart(part, lon, lat)) return true;
if (inPart(part, lon + 360, lat)) return true;
if (inPart(part, lon - 360, lat)) return true;
}
return false;
}
/** Spherical-excess ring area in km² (unsigned). */
export function ringAreaKm2(ring) {
const n = ring.length;
if (n < 3) return 0;
let sum = 0;
for (let i = 0; i < n - 1; i += 1) {
const [lon1, lat1] = ring[i];
const [lon2, lat2] = ring[i + 1];
sum +=
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
}
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
}
/**
* Area of a MultiPolygon in km², holes subtracted.
* @param {Array} coordinates
* @returns {number}
*/
export function multiPolygonAreaKm2(coordinates) {
const multi = normalizeMultiPolygon(coordinates);
if (!multi) return 0;
let total = 0;
for (const polygon of multi) {
total += ringAreaKm2(unwrapRing(polygon[0]));
for (const hole of polygon.slice(1)) total -= ringAreaKm2(unwrapRing(hole));
}
return Math.max(0, total);
}
/** Counts of parts and holes. */
export function multiPolygonCounts(coordinates) {
const multi = normalizeMultiPolygon(coordinates) || [];
return {
parts: multi.length,
holes: multi.reduce((sum, polygon) => sum + polygon.length - 1, 0),
};
}
/** Mean of the largest part's outer ring vertices, wrapped to −180..180. */
export function areaAnchor(coordinates) {
const multi = normalizeMultiPolygon(coordinates);
if (!multi) return null;
let best = null;
let bestArea = -1;
for (const polygon of multi) {
const ring = unwrapRing(polygon[0]);
const area = ringAreaKm2(ring);
if (area > bestArea) {
bestArea = area;
best = ring;
}
}
let lon = 0;
let lat = 0;
const pts = best.slice(0, -1);
for (const [x, y] of pts) {
lon += x;
lat += y;
}
return { lon: wrap180(lon / pts.length), lat: lat / pts.length };
}
// ── Display copies ────────────────────────────────────────────
function dpRing(ring, tol) {
const n = ring.length;
if (n <= 5) return ring;
const keep = new Uint8Array(n);
keep[0] = 1;
keep[n - 1] = 1;
const stack = [[0, n - 1]];
while (stack.length) {
const [a, b] = stack.pop();
if (b - a < 2) continue;
const [ax, ay] = ring[a];
const vx = ring[b][0] - ax;
const vy = ring[b][1] - ay;
const c2 = vx * vx + vy * vy;
let worst = -1;
let worstD = tol;
for (let i = a + 1; i < b; i += 1) {
const wx = ring[i][0] - ax;
const wy = ring[i][1] - ay;
const t =
c2 === 0 ? 0 : Math.max(0, Math.min(1, (vx * wx + vy * wy) / c2));
const d = Math.hypot(wx - t * vx, wy - t * vy);
if (d > worstD) {
worstD = d;
worst = i;
}
}
if (worst >= 0) {
keep[worst] = 1;
stack.push([a, worst], [worst, b]);
}
}
const out = [];
for (let i = 0; i < n; i += 1) if (keep[i]) out.push(ring[i]);
return out.length >= 4 ? out : ring;
}
/**
* A simplified copy of the area for drawing only: the largest `maxParts`
* parts, each ring Douglas-Peucker simplified until the whole copy fits in
* `maxVertices`. Rings keep their antimeridian-unwrapped longitudes so a
* crossing part draws as one shape.
* @param {Array} coordinates MultiPolygon coordinates.
* @param {{maxVertices?: number, maxParts?: number}} [options]
* @returns {{parts: Array<{outer: Array, holes: Array}>, drawnParts: number, totalParts: number}}
*/
export function displayParts(
coordinates,
{ maxVertices = 6000, maxParts = 40 } = {},
) {
const multi = normalizeMultiPolygon(coordinates) || [];
const ranked = multi
.map((polygon) => ({
rings: polygon.map((ring) => unwrapRing(ring)),
area: ringAreaKm2(unwrapRing(polygon[0])),
}))
.sort((a, b) => b.area - a.area)
.slice(0, maxParts);
const bbox = ranked.length ? ringBbox(ranked[0].rings[0]) : [0, 0, 0, 0];
let tol = Math.max(
1e-6,
Math.max(bbox[2] - bbox[0], bbox[3] - bbox[1]) / 1500,
);
let parts = [];
for (let attempt = 0; attempt < 12; attempt += 1) {
parts = ranked.map(({ rings }) => ({
outer: dpRing(rings[0], tol),
holes: rings.slice(1).map((ring) => dpRing(ring, tol)),
}));
const total = parts.reduce(
(sum, p) =>
sum + p.outer.length + p.holes.reduce((h, r) => h + r.length, 0),
0,
);
if (total <= maxVertices) break;
tol *= 2;
}
return { parts, drawnParts: parts.length, totalParts: multi.length };
}
/**
* A circle as a closed ring (planar metres around the centre — accurate for
* the few-kilometre buffers it is used for).
* @returns {Array<[number, number]>}
*/
export function circleRing(lat, lon, radiusM, segments = 48) {
const mPerDegLat = 111_320;
const mPerDegLon = mPerDegLat * Math.max(0.01, Math.cos(toRad(lat)));
const ring = [];
for (let i = 0; i < segments; i += 1) {
const a = (i / segments) * Math.PI * 2;
ring.push([
lon + (Math.cos(a) * radiusM) / mPerDegLon,
lat + (Math.sin(a) * radiusM) / mPerDegLat,
]);
}
ring.push([ring[0][0], ring[0][1]]);
return ring;
}
+178
View File
@@ -0,0 +1,178 @@
// Area geometry: containment over full MultiPolygons (islands, holes, the
// antimeridian), OSM relation assembly and display copies that never touch the
// counting geometry.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import {
circleRing,
displayParts,
multiPolygonAreaKm2,
multiPolygonCounts,
normalizeMultiPolygon,
pointInPreparedArea,
prepareArea,
} from './areaGeometry.js';
const square = (w, s, e, n) => [
[w, s],
[e, s],
[e, n],
[w, n],
[w, s],
];
test('a point on an island counts, not only on the largest part', () => {
// Mainland 0..10, island 20..22: the old largest-ring shortcut dropped the island.
const area = prepareArea([[square(0, 0, 10, 10)], [square(20, 0, 22, 2)]]);
assert.equal(pointInPreparedArea(area, 5, 5), true);
assert.equal(pointInPreparedArea(area, 1, 21), true);
assert.equal(pointInPreparedArea(area, 1, 15), false);
});
test('a point inside a hole is outside the area', () => {
const area = prepareArea([[square(0, 0, 10, 10), square(4, 4, 6, 6)]]);
assert.equal(pointInPreparedArea(area, 5, 5), false);
assert.equal(pointInPreparedArea(area, 2, 2), true);
// An island inside the hole (an enclave within an enclave) counts again.
const nested = prepareArea([
[square(0, 0, 10, 10), square(3, 3, 7, 7)],
[square(4, 4, 6, 6)],
]);
assert.equal(pointInPreparedArea(nested, 5, 5), true);
assert.equal(pointInPreparedArea(nested, 3.5, 3.5), false);
});
test('a ring crossing the antimeridian contains points on both sides', () => {
// 175E → 175W as one continuous ring (a drawn box over Fiji).
const ring = [
[175, -20],
[-175, -20],
[-175, -15],
[175, -15],
[175, -20],
];
const area = prepareArea([[ring]]);
assert.equal(area.crossesAntimeridian, true);
assert.equal(pointInPreparedArea(area, -17, 178), true);
assert.equal(pointInPreparedArea(area, -17, -178), true);
assert.equal(pointInPreparedArea(area, -17, 0), false);
assert.equal(pointInPreparedArea(area, -17, 170), false);
// bbox is west > east across the line.
assert.deepEqual(area.bbox, [175, -20, -175, -15]);
// Area of a 10°×5° box near 17°S, not the 350° long way round.
const km2 = multiPolygonAreaKm2([[ring]]);
assert.ok(km2 > 500_000 && km2 < 620_000, `area ${km2}`);
});
test('a multipolygon split at ±180 keeps a tight bbox', () => {
const area = prepareArea([
[square(177, -19, 180, -16)],
[square(-180, -19, -178, -16)],
]);
assert.equal(area.crossesAntimeridian, true);
assert.deepEqual(area.bbox, [177, -19, -178, -16]);
assert.equal(pointInPreparedArea(area, -17, -179), true);
assert.equal(pointInPreparedArea(area, -17, 179), true);
});
test('a crossing part and a separate island share one tight longitude frame', () => {
const crossing = [
[175, -20],
[-175, -20],
[-175, -15],
[175, -15],
[175, -20],
];
const area = prepareArea([[crossing], [square(-170, -19, -160, -16)]]);
assert.equal(area.crossesAntimeridian, true);
assert.deepEqual(area.bbox, [175, -20, -160, -15]);
assert.equal(pointInPreparedArea(area, -17, 178), true);
assert.equal(pointInPreparedArea(area, -17, -165), true);
assert.equal(pointInPreparedArea(area, -17, 0), false);
});
test('normalization closes rings and drops degenerate parts', () => {
const multi = normalizeMultiPolygon({
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
],
[
[0.2, 0.2],
[0.2, 0.2],
],
],
});
assert.equal(multi.length, 1);
assert.equal(multi[0].length, 1, 'degenerate hole dropped');
assert.deepEqual(multi[0][0][0], multi[0][0][3]);
assert.equal(
normalizeMultiPolygon({ type: 'LineString', coordinates: [] }),
null,
);
assert.equal(
normalizeMultiPolygon([
[
[0, 0],
[1, 1],
],
]),
null,
);
});
test('area subtracts holes and counts parts', () => {
const coords = [
[square(0, 0, 1, 1), square(0.25, 0.25, 0.75, 0.75)],
[square(5, 5, 6, 6)],
];
const whole = multiPolygonAreaKm2([[square(0, 0, 1, 1)]]);
const km2 = multiPolygonAreaKm2(coords);
assert.ok(km2 < whole * 2 && km2 > whole * 1.7, `${km2} vs ${whole}`);
assert.deepEqual(multiPolygonCounts(coords), { parts: 2, holes: 1 });
});
test('display copies are bounded and leave the counting geometry intact', () => {
const dense = [];
for (let i = 0; i < 20_000; i += 1) {
const a = (i / 20_000) * Math.PI * 2;
dense.push([
Math.cos(a) * (1 + 0.01 * Math.sin(a * 200)),
Math.sin(a) * (1 + 0.01 * Math.sin(a * 200)),
]);
}
dense.push(dense[0].slice());
const coords = [[dense]];
const snapshot = JSON.stringify(coords);
const shown = displayParts(coords, { maxVertices: 2000 });
const total = shown.parts.reduce((sum, p) => sum + p.outer.length, 0);
assert.ok(total <= 2000, `${total} vertices`);
assert.equal(JSON.stringify(coords), snapshot, 'input untouched');
// A point the simplified copy might shave off still counts on the original.
const area = prepareArea(coords);
assert.equal(pointInPreparedArea(area, 0, 0), true);
});
test('display copies keep the largest parts first', () => {
const coords = [
[square(0, 0, 1, 1)],
[square(10, 10, 15, 15)],
[square(20, 20, 20.1, 20.1)],
];
const shown = displayParts(coords, { maxParts: 2 });
assert.equal(shown.totalParts, 3);
assert.equal(shown.drawnParts, 2);
assert.equal(shown.parts[0].outer[0][0], 10);
});
test('a circle ring is closed and about the requested radius', () => {
const ring = circleRing(37.8, -122.39, 500);
assert.deepEqual(ring[0], ring[ring.length - 1]);
const km2 = multiPolygonAreaKm2([[ring]]);
assert.ok(Math.abs(km2 - Math.PI * 0.25) < 0.02, `${km2}`);
});
+668
View File
@@ -0,0 +1,668 @@
/**
* Named place → area handle, through the outline ladder that needs no public
* Overpass:
*
* 1. the area store (a query already resolved this session);
* 2. bundled Natural Earth physical/marine regions;
* 3. bundled countries and states/provinces by name — a name several
* prominent units share ("Georgia", "Punjab") asks which one, unless
* the view is inside exactly one of them;
* 4. the annotation outline ladder (`resolveNamedOutline`): bundled
* counties, US Census places and neighbourhood packs, then the guarded
* Nominatim outline route, then Overpass only when an operator
* configured it.
*
* Providers are injected, so the ladder runs under node:test:
* findNaturalRegion(query) → {name, polygons, kind}|null
* findAdminCandidates(query) → Array<AdminArea> (see data/adminBoundaries.js)
* resolveNamedOutline(query, {level, levelHint, signal}) →
* {ok:true, name, polygons, source, rung, approximate?, country?}
* |{ok:false, code, error}
* viewCenter() → {lat, lon}|null
*
* @module data/areaResolver
*/
import { AREA_LEVELS } from './areaStore.js';
import { pointInPreparedArea, prepareArea } from './areaGeometry.js';
import { adminQueryIdentity, normalizeAdminName } from './adminBoundaries.js';
import {
isAreaCandidateId,
MAX_AREA_CANDIDATE_ID_LENGTH,
} from '../voice/areaCandidateContract.js';
export {
AREA_CANDIDATE_ID_PATTERN,
isAreaCandidateId,
MAX_AREA_CANDIDATE_ID_LENGTH,
} from '../voice/areaCandidateContract.js';
/** Most candidates a clarification offers. */
export const MAX_AREA_CANDIDATES = 4;
/** Candidates remembered for a follow-up `candidateId`. */
const CANDIDATE_MEMORY = 24;
/**
* A place name as a cache and matching key: case-folded and NFKC-normalized,
* punctuation dropped, letters, marks and digits in every script kept (東京
* and 大阪 are different keys). Empty when the text names nothing.
*/
export function normalizeAreaQuery(text) {
return String(text || '')
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{M}\p{N} ]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/^the /, '');
}
/** Words that name the kind of area rather than the area. */
const LEVEL_WORDS = [
[/\b(province|state|region|oblast|prefecture|canton|governorate)$/, 'admin1'],
[/\b(county|district|department)$/, 'admin2'],
[/\b(city|town|municipality|metropolitan city)$/, 'city'],
[/\b(neighbou?rhood|borough|suburb|quarter)$/, 'district'],
[/\b(country|nation)$/, 'country'],
];
/**
* Strip a trailing level word ("Bagmati province" → "bagmati", admin1). The
* word is kept as a hint, not a filter, since names often contain it.
*/
export function splitLevelWord(query) {
const norm = normalizeAreaQuery(query);
for (const [re, level] of LEVEL_WORDS) {
if (re.test(norm)) {
const bare = norm.replace(re, '').trim();
if (bare) return { bare, level };
}
}
return { bare: norm, level: null };
}
/** Area level of a bundled admin unit. */
const ADMIN_LEVEL = Object.freeze({
country: 'country',
state: 'admin1',
county: 'admin2',
natural: 'natural',
});
/**
* One of several same-name natural places as a clarification choice, located
* by its rough centre ("39 N 31 W") since it has no country.
*/
function naturalCandidate(place, kind) {
const { lat, lon } = place.center || {};
const locator = Number.isFinite(lat)
? `${Math.abs(Math.round(lat))} ${lat >= 0 ? 'N' : 'S'} ${Math.abs(Math.round(lon))} ${lon >= 0 ? 'E' : 'W'}`
: '';
return {
kind: 'natural',
name: place.name,
country: '',
regionCode: locator,
polygons: place.polygons.map((ring) => [ring]),
source: 'natural-earth',
meta: { kind },
};
}
const ADMIN_NAME_LEVEL = Object.freeze({
country: 'country',
nation: 'country',
province: 'admin1',
state: 'admin1',
region: 'admin1',
oblast: 'admin1',
prefecture: 'admin1',
canton: 'admin1',
governorate: 'admin1',
county: 'admin2',
district: 'admin2',
department: 'admin2',
});
/**
* Drop accents from Latin letters only, for comparing names: speech often
* loses them ("Quebec" for "Québec"), while a Thai or Devanagari mark changes
* the name and is kept.
*/
export function foldLatinMarks(text) {
return String(text || '')
.normalize('NFD')
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFC');
}
function explicitAdminName(text) {
const normalized = normalizeAreaQuery(String(text).split(',')[0]);
const match = normalized.match(
/\b(country|nation|province|state|region|zone|oblast|prefecture|canton|governorate|county|district|department)$/,
);
return {
normalized,
bare: foldLatinMarks(
match ? normalized.slice(0, -match[0].length).trim() : normalized,
),
word: match?.[1] || null,
level: match ? ADMIN_NAME_LEVEL[match[1]] || null : null,
};
}
/**
* Fail closed when a bundled unit's own name contradicts the requested unit.
* With `aliases`, an ask the administrative matcher reads as naming this very
* unit at its level ("State of Texas", "USA") also matches; the natural-region
* check leaves it off, since "Great Britain" is not the United Kingdom.
*/
function bundledAdminIdentityRefusal(
admin,
query,
wantedLevel,
{ aliases = true } = {},
) {
const source = explicitAdminName(admin?.name || '');
const requested = explicitAdminName(query);
const expectedLevel = wantedLevel || requested.level;
const emittedLevel = ADMIN_LEVEL[admin?.kind] || null;
if (!source.normalized || source.word === 'zone')
return {
ok: false,
code: 'AREA_IDENTITY_UNVERIFIED',
error: `The bundled boundary is ${admin?.name || 'an unverified administrative unit'}, not a verified current match for "${query}".`,
};
const alias = aliases ? adminQueryIdentity(query) : null;
const named =
source.bare === requested.bare ||
Boolean(
alias &&
alias.level === emittedLevel &&
alias.name === normalizeAdminName(admin.name),
);
if (
!named ||
(source.level && expectedLevel && source.level !== expectedLevel) ||
(expectedLevel && emittedLevel !== expectedLevel)
)
return {
ok: false,
code: 'AREA_IDENTITY_MISMATCH',
error: `The bundled boundary is ${admin.name}, not the requested administrative area "${query}".`,
};
return null;
}
function stableCandidateHash(value) {
let hash = 0x811c9dc5;
for (const char of String(value)) {
hash ^= char.codePointAt(0);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
function candidateIdOf(admin) {
const prefix = `ne:${admin.kind}:`;
// The region code keeps same-named units apart: "Washington" is both WA
// and DC, which would otherwise share one candidate and one stored area.
const slug = normalizeAreaQuery(
`${admin.country} ${admin.name} ${admin.regionCode || ''}`,
).replace(/ /g, '-');
const full = `${prefix}${slug}`;
if (full.length <= MAX_AREA_CANDIDATE_ID_LENGTH) return full;
const suffix = `-${stableCandidateHash(slug)}`;
let shortened = '';
for (const char of slug) {
if (
`${prefix}${shortened}${char}${suffix}`.length >
MAX_AREA_CANDIDATE_ID_LENGTH
)
break;
shortened += char;
}
return `${prefix}${shortened.replace(/-+$/u, '')}${suffix}`;
}
/** Short, speakable identity for a clarification choice. */
function describeCandidate(admin) {
return {
candidateId: candidateIdOf(admin),
name: admin.name,
level: ADMIN_LEVEL[admin.kind] || null,
// Tells same-named units apart when spoken: Washington WA vs DC.
...(admin.regionCode && admin.kind !== 'country'
? { region: String(admin.regionCode) }
: {}),
...(admin.country && admin.kind !== 'country'
? { country: admin.country }
: {}),
};
}
/**
* Distinct places among bundled candidates. Within one country, the country
* itself stands for its same-named state ("Mexico" is the country, not the
* State of Mexico); across countries each name is its own place.
*/
/** A coarse ~2° cell of the view, or "none", for keying view-picked answers. */
function viewCell(center) {
if (!Number.isFinite(center?.lat) || !Number.isFinite(center?.lon))
return 'none';
return `${Math.floor(center.lat / 2)},${Math.floor(center.lon / 2)}`;
}
export function distinctAdminPlaces(candidates) {
const byCountry = new Map();
for (const admin of candidates) {
const list = byCountry.get(admin.country) || [];
list.push(admin);
byCountry.set(admin.country, list);
}
const out = [];
for (const list of byCountry.values()) {
const country = list.find((admin) => admin.kind === 'country');
out.push(...(country ? [country] : list));
}
return out;
}
/**
* Create a resolver bound to an area store and providers.
* @param {object} options
*/
export function createAreaResolver({
store,
findNaturalRegion = async () => null,
findAdminCandidates = async () => [],
resolveNamedOutline = async () => ({
ok: false,
code: 'AREA_UNAVAILABLE',
error: 'Boundary lookup is unavailable here.',
}),
viewCenter = () => null,
onCredit = () => {},
now = () => Date.now(),
maxInFlight = 8,
} = {}) {
if (!store) throw new TypeError('An area store is required');
/** candidateId → bundled unit and original ask, for a clarification answered later. */
const candidateMemory = new Map();
/** query key → in-flight lookup, so a repeat joins the first. */
const inFlight = new Map();
const lifetime = new AbortController();
const inFlightLimit = Number.isFinite(maxInFlight)
? Math.max(1, Math.floor(maxInFlight))
: 8;
let activeLookups = 0;
let disposed = false;
const assertActive = () => lifetime.signal.throwIfAborted();
const remember = (admin, identity) => {
const id = candidateIdOf(admin);
candidateMemory.delete(id);
candidateMemory.set(id, { admin, identity });
while (candidateMemory.size > CANDIDATE_MEMORY)
candidateMemory.delete(candidateMemory.keys().next().value);
};
function storeAdmin(admin, confidence, identity) {
assertActive();
const refused = bundledAdminIdentityRefusal(
admin,
identity?.query,
identity?.wantedLevel,
);
if (refused) return refused;
const record = store.put({
geometry: admin.polygons,
name: admin.name,
level: ADMIN_LEVEL[admin.kind] || null,
source: admin.source,
sourceId: candidateIdOf(admin),
meta: {
...(admin.kind !== 'country' && admin.country
? { country: admin.country }
: {}),
rung: 'bundled',
},
});
if (!record)
return {
ok: false,
code: 'AREA_NOT_FOUND',
error: `The boundary for ${admin.name} is not a usable polygon.`,
};
onCredit(admin.source);
return { ok: true, record, confidence, rung: 'bundled' };
}
async function lookup({ query, level, within }, { signal }) {
const { level: levelHint } = splitLevelWord(query);
const searchText = within ? `${query}, ${within}` : query;
let bundledRefusal = null;
// Offline first: a named natural region, unless an admin level or a
// country/state qualifier was given (the pack cannot check `within`).
if ((!level || level === 'natural') && !within) {
const ne = await findNaturalRegion(query, { signal }).catch(() => null);
signal?.throwIfAborted?.();
// A region named like a country or state ("India" the peninsula,
// "Punjab" the desert) never hides it; asked for by level, it answers.
// Only a unit that really has this name counts: "Great Britain" finds
// the United Kingdom by alias, which is not a Great Britain.
// A feature split into parts is one place with every part; distant
// namesakes (Flores in the Azores and Indonesia) are asked about.
const places = ne?.places?.length
? ne.places
: ne?.polygons?.length
? [{ name: ne.name, polygons: ne.polygons }]
: [];
const shadowsAdmin =
places.length > 0 &&
level !== 'natural' &&
(await findAdminCandidates(query, { signal }).catch(() => [])).some(
(admin) =>
!bundledAdminIdentityRefusal(admin, query, null, {
aliases: false,
}),
);
signal?.throwIfAborted?.();
if (places.length > 1 && !shadowsAdmin) {
const offered = places
.slice(0, MAX_AREA_CANDIDATES)
.map((place) => naturalCandidate(place, ne.kind));
const identity = { query, wantedLevel: 'natural' };
offered.forEach((admin) => remember(admin, identity));
return {
ok: false,
needsClarification: true,
code: 'AMBIGUOUS_AREA',
candidates: offered.map(describeCandidate),
error: `"${query}" names ${offered.length} places — ask which one.`,
};
}
if (places.length === 1 && !shadowsAdmin) {
const record = store.put({
geometry: places[0].polygons.map((ring) => [ring]),
name: ne.name,
level: 'natural',
source: 'natural-earth',
sourceId: `ne:${normalizeAreaQuery(ne.name).replace(/ /g, '-')}`,
meta: { kind: ne.kind, rung: 'bundled' },
});
if (record) {
onCredit('natural-earth');
return { ok: true, record, confidence: 0.95, rung: 'bundled' };
}
}
}
signal?.throwIfAborted?.();
// Bundled countries and states by name. A name prominent units share is
// asked about, never guessed — except when the view is inside one.
const wanted = level || levelHint;
if (!wanted || wanted === 'country' || wanted === 'admin1') {
let admins = await findAdminCandidates(searchText, { signal }).catch(
() => [],
);
signal?.throwIfAborted?.();
if (wanted) admins = admins.filter((a) => ADMIN_LEVEL[a.kind] === wanted);
let places = distinctAdminPlaces(admins);
const identity = { query, wantedLevel: wanted };
const checked = places.map((admin) => ({
admin,
refused: bundledAdminIdentityRefusal(admin, query, wanted),
}));
places = checked
.filter(({ refused }) => !refused)
.map(({ admin }) => admin);
if (!places.length && checked.length) {
const refused = checked.find((entry) => entry.refused)?.refused;
// A retired or unverifiable unit is final. A different unit that
// merely shares the name ("Azad Kashmir" for "Kashmir") is not an
// answer, so the outline ladder still gets to look.
if (refused?.code !== 'AREA_IDENTITY_MISMATCH') return refused;
bundledRefusal = refused;
}
if (places.length === 1) {
return storeAdmin(places[0], 0.95, identity);
}
if (places.length > 1) {
const center = viewCenter();
const holding = center
? places.filter((admin) => {
const prepared = prepareArea(admin.polygons);
return (
prepared &&
pointInPreparedArea(prepared, center.lat, center.lon)
);
})
: [];
// Chosen by the view, so never remembered for the name: asked again
// over the other one, it answers the other one.
if (holding.length === 1)
return { ...storeAdmin(holding[0], 0.85, identity), byView: true };
const offered = places.slice(0, MAX_AREA_CANDIDATES);
offered.forEach((admin) => remember(admin, identity));
return {
ok: false,
needsClarification: true,
code: 'AMBIGUOUS_AREA',
candidates: offered.map(describeCandidate),
error: `"${query}" names ${offered.length} places — ask which one.`,
};
}
}
signal?.throwIfAborted?.();
// Everything else: the annotation outline ladder.
const outline = await resolveNamedOutline(searchText, {
level,
levelHint,
signal,
});
signal?.throwIfAborted?.();
if (!outline?.ok)
return (
bundledRefusal || {
ok: false,
code: outline?.code || 'AREA_NOT_FOUND',
error: outline?.error || `No boundary found for "${query}".`,
}
);
const record = store.put({
geometry: outline.polygons,
name: outline.name || query,
level: level || levelHint || outline.adminLevel || null,
source: outline.approximate ? 'approximate' : outline.source,
approximate: Boolean(outline.approximate),
meta: {
...(outline.country ? { country: outline.country } : {}),
...(outline.adminName ? { adminName: outline.adminName } : {}),
...(outline.adminLevel ? { adminLevel: outline.adminLevel } : {}),
rung: outline.rung || null,
...(outline.basis ? { basis: outline.basis } : {}),
},
});
if (!record)
return {
ok: false,
code: 'AREA_NOT_FOUND',
error: `The boundary for ${query} is not a usable polygon.`,
};
return {
ok: true,
record,
confidence: outline.approximate ? 0.5 : 0.8,
rung: outline.rung || null,
};
}
/**
* Resolve a place to an area handle.
* @param {{query?: string, level?: string, within?: string, candidateId?: string}} args
* @param {{signal?: AbortSignal, budgetMs?: number}} [options] Past the
* budget the call answers AREA_TIMEOUT while the lookup continues and
* fills the caches, so asking again shortly is fast.
* @returns {Promise<object>}
*/
async function resolve(args = {}, { signal, budgetMs = 8000 } = {}) {
const started = now();
const level = AREA_LEVELS.includes(args.level) ? args.level : null;
const within = String(args.within || '').trim();
const candidateId = String(args.candidateId || '').trim();
const finish = (result, cached = false) => ({
...result,
cached,
ms: now() - started,
});
if (disposed)
return finish({
ok: false,
code: 'CANCELLED',
cancelled: true,
error: 'Area resolver has been disposed.',
});
if (candidateId) {
if (!isAreaCandidateId(candidateId))
return finish({
ok: false,
code: 'BAD_CANDIDATE',
error:
'candidateId is malformed or too long. Resolve the name again.',
});
const remembered = candidateMemory.get(candidateId);
if (!remembered)
return finish({
ok: false,
code: 'UNKNOWN_CANDIDATE',
error: `Unknown candidateId "${candidateId}". Resolve the name again.`,
});
if (signal?.aborted)
return finish({
ok: false,
code: 'CANCELLED',
cancelled: true,
error: 'This request was superseded.',
});
return finish(storeAdmin(remembered.admin, 0.9, remembered.identity));
}
const query = String(args.query || '').trim();
if (!query)
return finish({
ok: false,
code: 'BAD_AREA',
error: 'Name a place to resolve.',
});
const nameKey = normalizeAreaQuery(query);
// The outline ladder and geocoder pick by the view ("Springfield" over
// Illinois), so an answer is reused or shared only within the same
// ~2° view cell. A name that normalizes to nothing is never cached.
const key = nameKey
? `${nameKey}|${level || ''}|${normalizeAreaQuery(within)}|${viewCell(viewCenter())}`
: null;
const recalled = key ? store.recallQuery(key) : null;
if (recalled)
return finish(
{ ok: true, record: recalled, confidence: 0.95, rung: 'store' },
true,
);
let pending = key ? inFlight.get(key) : null;
if (!pending) {
if (activeLookups >= inFlightLimit)
return finish({
ok: false,
code: 'AREA_BUSY',
error:
'Too many area lookups are already running — try again shortly.',
});
// The shared lookup outlives any one caller's signal: a superseded turn
// must not cancel the answer a repeat is waiting on. The resolver's own
// lifetime still owns the provider work, so final teardown stops it.
activeLookups += 1;
pending = lookup({ query, level, within }, { signal: lifetime.signal })
.then((result) => {
assertActive();
if (result.ok && key && !result.byView)
store.rememberQuery(key, result.record.areaId);
return result;
})
.catch((error) =>
lifetime.signal.aborted
? {
ok: false,
code: 'CANCELLED',
cancelled: true,
error: 'Area resolver has been disposed.',
}
: {
ok: false,
code: 'AREA_UNAVAILABLE',
error: error?.message || 'Boundary lookup failed.',
},
)
.finally(() => {
activeLookups -= 1;
if (key) inFlight.delete(key);
});
if (key) inFlight.set(key, pending);
}
let timer;
const racers = [pending];
if (Number.isFinite(budgetMs))
racers.push(
new Promise((resolveTimeout) => {
timer = setTimeout(
() =>
resolveTimeout({
ok: false,
code: 'AREA_TIMEOUT',
error: `Looking up "${query}" is taking a while — ask again in a moment.`,
}),
budgetMs,
);
}),
);
// Detached once this call settles: a caller's long-lived signal must not
// collect one listener per completed lookup.
let cancelled = null;
if (signal)
racers.push(
new Promise((resolveAbort) => {
const superseded = {
ok: false,
code: 'CANCELLED',
cancelled: true,
error: 'Superseded.',
};
if (signal.aborted) return resolveAbort(superseded);
cancelled = () => resolveAbort(superseded);
signal.addEventListener?.('abort', cancelled, { once: true });
return undefined;
}),
);
try {
return finish(await Promise.race(racers));
} finally {
clearTimeout(timer);
if (cancelled) signal.removeEventListener?.('abort', cancelled);
}
}
function dispose() {
if (disposed) return;
disposed = true;
lifetime.abort();
inFlight.clear();
candidateMemory.clear();
}
return {
resolve,
dispose,
candidate: (id) => candidateMemory.get(id) || null,
};
}
+896
View File
@@ -0,0 +1,896 @@
// Area handles: the session store and the resolution ladder (bundled Natural
// Earth regions → bundled countries and states → the annotation outline
// ladder), with every rung mocked. Pins the clarification contract for
// ambiguous names and the fast cached path.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAreaStore, summarizeArea } from './areaStore.js';
import {
AREA_CANDIDATE_ID_PATTERN,
createAreaResolver,
distinctAdminPlaces,
isAreaCandidateId,
MAX_AREA_CANDIDATE_ID_LENGTH,
splitLevelWord,
} from './areaResolver.js';
import { pointInPreparedArea } from './areaGeometry.js';
import { findAdminCandidates } from './adminBoundaries.js';
const square = (w, s, e, n) => [
[w, s],
[e, s],
[e, n],
[w, n],
[w, s],
];
const admin = (kind, name, country, box, extra = {}) => ({
kind,
name,
country,
source: 'natural-earth',
polygons: [[square(...box)]],
...extra,
});
const GEORGIA_COUNTRY = admin(
'country',
'Georgia',
'Georgia',
[40, 41, 46.7, 43.6],
);
const GEORGIA_STATE = admin(
'state',
'Georgia',
'United States of America',
[-85.6, 30.4, -80.8, 35],
);
const PUNJAB_IN = admin('state', 'Punjab', 'India', [73.8, 29.5, 77, 32.5]);
const PUNJAB_PK = admin('state', 'Punjab', 'Pakistan', [69.3, 27.7, 73.7, 34]);
const TEXAS = admin(
'state',
'Texas',
'United States of America',
[-106, 26, -94, 36],
);
const BAGMATI = {
ok: true,
name: 'Bagmati Province',
polygons: [[square(84.4, 26.9, 86.6, 28.4), square(85.3, 27.6, 85.4, 27.7)]],
source: 'osm',
rung: 'nominatim',
adminName: 'Bagmati Province',
};
function makeResolver(overrides = {}) {
const store = createAreaStore();
const calls = { outline: [], natural: 0, admin: 0 };
const resolver = createAreaResolver({
store,
findNaturalRegion: async (q) => {
calls.natural += 1;
return /alps/i.test(q)
? {
name: 'Alps',
kind: 'natural',
polygons: [square(5, 44, 16, 48), square(17, 44, 18, 45)],
}
: null;
},
findAdminCandidates: async (q) => {
calls.admin += 1;
if (/^georgia$/i.test(q)) return [GEORGIA_STATE, GEORGIA_COUNTRY];
if (/^georgia, united states/i.test(q)) return [GEORGIA_STATE];
if (/^punjab$/i.test(q)) return [PUNJAB_PK, PUNJAB_IN];
if (/^punjab, pakistan/i.test(q)) return [PUNJAB_PK];
if (/^texas$/i.test(q)) return [TEXAS];
return [];
},
resolveNamedOutline: async (q, options) => {
calls.outline.push({ q, ...options, signal: undefined });
if (/bagmati/i.test(q)) return BAGMATI;
return {
ok: false,
code: 'AREA_NOT_FOUND',
error: `No boundary found for "${q}".`,
};
},
...overrides,
});
return { store, resolver, calls };
}
test('the store keeps geometry local and summarizes without coordinates', () => {
const store = createAreaStore({ maxEntries: 2 });
const a = store.put({
geometry: [[square(0, 0, 1, 1)]],
name: 'A',
source: 'drawn',
level: 'drawn',
});
const b = store.put({
geometry: [[square(2, 2, 3, 3)]],
name: 'B',
source: 'us-census',
sourceId: 'place:1',
});
assert.equal(b.areaId, 'place:1');
assert.equal(b.source, 'us-census');
store.get(a.areaId); // A is now the most recently used
store.put({ geometry: [[square(4, 4, 5, 5)]], name: 'C', source: 'osm' });
assert.equal(store.has('place:1'), false, 'least recently used evicted');
assert.ok(store.get(a.areaId));
const summary = summarizeArea(a);
assert.deepEqual(
Object.keys(summary).sort(),
[
'areaId',
'areaKm2',
'bbox',
'holes',
'level',
'name',
'parts',
'source',
].sort(),
);
assert.equal(JSON.stringify(summary).includes('geometry'), false);
assert.equal(
store.put({
geometry: [
[
[0, 0],
[1, 1],
],
],
name: 'bad',
}),
null,
);
});
test('query aliases have their own LRU bound even for one stable area', () => {
const store = createAreaStore({ maxEntries: 2, maxQueryEntries: 3 });
const area = store.put({
geometry: [[square(0, 0, 1, 1)]],
name: 'Stable',
source: 'osm',
sourceId: 'osm:stable',
});
for (let i = 0; i < 50; i += 1)
store.rememberQuery(`alias-${i}`, area.areaId);
assert.equal(store.recallQuery('alias-0'), null, 'old aliases are evicted');
assert.equal(store.recallQuery('alias-46'), null, 'the bound is independent');
assert.equal(store.recallQuery('alias-47')?.areaId, area.areaId);
assert.equal(store.recallQuery('alias-49')?.areaId, area.areaId);
assert.equal(store.size, 1, 'alias churn does not evict the area record');
});
test('a stable source keeps one handle when stored again', () => {
const store = createAreaStore();
for (let i = 0; i < 2; i += 1)
store.put({
geometry: [[square(0, 0, 1, 1)]],
name: 'X',
source: 'natural-earth',
sourceId: 'ne:state:x',
});
assert.equal(store.size, 1);
});
test('a named natural region resolves offline with every part', async () => {
const { resolver, calls } = makeResolver();
const result = await resolver.resolve({ query: 'the Alps' });
assert.equal(result.ok, true);
assert.equal(result.record.source, 'natural-earth');
assert.equal(result.record.parts, 2);
assert.equal(calls.outline.length, 0, 'no lookup');
assert.ok(result.ms < 300);
});
test('a bundled state resolves by name with no lookup', async () => {
const { resolver, calls } = makeResolver();
const result = await resolver.resolve({ query: 'Texas' });
assert.equal(result.ok, true);
assert.equal(result.rung, 'bundled');
assert.equal(result.record.level, 'admin1');
assert.equal(result.record.source, 'natural-earth');
assert.equal(result.record.meta.country, 'United States of America');
assert.equal(calls.outline.length, 0);
});
test('Bagmati goes to the outline ladder and keeps its hole', async () => {
const { resolver, calls } = makeResolver();
const result = await resolver.resolve({ query: 'Bagmati province' });
assert.equal(result.ok, true);
assert.equal(result.rung, 'nominatim');
assert.equal(result.record.source, 'osm');
assert.equal(result.record.level, 'admin1', 'the level word is kept');
assert.equal(result.record.holes, 1);
assert.equal(pointInPreparedArea(result.record.prepared, 27.2, 85), true);
assert.equal(
pointInPreparedArea(result.record.prepared, 27.65, 85.35),
false,
'inside the hole',
);
assert.equal(calls.outline[0].levelHint, 'admin1');
});
test('a legacy bundled zone is never stored as the requested current province', async () => {
const legacy = admin(
'state',
'Bagmati Zone',
'Nepal',
[84.645, 27.357, 86.055, 28.335],
);
const { store, resolver } = makeResolver({
findAdminCandidates: async () => [legacy],
});
const result = await resolver.resolve({ query: 'Bagmati province' });
assert.equal(result.ok, false);
assert.equal(result.code, 'AREA_IDENTITY_UNVERIFIED');
assert.equal(result.record, undefined);
assert.equal(store.size, 0);
const repeated = await resolver.resolve({ query: 'Bagmati province' });
assert.equal(repeated.code, 'AREA_IDENTITY_UNVERIFIED');
assert.equal(repeated.cached, false);
assert.equal(store.size, 0);
});
test('legacy zone identity cannot escape through view choice or candidate memory', async () => {
const nepal = admin(
'state',
'Bagmati Zone',
'Nepal',
[84.645, 27.357, 86.055, 28.335],
);
const namesake = admin(
'state',
'Bagmati Zone',
'Elsewhere',
[10, 10, 11, 11],
);
const { store, resolver } = makeResolver({
findAdminCandidates: async () => [nepal, namesake],
viewCenter: () => ({ lat: 27.7, lon: 85.3 }),
});
const result = await resolver.resolve({ query: 'Bagmati province' });
assert.equal(result.ok, false);
assert.equal(result.code, 'AREA_IDENTITY_UNVERIFIED');
assert.equal(result.needsClarification, undefined);
assert.equal(result.candidates, undefined);
assert.equal(store.size, 0);
assert.equal(
(await resolver.resolve({ candidateId: 'ne:state:nepal-bagmati-zone' }))
.code,
'UNKNOWN_CANDIDATE',
);
assert.equal(store.size, 0);
});
test('a repeat is answered from the store without a lookup', async () => {
const { resolver, calls } = makeResolver();
await resolver.resolve({ query: 'Bagmati' });
const again = await resolver.resolve({ query: 'bagmati' });
assert.equal(again.ok, true);
assert.equal(again.cached, true);
assert.equal(calls.outline.length, 1);
});
test('an ambiguous name asks instead of guessing, and the choice resolves', async () => {
const { resolver, calls } = makeResolver();
const result = await resolver.resolve({ query: 'Georgia' });
assert.equal(result.ok, false);
assert.equal(result.needsClarification, true);
assert.equal(result.candidates.length, 2);
assert.deepEqual(result.candidates[0], {
candidateId: 'ne:state:united-states-of-america-georgia',
name: 'Georgia',
level: 'admin1',
country: 'United States of America',
});
assert.equal(result.candidates[1].level, 'country');
assert.equal(calls.outline.length, 0, 'no lookup spent on a question');
const chosen = await resolver.resolve({
candidateId: result.candidates[0].candidateId,
});
assert.equal(chosen.ok, true);
assert.equal(chosen.record.meta.country, 'United States of America');
});
test('an already-aborted clarification choice does not persist page state', async () => {
const { store, resolver } = makeResolver();
const result = await resolver.resolve({ query: 'Georgia' });
const controller = new AbortController();
controller.abort();
const chosen = await resolver.resolve(
{ candidateId: result.candidates[0].candidateId },
{ signal: controller.signal },
);
assert.equal(chosen.code, 'CANCELLED');
assert.equal(chosen.cancelled, true);
assert.equal(store.size, 0);
});
test('candidate IDs accept every emitted choice and reject malformed or oversized input', async () => {
const { store, resolver } = makeResolver();
const result = await resolver.resolve({ query: 'Georgia' });
const grammar = new RegExp(AREA_CANDIDATE_ID_PATTERN, 'u');
for (const choice of result.candidates) {
assert.ok(choice.candidateId.length <= MAX_AREA_CANDIDATE_ID_LENGTH);
assert.match(choice.candidateId, grammar);
assert.equal(isAreaCandidateId(choice.candidateId), true);
assert.equal(
(await resolver.resolve({ candidateId: choice.candidateId })).ok,
true,
);
}
assert.equal(result.candidates[0].candidateId.length, 41);
store.put({
geometry: [[square(0, 0, 1, 1)]],
name: 'Not a candidate',
source: 'osm',
sourceId: 'area-1',
});
for (const bad of [
'area-1',
'ne:state:bad slug',
'ne:state:../../etc',
'ne:state:!!!',
'ne:state:a_b',
'ne:planet:earth',
`ne:state:${'a'.repeat(MAX_AREA_CANDIDATE_ID_LENGTH)}`,
]) {
const rejected = await resolver.resolve({ candidateId: bad });
assert.equal(rejected.code, 'BAD_CANDIDATE');
}
});
test('long bundled names produce bounded, distinct candidate handles', async () => {
const name = 'A'.repeat(70);
const candidates = [
admin('state', name, `B${'b'.repeat(70)}`, [0, 0, 1, 1]),
admin('state', name, `C${'c'.repeat(70)}`, [2, 2, 3, 3]),
];
const { resolver } = makeResolver({
findAdminCandidates: async () => candidates,
});
const result = await resolver.resolve({ query: name });
assert.equal(result.needsClarification, true);
assert.equal(
new Set(result.candidates.map((choice) => choice.candidateId)).size,
2,
);
for (const choice of result.candidates) {
assert.equal(isAreaCandidateId(choice.candidateId), true);
assert.ok(choice.candidateId.length <= MAX_AREA_CANDIDATE_ID_LENGTH);
assert.equal(
(await resolver.resolve({ candidateId: choice.candidateId })).ok,
true,
);
}
});
test('within or level settles an ambiguous name', async () => {
const { resolver } = makeResolver();
const state = await resolver.resolve({
query: 'Georgia',
within: 'United States',
});
assert.equal(state.ok, true);
assert.equal(state.record.level, 'admin1');
const country = await resolver.resolve({
query: 'Georgia',
level: 'country',
});
assert.equal(country.ok, true);
assert.equal(country.record.level, 'country');
const both = await resolver.resolve({ query: 'Punjab', level: 'admin1' });
assert.equal(both.needsClarification, true, 'both Punjabs are admin1');
const pk = await resolver.resolve({ query: 'Punjab', within: 'Pakistan' });
assert.equal(pk.record.meta.country, 'Pakistan');
});
test('the view settles an ambiguous name only when it is inside one of them', async () => {
const { resolver } = makeResolver({
viewCenter: () => ({ lat: 31, lon: 75.5 }),
});
const result = await resolver.resolve({ query: 'Punjab' });
assert.equal(result.ok, true);
assert.equal(result.record.meta.country, 'India');
const away = makeResolver({ viewCenter: () => ({ lat: 0, lon: 0 }) });
const asked = await away.resolver.resolve({ query: 'Punjab' });
assert.equal(asked.needsClarification, true);
// A choice the view made is not remembered for the name.
let view = { lat: 31, lon: 75.5 };
const moving = makeResolver({ viewCenter: () => view });
await moving.resolver.resolve({ query: 'Punjab' });
view = { lat: 31, lon: 71 };
const again = await moving.resolver.resolve({ query: 'Punjab' });
assert.equal(again.record.meta.country, 'Pakistan');
});
test('a qualifier skips the natural-region pack, which cannot check it', async () => {
const { resolver, calls } = makeResolver();
await resolver.resolve({ query: 'the Alps', within: 'Austria' });
assert.equal(calls.natural, 0);
assert.equal(calls.outline.length, 1);
});
test('the area just stored survives eviction even when every other area is pinned', () => {
const store = createAreaStore({ maxEntries: 2, isPinned: () => true });
for (let i = 0; i < 3; i += 1)
store.put({
geometry: [[square(i, 0, i + 0.5, 0.5)]],
name: `A${i}`,
source: 'drawn',
});
const last = store.put({
geometry: [[square(9, 0, 9.5, 0.5)]],
name: 'new',
source: 'drawn',
});
assert.ok(store.get(last.areaId), 'the new handle is usable');
});
test('a country stands for its same-named state; namesakes abroad are rivals', () => {
const mexico = admin('country', 'Mexico', 'Mexico', [-118, 14, -86, 33]);
const state = admin(
'state',
'State of Mexico',
'Mexico',
[-100, 18, -98, 20],
);
assert.deepEqual(distinctAdminPlaces([state, mexico]), [mexico]);
assert.equal(distinctAdminPlaces([PUNJAB_IN, PUNJAB_PK]).length, 2);
});
test('bundled candidates: shared prominent names list every unit, minor ones none', async () => {
const names = async (q) =>
(await findAdminCandidates(q)).map((a) => `${a.kind}:${a.country}`);
assert.deepEqual((await names('Punjab')).sort(), [
'state:India',
'state:Pakistan',
]);
assert.deepEqual((await names('Georgia')).sort(), [
'country:Georgia',
'state:United States of America',
]);
assert.deepEqual(await names('Punjab, India'), ['state:India']);
assert.deepEqual(await names('Kent'), [], 'minor units are the geocoder’s');
});
test('an outline ladder refusal passes through with its code', async () => {
const { resolver } = makeResolver();
const miss = await resolver.resolve({ query: 'Nowhere Province' });
assert.equal(miss.ok, false);
assert.equal(miss.code, 'AREA_NOT_FOUND');
const down = makeResolver({
resolveNamedOutline: async () => ({
ok: false,
code: 'AREA_UNAVAILABLE',
error: 'No outline source can trace it here.',
}),
});
const failed = await down.resolver.resolve({ query: 'Kathmandu' });
assert.equal(failed.code, 'AREA_UNAVAILABLE');
});
test('an approximate outline is stored and labelled as such', async () => {
const { resolver } = makeResolver({
resolveNamedOutline: async () => ({
ok: true,
name: 'Mission',
polygons: [[square(0, 0, 0.01, 0.01)]],
source: 'osm',
rung: 'overpass',
approximate: true,
basis: 'buffer sized to the place',
}),
});
const result = await resolver.resolve({ query: 'the Mission' });
assert.equal(result.record.approximate, true);
assert.equal(result.record.source, 'approximate');
assert.equal(result.record.meta.basis, 'buffer sized to the place');
});
test('a slow lookup times out but keeps filling the cache', async () => {
let release;
const gate = new Promise((r) => {
release = r;
});
const { resolver } = makeResolver({
resolveNamedOutline: async () => {
await gate;
return BAGMATI;
},
});
const first = await resolver.resolve({ query: 'Bagmati' }, { budgetMs: 20 });
assert.equal(first.code, 'AREA_TIMEOUT');
release();
await new Promise((r) => setTimeout(r, 10));
const second = await resolver.resolve({ query: 'Bagmati' }, { budgetMs: 20 });
assert.equal(second.ok, true);
assert.equal(second.cached, true);
});
test('a cancelled caller returns at once without cancelling the shared lookup', async () => {
const { resolver } = makeResolver();
const controller = new AbortController();
controller.abort();
const result = await resolver.resolve(
{ query: 'Bagmati' },
{ signal: controller.signal },
);
assert.equal(result.code, 'CANCELLED');
await new Promise((r) => setTimeout(r, 5));
const again = await resolver.resolve({ query: 'Bagmati' });
assert.equal(again.ok, true);
});
test('distinct area lookups are hard-bounded while same-key callers still coalesce', async () => {
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
let calls = 0;
const { resolver } = makeResolver({
maxInFlight: 1,
resolveNamedOutline: async (query) => {
calls += 1;
await gate;
return { ...BAGMATI, name: query };
},
});
const first = resolver.resolve({ query: 'First Area' });
const joined = resolver.resolve({ query: 'First Area' });
const refused = await resolver.resolve({ query: 'Second Area' });
assert.equal(refused.code, 'AREA_BUSY');
release();
const [a, b] = await Promise.all([first, joined]);
assert.equal(calls, 1, 'the same key shares the one live provider lookup');
assert.equal(a.ok, true);
assert.equal(b.ok, true);
});
test("settled lookups leave no listener on the caller's signal", async () => {
const { resolver } = makeResolver();
const controller = new AbortController();
let added = 0;
let removed = 0;
const add = controller.signal.addEventListener.bind(controller.signal);
const remove = controller.signal.removeEventListener.bind(controller.signal);
controller.signal.addEventListener = (...args) => {
if (args[0] === 'abort') added += 1;
return add(...args);
};
controller.signal.removeEventListener = (...args) => {
if (args[0] === 'abort') removed += 1;
return remove(...args);
};
for (const query of ['Texas', 'Bagmati', 'Nowhere at all'])
await resolver.resolve({ query }, { signal: controller.signal });
assert.ok(added > 0, 'the lookups listened while running');
assert.equal(removed, added, 'every listener is detached once settled');
const aborted = AbortSignal.abort();
let attached = 0;
aborted.addEventListener = () => {
attached += 1;
};
const late = await resolver.resolve(
{ query: 'Punjab, Pakistan' },
{ signal: aborted },
);
assert.equal(late.code, 'CANCELLED');
assert.equal(attached, 0, 'an aborted signal gets no listener');
});
test('resolver disposal aborts provider lifetime and prevents late store/cache writes', async () => {
let release;
let providerSignal;
const gate = new Promise((resolve) => {
release = resolve;
});
const { resolver, store } = makeResolver({
resolveNamedOutline: async (_query, { signal }) => {
providerSignal = signal;
await gate; // Deliberately ignores abort until it returns.
return BAGMATI;
},
});
const pending = resolver.resolve({ query: 'Late Area' });
await new Promise((resolve) => setTimeout(resolve, 0));
resolver.dispose();
assert.equal(providerSignal.aborted, true);
release();
const result = await pending;
assert.equal(result.ok, false);
assert.equal(store.size, 0, 'late provider data never reaches the store');
const after = await resolver.resolve({ query: 'Late Area' });
assert.equal(after.code, 'CANCELLED');
});
test('level words map to area levels', () => {
assert.deepEqual(splitLevelWord('Bagmati Province'), {
bare: 'bagmati',
level: 'admin1',
});
assert.deepEqual(splitLevelWord('Kathmandu'), {
bare: 'kathmandu',
level: null,
});
});
test('non-Latin names are distinct keys: 東京 then 大阪 looks up both', async () => {
const asked = [];
const { resolver } = makeResolver({
resolveNamedOutline: async (q) => {
asked.push(q);
return { ...BAGMATI, name: q };
},
});
const first = await resolver.resolve({ query: '東京' });
const second = await resolver.resolve({ query: '大阪' });
assert.notEqual(first.record.areaId, second.record.areaId);
assert.equal(second.cached, false, 'not the cached Tokyo');
assert.deepEqual(asked, ['東京', '大阪']);
// The same name with a different `within` is a different question.
await resolver.resolve({ query: '東京', within: '日本' });
assert.equal(asked.length, 3);
assert.equal((await resolver.resolve({ query: '東京' })).cached, true);
});
test('a name that normalizes to nothing is never cached or shared', async () => {
let calls = 0;
const { resolver } = makeResolver({
resolveNamedOutline: async () => {
calls += 1;
return BAGMATI;
},
});
await resolver.resolve({ query: '!!!' });
await resolver.resolve({ query: '???' });
assert.equal(calls, 2, 'two punctuation-only asks are two lookups');
});
test('a natural region never hides a country or state of the same name', async () => {
const desert = {
name: 'Punjab',
kind: 'natural',
polygons: [square(70, 28, 76, 33)],
};
const { resolver } = makeResolver({
findNaturalRegion: async (q) => (/^punjab$/i.test(q) ? desert : null),
});
const bare = await resolver.resolve({ query: 'Punjab' });
assert.equal(bare.ok, false);
assert.equal(bare.code, 'AMBIGUOUS_AREA', 'the two states are asked about');
const natural = await resolver.resolve({ query: 'Punjab', level: 'natural' });
assert.equal(natural.ok, true);
assert.equal(natural.record.level, 'natural', 'asked for, the region wins');
});
test('same-named states in one country get their own candidate and area', async () => {
const wa = admin(
'state',
'Washington',
'United States of America',
[-124, 45.5, -117, 49],
{ regionCode: 'WA' },
);
const dc = admin(
'state',
'Washington',
'United States of America',
[-77.2, 38.8, -76.9, 39],
{ regionCode: 'DC' },
);
const { resolver } = makeResolver({
findAdminCandidates: async (q) => (/^washington$/i.test(q) ? [wa, dc] : []),
});
const asked = await resolver.resolve({ query: 'Washington' });
assert.equal(asked.code, 'AMBIGUOUS_AREA');
const ids = asked.candidates.map((c) => c.candidateId);
assert.equal(new Set(ids).size, 2, `distinct ids: ${ids.join(', ')}`);
assert.deepEqual(
asked.candidates.map((c) => c.region),
['WA', 'DC'],
'the choices can be told apart',
);
const picked = await Promise.all(
ids.map((candidateId) => resolver.resolve({ candidateId })),
);
assert.ok(picked.every((p) => p.ok));
assert.notEqual(
picked[0].record.areaId,
picked[1].record.areaId,
'two areas, not one overwritten',
);
assert.equal(
pointInPreparedArea(picked[0].record.prepared, 47, -120),
true,
'Washington State',
);
assert.equal(
pointInPreparedArea(picked[1].record.prepared, 38.9, -77),
true,
'District of Columbia',
);
});
test('a bundled name mismatch falls through to the outline ladder', async () => {
const azad = admin('state', 'Azad Kashmir', 'Pakistan', [73, 33, 75, 35.5]);
const kashmir = {
ok: true,
name: 'Kashmir',
polygons: [[square(73, 32, 80, 37)]],
source: 'osm',
rung: 'nominatim',
};
let outlineOk = true;
const { resolver } = makeResolver({
findAdminCandidates: async (q) => (/^kashmir$/i.test(q) ? [azad] : []),
resolveNamedOutline: async () =>
outlineOk
? kashmir
: { ok: false, code: 'AREA_NOT_FOUND', error: 'none' },
});
const found = await resolver.resolve({ query: 'Kashmir' });
assert.equal(found.ok, true, found.error);
assert.equal(found.record.name, 'Kashmir');
outlineOk = false;
const { resolver: offline } = makeResolver({
findAdminCandidates: async () => [azad],
resolveNamedOutline: async () => ({
ok: false,
code: 'AREA_NOT_FOUND',
error: 'none',
}),
});
const refused = await offline.resolve({ query: 'Kashmir' });
assert.equal(
refused.code,
'AREA_IDENTITY_MISMATCH',
'the bundled refusal is kept',
);
});
test('a view-picked outline answer is not reused for the same name elsewhere', async () => {
let view = { lat: 39.8, lon: -89.6 }; // Springfield, Illinois
const springfield = (center) => ({
ok: true,
name:
center.lon < -80 ? 'Springfield, Illinois' : 'Springfield, Massachusetts',
polygons: [
[
square(
center.lon - 0.1,
center.lat - 0.1,
center.lon + 0.1,
center.lat + 0.1,
),
],
],
source: 'osm',
rung: 'nominatim',
});
const { resolver } = makeResolver({
viewCenter: () => view,
resolveNamedOutline: async () => springfield(view),
});
const illinois = await resolver.resolve({ query: 'Springfield' });
assert.equal(illinois.record.name, 'Springfield, Illinois');
const again = await resolver.resolve({ query: 'Springfield' });
assert.equal(again.cached, true, 'the same view still reuses it');
view = { lat: 42.1, lon: -72.6 }; // Springfield, Massachusetts
const massachusetts = await resolver.resolve({ query: 'Springfield' });
assert.equal(massachusetts.cached, false);
assert.equal(massachusetts.record.name, 'Springfield, Massachusetts');
});
test('a name spoken without accents matches its accented unit, Latin letters only', async () => {
const quebec = admin('state', 'Québec', 'Canada', [-79.8, 45, -57.1, 62.6]);
const cordobaAr = admin(
'state',
'Córdoba',
'Argentina',
[-65.8, -35, -61.8, -29.5],
);
const cordobaEs = admin('state', 'Córdoba', 'Spain', [-5.6, 37.2, -4, 38.7]);
const thai = admin('state', 'ป่าย', 'Thailand', [98, 19, 99, 20]);
const { resolver } = makeResolver({
findAdminCandidates: async (q) =>
/^quebec$/i.test(q)
? [quebec]
: /^cordoba$/i.test(q)
? [cordobaAr, cordobaEs]
: q === 'ปาย'
? [thai]
: [],
});
const qc = await resolver.resolve({ query: 'Quebec' });
assert.equal(qc.ok, true, qc.error);
assert.equal(qc.record.name, 'Québec');
const cordoba = await resolver.resolve({ query: 'Cordoba' });
assert.equal(cordoba.code, 'AMBIGUOUS_AREA', 'both Córdobas are asked about');
assert.equal(cordoba.candidates.length, 2);
const tone = await resolver.resolve({ query: 'ปาย' });
assert.equal(tone.ok, false, 'a Thai tone mark still tells names apart');
});
test('a natural name for two distant places is asked about, and the choice resolves', async () => {
const azores = {
name: 'Flores',
featurecla: 'Island',
polygons: [square(-31.3, 39.4, -31.1, 39.5)],
areaKm2: 140,
center: { lat: 39.45, lon: -31.2 },
};
const indonesia = {
name: 'Flores',
featurecla: 'Island',
polygons: [square(119.8, -8.9, 123, -8.1)],
areaKm2: 14000,
center: { lat: -8.5, lon: 121.4 },
};
const { resolver } = makeResolver({
findNaturalRegion: async (q) =>
/^flores$/i.test(q)
? {
name: 'Flores',
kind: 'natural',
polygons: indonesia.polygons,
places: [indonesia, azores],
}
: null,
});
const asked = await resolver.resolve({ query: 'Flores' });
assert.equal(asked.code, 'AMBIGUOUS_AREA');
assert.equal(asked.candidates.length, 2);
assert.ok(asked.candidates.every((c) => c.level === 'natural' && c.region));
const picked = await resolver.resolve({
candidateId: asked.candidates[1].candidateId,
});
assert.equal(picked.ok, true, picked.error);
assert.equal(picked.record.level, 'natural');
assert.equal(
pointInPreparedArea(picked.record.prepared, 39.45, -31.2),
true,
'the Azores Flores',
);
});
test('a natural feature in several parts is stored whole', async () => {
const north = square(33.7, 2.4, 44.2, 15.4);
const south = square(28.9, -15.6, 35.8, 2.4);
const { resolver } = makeResolver({
findNaturalRegion: async (q) =>
/rift/i.test(q)
? {
name: 'Great Rift Valley',
kind: 'natural',
polygons: [north],
places: [
{
name: 'Great Rift Valley',
featurecla: 'Valley',
polygons: [north, south],
areaKm2: 2,
center: { lat: 0, lon: 36 },
},
],
}
: null,
});
const rift = await resolver.resolve({ query: 'Great Rift Valley' });
assert.equal(rift.ok, true);
assert.equal(
pointInPreparedArea(rift.record.prepared, -10, 33),
true,
'the southern part counts',
);
});
+212
View File
@@ -0,0 +1,212 @@
/**
* Session-scoped store of area handles. A voice turn refers to an area by its
* `areaId`; the geometry itself stays here and never goes to the model.
*
* Records are immutable once stored. A source with a stable identity (an OSM
* relation, a Natural Earth region, an annotation) keeps the same id when it
* is stored again, so repeating "outline Bagmati" does not mint new handles.
* Least recently used records are evicted past `maxEntries`, except those
* `isPinned` reports as still in use.
*
* Pure: no Cesium, DOM or network.
* @module data/areaStore
*/
import {
multiPolygonAreaKm2,
multiPolygonCounts,
normalizeMultiPolygon,
prepareArea,
} from './areaGeometry.js';
/** Where an area came from, as the model and the panel name it. */
export const AREA_SOURCES = Object.freeze([
'natural-earth',
'us-census',
'wof',
'datasf',
'osm',
'openfreemap',
'annotation',
'drawn',
'approximate',
]);
/** Levels a resolved area can have. */
export const AREA_LEVELS = Object.freeze([
'country',
'admin1',
'admin2',
'city',
'district',
'natural',
'site',
'drawn',
]);
const round = (value, digits) => {
const f = 10 ** digits;
return Math.round(value * f) / f;
};
/**
* Create an area store.
* @param {{maxEntries?: number, maxQueryEntries?: number, now?: () => number, isPinned?: (areaId: string) => boolean}} [options]
*/
export function createAreaStore({
maxEntries = 32,
maxQueryEntries = Math.max(32, maxEntries * 8),
now = () => Date.now(),
isPinned = () => false,
} = {}) {
/** Insertion order doubles as recency: get() re-inserts. */
const records = new Map();
/** query key → areaId, for cached resolutions. */
const queries = new Map();
let seq = 0;
function forgetRecordQueries(areaId) {
for (const [key, queried] of queries)
if (queried === areaId) queries.delete(key);
}
/**
* Drop least recently used records past the bound, skipping pinned ones
* (an area an outline on the map still refers to keeps its geometry for as
* long as the outline exists; the annotation cap bounds how many that is).
*/
function evict(keep) {
if (records.size <= maxEntries) return;
for (const id of [...records.keys()]) {
if (records.size <= maxEntries) break;
// The record just stored is always kept; pinned ones may exceed the bound.
if (id === keep || isPinned(id)) continue;
records.delete(id);
forgetRecordQueries(id);
}
}
/**
* Store an area. Returns the stored record, or null when the geometry is
* unusable.
* @param {object} input
* @param {object|Array} input.geometry GeoJSON Polygon/MultiPolygon or coordinates.
* @param {string} input.name
* @param {string} input.source One of AREA_SOURCES.
* @param {string} [input.sourceId] Stable identity, e.g. `ne:state:india-punjab`.
* @param {string} [input.level]
* @param {boolean} [input.approximate]
* @param {object} [input.meta] Extra facts (country, license, completeness).
* @returns {object|null}
*/
function put(input) {
const geometry = normalizeMultiPolygon(input?.geometry);
if (!geometry) return null;
const prepared = prepareArea(geometry);
if (!prepared) return null;
const areaId = input.sourceId
? String(input.sourceId)
: `area-${(seq += 1)}`;
const counts = multiPolygonCounts(geometry);
const record = Object.freeze({
areaId,
name: String(input.name || 'Unnamed area').slice(0, 160),
level: AREA_LEVELS.includes(input.level) ? input.level : null,
source: AREA_SOURCES.includes(input.source)
? input.source
: 'approximate',
approximate: Boolean(input.approximate),
geometry,
prepared,
bbox: prepared.bbox,
areaKm2: multiPolygonAreaKm2(geometry),
parts: counts.parts,
holes: counts.holes,
meta: Object.freeze({ ...(input.meta || {}) }),
storedAt: now(),
});
records.delete(areaId);
records.set(areaId, record);
evict(areaId);
return record;
}
/** The record for an id, marking it recently used; null when unknown or evicted. */
function get(areaId) {
const record = records.get(String(areaId ?? ''));
if (!record) return null;
records.delete(record.areaId);
records.set(record.areaId, record);
return record;
}
return {
put,
get,
has: (areaId) => records.has(String(areaId ?? '')),
/** Remember which area a query resolved to. */
rememberQuery(key, areaId) {
if (!key || !records.has(areaId)) return;
queries.delete(key);
queries.set(key, areaId);
while (queries.size > maxQueryEntries)
queries.delete(queries.keys().next().value);
},
/** The area a query resolved to earlier, if still stored. */
recallQuery(key) {
const id = queries.get(key);
const record = id ? get(id) : null;
if (!record) {
queries.delete(key);
return null;
}
queries.delete(key);
queries.set(key, id);
return record;
},
/** Newest first. */
list: () => [...records.values()].reverse(),
/** The most recently stored or used record matching `predicate`. */
latest(predicate = () => true) {
return [...records.values()].reverse().find(predicate) || null;
},
delete(areaId) {
const id = String(areaId ?? '');
const deleted = records.delete(id);
if (deleted) forgetRecordQueries(id);
return deleted;
},
clear() {
records.clear();
queries.clear();
},
get size() {
return records.size;
},
};
}
/**
* The model-facing summary of an area: identity, size and provenance, never
* coordinates beyond a rounded bbox.
* @param {object} record
* @returns {object}
*/
export function summarizeArea(record) {
if (!record) return null;
return {
areaId: record.areaId,
name: record.name,
level: record.level,
source: record.source,
...(record.meta.country ? { country: record.meta.country } : {}),
bbox: record.bbox.map((v) => round(v, 3)),
areaKm2:
record.areaKm2 >= 100
? Math.round(record.areaKm2)
: round(record.areaKm2, 2),
parts: record.parts,
holes: record.holes,
...(record.approximate ? { approximate: true } : {}),
};
}
+2
View File
@@ -67,6 +67,8 @@ export const _extractPickedCameraIdForTest =
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
export const focusCctvRecord = layer.focusCctvRecord;
export const maybeAutoHop = layer.maybeAutoHop;
export const openingCameraId = layer.openingCameraId;
export const reopenNearestCamera = layer.reopenNearestCamera;
export const cctvCycleIndex = layer.cctvCycleIndex;
export {
CCTV_CALIBRATION_STORAGE_KEY_V1,
+116
View File
@@ -61,6 +61,8 @@ import cctvLayer, {
materializeCctvActiveCoverageEntities,
materializeCctvVisibleCoverageEntities,
maybeAutoHop,
openingCameraId,
reopenNearestCamera,
prioritizeActiveCctvGeometryRecord,
processCctvGeometryDrainBatch,
processCctvGeometryQueueBatch,
@@ -1296,6 +1298,120 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
}
});
test('CCTV opens on the camera nearest the viewer, not the first in the catalog', () => {
// QA 2026-11-05: at King's Cross, turning CCTV on showed a Sydney camera,
// the first catalog record, instead of the TfL camera 0.3 km away.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(openingCameraId(), 'gb-kings-cross');
_setCctvCoverageStateForTest({
viewer: null,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(
openingCameraId(),
'au-sydney',
'with no viewer position the first record is the fallback',
);
_setCctvCoverageStateForTest({ viewer, records: [], enabled: false });
assert.equal(openingCameraId(), null);
} finally {
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('the opening camera follows a shared view that lands after CCTV turned on, until someone picks one', () => {
// A share link turns CCTV on before its flight lands, so enable() picks
// from the startup view; the flight's settled move re-picks near London.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
for (const record of [sydney, london])
Object.assign(record, {
groundResolved: {},
groundSamples: {},
groundPrior: 0,
frustumGeometry: null,
frustumPositions: null,
probeClampRangeM: null,
});
_setCctvOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
// Activation builds the projection canvas and frame image.
const originalDocument = globalThis.document;
const originalImage = globalThis.Image;
globalThis.document = {
createElement: () => ({ getContext: () => null }),
};
globalThis.Image = class {};
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
assert.equal(reopenNearestCamera(), true);
assert.equal(cctvLayer.getUIState().activeCameraId, 'gb-kings-cross');
assert.equal(reopenNearestCamera(), false, 'already the nearest');
// Someone chooses a camera: settled moves leave it alone.
setActiveCamera('au-sydney');
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, 'au-sydney');
// A deselect is a choice too.
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
deactivateActiveCamera();
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, null);
} finally {
globalThis.document = originalDocument;
globalThis.Image = originalImage;
_setCctvOverlayHostForTest();
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => {
const calls = [];
_setCctvOverlayHostForTest({
+3 -3
View File
@@ -12,7 +12,7 @@ import {
CALGARY_IMAGE_ORIGIN,
CALGARY_MAX_CATALOG_BYTES,
DEFAULT_CALGARY_ROWS_URL,
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
@@ -384,6 +384,6 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t)
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
});
+65
View File
@@ -6,9 +6,12 @@ import {
} from '../../server/providers/cctv/cap.js';
import {
CCTV_MAX_SOURCES_CEILING,
CCTV_SHARED_SOURCE_SLOTS,
CCTV_SOURCE_CACHE_MS,
DEFAULT_CCTV_MAX_SOURCES,
WORLD_CCTV_SOURCE_COUNT,
} from '../../server/providers/cctv/constants.js';
import { QUEBEC_CAMERA_SOURCE_COUNT } from '../../server/providers/cctv/quebec.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const pack = (name, count, prefix = name) => ({
@@ -99,6 +102,68 @@ test('duplicate ids resolve last-pack-wins and count against the winning pack',
]);
});
/** Shared packs plus two reserved ones, shaped like the default catalog. */
const reservedFixture = () => [
...Array.from({ length: 20 }, (_, i) => pack(`shared${i}`, 250)),
{ ...pack('quebec', 1120), reservedCount: 1120 },
{ ...pack('world', 2230), reservedCount: 2230 },
];
const keptByName = (packs) =>
Object.fromEntries(packs.map(({ name, kept }) => [name, kept]));
test('the default cap is the shared floor plus the reserved Québec and World packs', () => {
assert.equal(
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS +
QUEBEC_CAMERA_SOURCE_COUNT +
WORLD_CCTV_SOURCE_COUNT,
);
});
test('at the default cap reserved packs are whole and shared packs keep the floor', () => {
const { sources, packs } = allocateSourceCap(
reservedFixture(),
DEFAULT_CCTV_MAX_SOURCES,
);
const kept = keptByName(packs);
assert.equal(sources.length, DEFAULT_CCTV_MAX_SOURCES);
assert.equal(kept.quebec, 1120);
assert.equal(kept.world, 2230);
for (let i = 0; i < 20; i++) assert.equal(kept[`shared${i}`], 200);
assert.match(
sources[0].id,
/^shared0-/,
'the first camera stays in a shared pack',
);
});
test('a cap at the shared floor reserves nothing and thins every pack evenly', () => {
const { packs } = allocateSourceCap(reservedFixture(), 4000);
const kept = keptByName(packs);
// 22 lanes share 4,000 round-robin: nobody is wiped out by the reservation.
for (const count of Object.values(kept))
assert.ok(count >= 181 && count <= 182);
});
test('a small cap (dev-secure 48) still reaches the shared packs first', () => {
const { sources, packs } = allocateSourceCap(reservedFixture(), 48);
const kept = keptByName(packs);
assert.equal(sources.length, 48);
for (let i = 0; i < 20; i++) assert.ok(kept[`shared${i}`] >= 2);
assert.match(sources[0].id, /^shared0-/);
});
test('reservations fill only the room above the floor, round-robin between reserved packs', () => {
const { packs } = allocateSourceCap(reservedFixture(), 4100);
const kept = keptByName(packs);
const shared = Object.entries(kept)
.filter(([name]) => name.startsWith('shared'))
.reduce((sum, [, count]) => sum + count, 0);
// 100 reserved slots split between Québec and World, then 4,000 round-robin.
assert.ok(shared >= 4000 - 2 * 182, `shared packs kept ${shared}`);
assert.equal(shared + kept.quebec + kept.world, 4100);
});
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
+8 -3
View File
@@ -14,6 +14,8 @@ import {
DEFAULT_ONTARIO_MAX_SOURCES,
DEFAULT_FINTRAFFIC_MAX_SOURCES,
DEFAULT_TXDOT_MAX_SOURCES,
DEFAULT_OTTAWA_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_TALLINN_MAX_SOURCES,
DEFAULT_TARKTEE_MAX_SOURCES,
DEFAULT_NSW_MAX_SOURCES,
@@ -222,7 +224,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
);
});
test('default caps of the original packs fit inside the default catalog cap', () => {
test('default caps of the original packs fit inside the shared catalog slots', () => {
// Every default pack at its own cap (Warendorf ships 1 curated camera).
const packs =
DEFAULT_AUSTIN_MAX_SOURCES +
@@ -231,15 +233,18 @@ test('default caps of the original packs fit inside the default catalog cap', ()
DEFAULT_ONTARIO_MAX_SOURCES +
DEFAULT_FINTRAFFIC_MAX_SOURCES +
DEFAULT_DRIVEBC_MAX_SOURCES +
DEFAULT_OTTAWA_MAX_SOURCES +
DEFAULT_TXDOT_MAX_SOURCES +
DEFAULT_TALLINN_MAX_SOURCES +
DEFAULT_TARKTEE_MAX_SOURCES +
DEFAULT_NSW_MAX_SOURCES +
DEFAULT_CATALONIA_MAX_SOURCES +
1;
// The reserved Québec and World packs sit above the shared floor, so the
// shared packs must fit the floor itself, not the whole default cap.
assert.ok(
packs <= DEFAULT_CCTV_MAX_SOURCES,
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
packs <= CCTV_SHARED_SOURCE_SLOTS,
`default packs (${packs}) would be thinned by the shared slots (${CCTV_SHARED_SOURCE_SLOTS})`,
);
assert.ok(
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
+4 -4
View File
@@ -10,7 +10,7 @@ import {
parseHkCameraLocationsXml,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_HK_CAMERAS_URL,
DEFAULT_HK_MAX_SOURCES,
HK_ANCHORS,
@@ -396,8 +396,8 @@ test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
});
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
@@ -406,7 +406,7 @@ test('the HK pack default sits above the full TD catalog and anchors the New Ter
'default cap should keep nearly all published TD cameras',
);
assert.ok(
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_HK_MAX_SOURCES <= CCTV_SHARED_SOURCE_SLOTS,
'a single pack must not outgrow the catalog-wide ceiling alone',
);
const places = HK_ANCHORS.map(
+71
View File
@@ -10,6 +10,77 @@ const playlist =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
const base = 'https://camera.example/live/list.m3u8';
test('high-resolution HLS segments play while the retained session cache stays bounded', async (t) => {
// Giroc publishes roughly ten-second TS segments; one measured 6,437,120
// bytes. The former 4 MiB download cap rejected it before playback began.
const segmentBytes = 6_437_120;
const master =
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=4570000\ntracks-v1a1/mono.m3u8\n';
const media =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:100\n' +
[0, 1, 2, 3, 4].map((i) => `#EXTINF:10.272,\n${i}.ts\n`).join('');
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 100000 },
fetchImpl: async (url) => {
if (url === base) return new Response(master);
if (url === 'https://camera.example/live/tracks-v1a1/mono.m3u8')
return new Response(media);
const match =
/^https:\/\/camera\.example\/live\/tracks-v1a1\/([0-4])\.ts$/.exec(url);
if (!match) throw new Error('Unexpected playlist reference');
// No Content-Length: the actual streamed bytes must also be accepted.
return new Response(Buffer.alloc(segmentBytes, Number(match[1])));
},
});
t.after(() => manager.shutdown());
const entry = await manager.ensure('giroc', base);
await entry.polling;
const result = await manager.buildPlaylist(entry, 'giroc');
assert.ok(result, 'large valid segments should produce a playable playlist');
assert.match(result, /#EXT-X-MEDIA-SEQUENCE:2\n/);
assert.equal(manager.getSegment('giroc', entry.token, 0), null);
const newest = manager.getSegment('giroc', entry.token, 4);
assert.equal(newest.length, segmentBytes);
assert.equal(newest[0], 4);
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 19_311_360 });
});
test('HLS downloads still reject declared and chunked bodies above 8 MiB', async () => {
for (const declared of [true, false]) {
let chunks = 0;
let cancelled = false;
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: HLS_LIMITS.segmentBytes,
fetchImpl: async () =>
new Response(
declared
? 'small body'
: new ReadableStream({
pull(controller) {
// Each chunk fits on its own; only their cumulative size
// crosses the cap. Leave it open to verify cancellation.
controller.enqueue(
Buffer.alloc(4 * 1024 * 1024 + (++chunks === 2 ? 1 : 0)),
);
},
cancel() {
cancelled = true;
},
}),
{
headers: declared
? { 'content-length': String(8 * 1024 * 1024 + 1) }
: {},
},
),
}),
/HLS response too large/,
);
if (!declared) assert.equal(cancelled, true);
}
});
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
assert.deepEqual(
parseHlsMedia(playlist, base).map((s) => s.seq),
+403
View File
@@ -0,0 +1,403 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
houstonCameraName,
houstonCameraToSource,
loadHoustonSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
import {
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_HOUSTON_MAX_SOURCES,
HOUSTON_CAMERAS_URL,
HOUSTON_GROUND_ELEVATION_M,
HOUSTON_IMAGE_ORIGIN,
HOUSTON_IMAGE_URL,
HOUSTON_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
CAMERA_CODE_MAX_CHARS,
isLikelyHoustonCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const DOWNTOWN_ID = '1002';
const GALLERIA_ID = '2001';
/** One TranStar camera row, shaped like the live `cctvSnapshots_json.js` payload. */
const row = (overrides = {}) => ({
location: '10 EAST @ SAN JACINTO',
id: DOWNTOWN_ID,
lat: '29.76824',
lng: '-95.355711',
dir: 'South',
fc: '6',
fp: '300',
url: "showWin('/layers/gc.aspx?cam=1002&loc=IH-10_East_at_SAN_JACINTO')",
...overrides,
});
test('a Houston row maps to a source on the pinned HTTPS frame host', () => {
const source = houstonCameraToSource(row());
assert.equal(source.id, `houston-${DOWNTOWN_ID}`);
assert.equal(source.name, '10 EAST @ SAN JACINTO');
assert.equal(source.city, 'Houston');
assert.equal(source.cityId, 'houston');
assert.equal(source.provider, 'Houston TranStar');
assert.equal(source.lat, 29.76824);
assert.equal(source.lon, -95.355711);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'houston-transtar');
assert.equal(
source.license,
'TranStar disclaimer — redistribution and external framing need TxDOT written permission',
);
assert.equal(source.groundElevationM, HOUSTON_GROUND_ELEVATION_M);
assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID));
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(HOUSTON_IMAGE_ORIGIN));
// dir South = the camera sits south of I-10; the nearest lanes run east.
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg, 90);
});
test('frame URLs are synthesized from the numeric id; the payload URL is ignored', () => {
const source = houstonCameraToSource(
row({ url: 'https://evil.example/cameras/x/image' }),
);
assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID));
assert.equal(source.snapshotUrl, source.url);
});
test('zero ids, off-city geometry and unusable ids are dropped', () => {
assert.equal(houstonCameraToSource(row({ id: '0' })), null);
assert.equal(houstonCameraToSource(row({ id: '00' })), null);
assert.equal(houstonCameraToSource(row({ id: '../etc/passwd' })), null);
assert.equal(houstonCameraToSource(row({ id: '12' })), null);
assert.equal(houstonCameraToSource(row({ id: '1002/../x' })), null);
// Dallas: a plausible lat/lon, but not a Houston camera.
assert.equal(
houstonCameraToSource(row({ lat: '32.7767', lng: '-96.797' })),
null,
);
assert.equal(houstonCameraToSource(row({ lat: '0', lng: '0' })), null);
assert.equal(houstonCameraToSource(null), null);
// 3-digit and 5-digit snapshot ids stay.
assert.equal(houstonCameraToSource(row({ id: '102' })).id, 'houston-102');
assert.equal(houstonCameraToSource(row({ id: '13711' })).id, 'houston-13711');
});
test('dir is the side of the road: it gives a low-confidence travel heading', () => {
// TranStar's map reads dir as "Traffic closest to the camera is traveling":
// South -> Eastbound, North -> Westbound, West -> Southbound, East -> Northbound.
const expected = { South: 90, North: 270, West: 180, East: 0, south: 90 };
for (const [dir, heading] of Object.entries(expected)) {
const source = houstonCameraToSource(row({ dir }));
assert.equal(source.headingDeg, heading, `dir ${dir}`);
assert.equal(source.headingConfidence, 'low', `dir ${dir}`);
assert.equal(source.pitchDeg, -18, `dir ${dir}`);
assert.equal(source.fovDeg, 44, `dir ${dir}`);
assert.equal(source.rangeM, 145, `dir ${dir}`);
assert.equal(source.mountHeightM, 8, `dir ${dir}`);
}
const none = houstonCameraToSource(row({ dir: 'None' }));
assert.equal(none.headingConfidence, 'low');
assert.ok(Number.isFinite(none.headingDeg));
assert.equal(
none.headingDeg,
houstonCameraToSource(row({ dir: '' })).headingDeg,
);
for (const dir of ['Northeast', 'constructor', '__proto__', undefined]) {
assert.equal(
houstonCameraToSource(row({ dir })).headingDeg,
none.headingDeg,
`dir ${dir} falls back to the id hash`,
);
}
});
test('a nameless row still gets a label', () => {
assert.equal(
houstonCameraName({ location: ' 45 GULF @ WEST DALLAS ' }, 'houston-102'),
'45 GULF @ WEST DALLAS',
);
assert.equal(houstonCameraName({}, 'houston-102'), 'Houston Camera 102');
assert.equal(houstonCameraName({}, ''), 'Houston Camera');
});
test('the unselected label is the interchange, trimmed to the code width', () => {
assert.equal(houstonCameraToSource(row()).code, '10 EAST @ SAN JACINTO');
const long = houstonCameraToSource(
row({
location:
'IH-10 Katy Managed Lanes Eastbound at a very long interchange name that must truncate',
}),
);
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.headers.Accept, init.redirect]);
return Response.json({
cameras: [
row(),
row({ location: '10 EAST @ SAN JACINTO (duplicate)' }),
row({
id: GALLERIA_ID,
location: '610 WEST LOOP @ SAN FELIPE',
lat: '29.749',
lng: '-95.461',
dir: 'North',
}),
row({ url: 'https://evil.example/x.jpg', id: '0' }),
],
});
});
const cameras = await loadHoustonSourcesFromOpenData();
assert.deepEqual(requested, [[HOUSTON_CAMERAS_URL, '*/*', 'manual']]);
assert.deepEqual(
cameras.map((camera) => camera.id),
[`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`],
'nearest-to-anchor first: Uptown sits closer to its pin than downtown does',
);
assert.equal(cameras[1].name, '10 EAST @ SAN JACINTO');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.js' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
assert.deepEqual(seen, [[HOUSTON_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify({ cameras: [row()] });
return new Response(body, {
headers: {
'Content-Type': 'application/javascript',
'content-length': String(HOUSTON_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
// Valid JSON with a usable camera, so only the byte cap can refuse it.
const oversized = JSON.stringify({
cameras: [row()],
padding: 'x'.repeat(HOUSTON_MAX_CATALOG_BYTES + 1024),
});
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/javascript' } },
);
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
});
test('a reduced pack cap keeps cameras nearest the metro anchors', async (t) => {
t.mock.method(console, 'log', () => {});
const saved = process.env.CCTV_HOUSTON_MAX_SOURCES;
t.after(() => {
if (saved === undefined) delete process.env.CCTV_HOUSTON_MAX_SOURCES;
else process.env.CCTV_HOUSTON_MAX_SOURCES = saved;
});
process.env.CCTV_HOUSTON_MAX_SOURCES = '8';
const rows = [
...[29.32, 29.322, 29.324, 29.326, 29.328, 29.33].map((lat, i) =>
row({
id: String(3000 + i),
lat: String(lat),
lng: '-94.80',
location: `Galveston ${i}`,
dir: 'East',
}),
),
...[29.76, 29.759, 29.758, 29.757, 29.756, 29.755].map((lat, i) =>
row({
id: String(4000 + i),
lat: String(lat),
lng: '-95.3698',
location: `Downtown ${i}`,
dir: 'North',
}),
),
];
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ cameras: rows }),
);
const cameras = await loadHoustonSourcesFromOpenData();
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras.map((camera) => camera.name.slice(0, 8)),
[
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Galvesto',
'Galvesto',
],
'downtown fills first; the Galveston anchor still keeps a share',
);
});
/**
* Serve the Houston catalog to the Houston endpoint and an empty payload to
* every other pack, so one catalog refresh exercises the registration without
* reaching the network.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === HOUSTON_CAMERAS_URL) {
return Response.json({
cameras: [
row(),
row({
id: GALLERIA_ID,
location: '610 WEST LOOP @ SAN FELIPE',
lat: '29.749',
lng: '-95.461',
dir: 'West',
}),
],
});
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the Houston lane is wired into the catalog and its loader runs', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_HOUSTON_ENABLED = '1';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(HOUSTON_CAMERAS_URL),
'the catalog refresh invokes the Houston loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'houston').map((s) => s.id),
[`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`],
'Houston cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
for (const value of [undefined, '0']) {
if (value === undefined) delete process.env.CCTV_HOUSTON_ENABLED;
else process.env.CCTV_HOUSTON_ENABLED = value;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(HOUSTON_CAMERAS_URL),
false,
'the pack reaches TranStar only after an explicit opt-in',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'houston'),
[],
);
}
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
});
test('isLikelyHoustonCoordinate spans the metro and rejects the rest', () => {
assert.equal(isLikelyHoustonCoordinate(29.7604, -95.3698), true); // Downtown
assert.equal(isLikelyHoustonCoordinate(29.3013, -94.7977), true); // Galveston
assert.equal(isLikelyHoustonCoordinate(29.9902, -95.3368), true); // IAH
assert.equal(isLikelyHoustonCoordinate(32.7767, -96.797), false); // Dallas
assert.equal(isLikelyHoustonCoordinate(30.2672, -97.7431), false); // Austin
assert.equal(isLikelyHoustonCoordinate(0, 0), false);
assert.equal(isLikelyHoustonCoordinate(NaN, -95.37), false);
});
+429
View File
@@ -0,0 +1,429 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
DEFAULT_ICELAND_MAX_SOURCES,
ICELAND_CAMERAS_URL,
ICELAND_IMAGE_ORIGIN,
ICELAND_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
import { isLikelyIcelandCoordinate } from '../../server/providers/cctv/normalize.js';
import {
icelandCameraToSource,
icelandHeadingFromDescription,
loadIcelandSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** One row shaped like the live `vefmyndavelar2014_1` payload. */
const row = (over = {}) => ({
Maelist_nr: 7001,
Myndavel: 'Hellisheiði',
Vegheiti: 'Hringvegur',
NrVegur: '1',
Skyring: 'Hellisheiði séð til vesturs',
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`,
PntX: 395000,
PntY: 390000,
Breidd: 64.0136,
Lengd: -21.4,
...over,
});
/** A live response body plus a flag that flips when the stream is cancelled. */
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const json = (value) =>
new Response(JSON.stringify(value), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
test('an Iceland row maps to a source on the pinned frame directory', () => {
const source = icelandCameraToSource(row());
assert.equal(source.id, 'is-vegagerdin-hellisheidi_1');
assert.equal(source.name, 'Hellisheiði séð til vesturs');
assert.equal(source.city, 'Iceland');
assert.equal(source.cityId, 'iceland');
assert.equal(source.provider, 'Vegagerðin (IRCA)');
assert.equal(source.lat, 64.0136);
assert.equal(source.lon, -21.4);
assert.equal(source.headingDeg, 270);
assert.equal(source.headingConfidence, 'high');
assert.equal(source.pitchDeg, -24);
assert.equal(source.fovDeg, 56);
assert.equal(source.rangeM, 210);
assert.equal(source.mountHeightM, 10);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'iceland-vegagerdin');
assert.equal(source.code, 'HELLISHEIÐI');
assert.match(source.license, /CC BY 4\.0/);
assert.ok(source.url.startsWith(ICELAND_IMAGE_ORIGIN));
assert.equal(source.snapshotUrl, source.url);
});
test('a view that states no facing takes the low-confidence id-hash pose', () => {
const source = icelandCameraToSource(
row({
Skyring: 'Hellisheiði séð niður á veg',
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg`,
}),
);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg % 22.5, 0);
assert.equal(
source.headingDeg,
icelandCameraToSource(
row({ Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg` }),
).headingDeg,
'the fallback depends on the id, not on the text',
);
});
test('the label collapses the feed’s doubled spaces and repeated words', () => {
assert.equal(
icelandCameraToSource(
row({ Skyring: 'Strandarheiði séð niður á veg til til austurs' }),
).name,
'Strandarheiði séð niður á veg til austurs',
);
const label = (Skyring) => icelandCameraToSource(row({ Skyring })).name;
// Repeats of words with Icelandic letters collapse too.
assert.equal(
label('Hellisheiði séð séð til austurs'),
'Hellisheiði séð til austurs',
);
assert.equal(label('Brú á á Jökulsá'), 'Brú á Jökulsá');
// A word ending in a letter the next word starts with is not a repeat.
for (const text of [
'Þverá Árnessýslu',
'brú Úlfarsá',
'Vesturlandsvegur við Elliðaá Ártúnsbrekku',
])
assert.equal(label(text), text);
});
test('a row with no label, station or station number is named by its frame', () => {
const source = icelandCameraToSource(
row({ Skyring: '', Myndavel: '', Maelist_nr: undefined }),
);
assert.equal(source.name, 'Vegagerðin hellisheidi_1');
});
test('only an explicit facing sets a heading', () => {
const cases = [
['Hellisheiði séð til vesturs', 270],
['Holtavörðuheiði til norðurs', 0],
['Klettsháls í suður', 180],
['Arnarneshæð séð austur', 90],
['Bústaðabrú séð norður Kringlumýrarbraut', 0],
['Staður séð til norðausturs', 45],
['Staður séð til suðausturs', 135],
['Staður séð í suðvestur', 225],
['Staður séð til norðvesturs', 315],
['STAÐUR SÉÐ TIL VESTURS', 270],
['Brú séð til austurs og Selfoss', 90], // "og" then a place, not a facing
['Staður séð til austurs og Vesturlandsvegur', 90],
];
for (const [text, heading] of cases) {
assert.equal(icelandHeadingFromDescription(text), heading, text);
}
for (const text of [
'',
null,
'Gautsdalur niður á veg',
'Miklabraut austur', // a road direction, not a facing
'Biskupsháls - norður',
'Hringtorg norðan Selfoss',
'Norðurárdalur niður á veg', // a place name that contains a cardinal
'Staður séð til suðurlands', // a longer word, not "suður"
'Staður séð til norðurs og til suðurs', // two facings: ambiguous
'Staður séð til austurs og vesturs', // the second facing shares "til"
'Staður séð í norður og suður',
'Staður séð í norður og í suður',
'Norðfjarðargöng séð til Eskifjarðar',
]) {
assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text));
}
});
test('rows off the pinned directory, or with unusable frames or places, are dropped', () => {
const bad = [
{ Slod: 'https://example.com/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'http://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://user:[email protected]/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is:8443/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/other/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/../x/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a%2Fb_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.php' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a b_1.jpg' },
{ Slod: '' },
{ Slod: null },
{ Breidd: 0, Lengd: 0 },
{ Breidd: null, Lengd: null },
{ Breidd: '64.01', Lengd: '-21.4' },
{ Breidd: '64.01' }, // each coordinate must be a number on its own
{ Lengd: '-21.4' },
{ Breidd: 59.9, Lengd: 10.7 }, // Oslo
{ Breidd: 64.01, Lengd: 21.4 }, // wrong hemisphere
];
for (const over of bad) {
assert.equal(icelandCameraToSource(row(over)), null, JSON.stringify(over));
}
assert.equal(icelandCameraToSource(null), null);
assert.equal(icelandCameraToSource({}), null);
assert.equal(isLikelyIcelandCoordinate(64.1466, -21.9426), true);
assert.equal(isLikelyIcelandCoordinate(66.3, -14.0), true);
});
test('the Iceland loader reads the list with redirects refused and keeps the nearest cameras', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: '8' });
const rows = Array.from({ length: 12 }, (_, i) =>
row({
Maelist_nr: 8000 + i,
Slod: `${ICELAND_IMAGE_ORIGIN}far_${i}.jpg`,
Skyring: `Far ${i}`,
Breidd: 66.2,
Lengd: -15.9 + i * 0.01,
}),
);
// Reykjavík cameras come last in the feed but first in the priority order.
// A frame the feed repeats keeps its last row, as the other packs do.
rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Skyring: 'Old' }));
rows.push(
row({
Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`,
Skyring: 'Kringlumýrarbraut',
Breidd: 64.1466,
Lengd: -21.9426,
}),
);
const seen = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return json(rows);
});
const sources = await loadIcelandSourcesFromOpenData();
assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]);
assert.equal(sources.length, 8);
assert.equal(sources[0].id, 'is-vegagerdin-rvk_1');
assert.equal(sources[0].name, 'Kringlumýrarbraut');
assert.equal(sources[0].lat, 64.1466);
assert.equal(
new Set(sources.map((s) => s.id)).size,
sources.length,
'a frame the feed repeats collapses to one camera',
);
assert.equal(sources.available, 13);
});
test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => {
t.mock.method(console, 'log', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
const rows = Array.from(
{ length: DEFAULT_ICELAND_MAX_SOURCES + 40 },
(_, i) => row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
);
t.mock.method(globalThis, 'fetch', async () => json(rows));
assert.equal(
(await loadIcelandSourcesFromOpenData()).length,
DEFAULT_ICELAND_MAX_SOURCES,
);
});
test('CCTV_ICELAND_MAX_SOURCES is clamped to 8..600 and ignored when not a number', async (t) => {
t.mock.method(console, 'log', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
const rows = Array.from({ length: 650 }, (_, i) =>
row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
);
t.mock.method(globalThis, 'fetch', async () => json(rows));
for (const [value, expected] of [
['0', 8],
['3', 8],
['1000', 600],
['abc', DEFAULT_ICELAND_MAX_SOURCES],
]) {
process.env.CCTV_ICELAND_MAX_SOURCES = value;
assert.equal(
(await loadIcelandSourcesFromOpenData()).length,
expected,
value,
);
}
});
test('a redirect on the Iceland frame host stays inside vefmyndavelar/', async () => {
const frame = `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`;
const redirectTo = (location) => async (url) =>
url === frame
? new Response(null, { status: 302, headers: { location } })
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
for (const location of [
'/vgdata/other/x.jpg',
'/vgdata/vefmyndavelar/../x.jpg',
'/',
])
assert.equal(
await fetchWithinHost(frame, {}, redirectTo(location)),
null,
location,
);
const followed = await fetchWithinHost(
frame,
{},
redirectTo('/vgdata/vefmyndavelar/hellisheidi_1b.jpg'),
);
assert.equal(await followed.text(), 'frame');
});
test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(
globalThis,
'fetch',
async () => new Response('x', { status: 503 }),
);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => json({ rows: [] }));
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.method(
globalThis,
'fetch',
async () => new Response('<html>', { status: 200 }),
);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
});
test('the Iceland catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const redirected = streamingResponse({
status: 302,
headers: {
location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1',
},
});
t.mock.method(globalThis, 'fetch', async () => redirected.response);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 500 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
const valid = JSON.stringify([row()]);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
return new Response(valid, {
headers: { 'content-length': String(ICELAND_MAX_CATALOG_BYTES + 1) },
});
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const padded = valid + ' '.repeat(ICELAND_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(padded));
controller.close();
},
}),
);
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
});
test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const sourceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'),
);
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_CALTRANS_DISTRICTS: undefined,
CCTV_TFL_ENABLED: undefined,
CCTV_ICELAND_ENABLED: undefined,
CCTV_ICELAND_MAX_SOURCES: undefined,
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
if (String(url) !== ICELAND_CAMERAS_URL) {
return new Response('unavailable', { status: 503 });
}
return json([row()]);
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['is-vegagerdin-hellisheidi_1'],
);
process.env.CCTV_ICELAND_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.some(([url]) => url === ICELAND_CAMERAS_URL),
false,
'a disabled pack makes no request',
);
});
+2
View File
@@ -34,6 +34,8 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
'ontario 511': 3 * 60 * 1000,
// TxDOT publishes roughly once a minute; 3 min matches the other highway packs.
txdot: 3 * 60 * 1000,
// TranStar says snapshots update about every 3 minutes.
'houston transtar': 3 * 60 * 1000,
// Digitraffic publishes a new weathercam frame on each station's
// collectionInterval, 600 s for every station sampled; polling faster only
// re-fetches the same JPEG.
+1
View File
@@ -639,6 +639,7 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value
assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Ontario 511' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Houston TranStar' }), 180_000);
// Digitraffic publishes one weathercam frame per 600 s collection interval.
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
assert.equal(
+3 -3
View File
@@ -8,7 +8,7 @@ import {
nycCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_NYC_MAX_SOURCES,
NYC_CAMERAS_URL,
NYC_GROUND_ELEVATION_M,
@@ -375,8 +375,8 @@ for (const [label, flag] of [
});
}
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
});
+255
View File
@@ -0,0 +1,255 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
OTTAWA_CAMERAS_URL,
OTTAWA_IMAGE_ORIGIN,
} from '../../server/providers/cctv/constants.js';
import {
fallbackHeadingFromId,
pinnedCameraUrl,
} from '../../server/providers/cctv/normalize.js';
import {
_resetOttawaCertificateWarningForTest,
loadOttawaSourcesFromOpenData,
ottawaAuthorizedFrameUrl,
} from '../../server/providers/cctv/ottawa.js';
const CERT = { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' };
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loader's progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** One documented `/map/service/camera` row carrying the fields the loader reads. */
function ottawaRow(number, lat, lon, overrides = {}) {
return {
id: number + 10000,
camera_number: number,
name: `Camera ${number}`,
name_french: `Caméra ${number}`,
cameraOwner: 'CITY',
latitude: lat,
longitude: lon,
...overrides,
};
}
/** The feed's envelope around its rows. */
const ottawaPayload = (rows) => Response.json({ cameras: rows });
test('without OTTAWA_CAMERA_CERTIFICATE the Ottawa pack is skipped with no request, logged once', async (t) => {
_resetOttawaCertificateWarningForTest();
const lines = [];
t.mock.method(console, 'warn', (line) => lines.push(String(line)));
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
throw new Error('no request expected');
});
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, {}), []);
assert.deepEqual(
await loadOttawaSourcesFromOpenData({}, { OTTAWA_CAMERA_CERTIFICATE: ' ' }),
[],
);
assert.equal(fetchMock.mock.callCount(), 0);
assert.equal(lines.length, 1);
assert.match(lines[0], /OTTAWA_CAMERA_CERTIFICATE/);
});
test('Ottawa frame URLs are signed only inside the opendata camera path', () => {
assert.equal(
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', CERT),
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
);
assert.equal(
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', {}),
'',
'no certificate, no signed URL',
);
for (const url of [
'https://traffic.ottawa.ca/opendata/camera?c=16x',
'https://traffic.ottawa.ca/opendata/other?c=16',
'https://traffic.ottawa.ca/map/camera?id=16',
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=16',
'http://traffic.ottawa.ca/opendata/camera?c=16',
])
assert.equal(ottawaAuthorizedFrameUrl(url, CERT), '', url);
});
test('Ottawa loader keeps in-city cameras and builds keyless frame URLs from the camera number', async (t) => {
quiet(t);
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: undefined });
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return ottawaPayload([
ottawaRow(2025, 45.341343, -75.81467, {
name: '(MTO) Hwy 416 NB ramp to Hwy 417 East',
cameraOwner: 'MTO',
}),
ottawaRow(1, 45.4215, -75.6972, {
name: 'Bank St at Somerset St',
camera_owner: 'CITY',
cameraOwner: undefined,
}),
ottawaRow(1, 45.4216, -75.6973, { name: 'duplicate number' }),
ottawaRow(0, 45.4215, -75.6972),
ottawaRow(-3, 45.4215, -75.6972),
ottawaRow(7, null, -75.7),
ottawaRow(8, 45.4, null),
ottawaRow(9, 43.6532, -79.3832, { description: 'Toronto' }),
ottawaRow(10, 45.9, -75.7),
]);
});
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
assert.deepEqual(requested, [OTTAWA_CAMERAS_URL]);
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
'ottawa-1',
'ottawa-2025',
]);
const mto = cameras.find((camera) => camera.id === 'ottawa-2025');
const city = cameras.find((camera) => camera.id === 'ottawa-1');
assert.equal(mto.url, 'https://traffic.ottawa.ca/opendata/camera?c=2025');
assert.doesNotMatch(mto.url, /certificate/, 'the catalog URL carries no key');
assert.equal(mto.snapshotUrl, mto.url);
assert.equal(mto.frameRefreshMs, 60000, 'the API allows one fetch a minute');
assert.equal(city.name, 'Bank St at Somerset St');
assert.equal(mto.name, '(MTO) Hwy 416 NB ramp to Hwy 417 East');
assert.equal(mto.city, 'Ottawa');
assert.equal(mto.provider, 'City of Ottawa');
assert.equal(mto.feedType, 'image');
assert.equal(mto.sourceKind, 'ottawa-traffic');
assert.equal(mto.headingConfidence, 'low');
assert.equal(
mto.headingDeg,
fallbackHeadingFromId('ottawa-2025'),
'the feed publishes no bearing, so the id-hash fallback is deterministic',
);
assert.match(mto.license, /City of Ottawa/);
assert.equal(
mto.credit,
'Ministry of Transportation of Ontario (MTO) camera',
'a provincial camera carried on the city feed names its owner',
);
assert.equal(city.credit, '', 'a city camera carries no partner credit');
});
test('Ottawa loader keeps the cameras nearest downtown under its cap', async (t) => {
quiet(t);
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: '8' });
const rows = [
// Far suburbs come first in payload order, so only distance can drop them.
...[45.55, 45.54, 45.53, 45.52].map((lat, i) =>
ottawaRow(100 + i, lat, -76.1),
),
...[45.42, 45.421, 45.422, 45.423, 45.424, 45.425, 45.426, 45.427].map(
(lat, i) => ottawaRow(200 + i, lat, -75.7),
),
];
t.mock.method(globalThis, 'fetch', async () => ottawaPayload(rows));
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras.map((camera) => camera.id).filter((id) => /^ottawa-10\d$/.test(id)),
[],
'the far-suburb cameras are the ones dropped',
);
});
test('Ottawa loader fails soft on HTTP errors, unexpected payloads and network errors', async (t) => {
quiet(t);
let respond;
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
for (respond of [
async () => new Response('unavailable', { status: 503 }),
async () => Response.json({ cameras: [] }),
async () => Response.json([ottawaRow(1, 45.4215, -75.6972)]),
async () => {
throw new TypeError('fetch failed');
},
]) {
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, CERT), []);
}
});
test('CCTV catalog merges Ottawa cameras with a certificate and CCTV_OTTAWA_ENABLED=0 skips the request', async (t) => {
quiet(t);
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-ottawa-'));
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_OTTAWA_ENABLED: undefined,
CCTV_OTTAWA_MAX_SOURCES: undefined,
CCTV_QUEBEC_ENABLED: '0',
CCTV_WORLD_CAMERAS_ENABLED: '0',
OTTAWA_CAMERA_CERTIFICATE: 'CERT123',
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
// Every other live pack is down; only Ottawa answers.
return String(url) === OTTAWA_CAMERAS_URL
? ottawaPayload([ottawaRow(1, 45.4215, -75.6972)])
: new Response('unavailable', { status: 503 });
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['ottawa-1'],
);
assert.equal(sources[0].url, 'https://traffic.ottawa.ca/opendata/camera?c=1');
process.env.CCTV_OTTAWA_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.includes(OTTAWA_CAMERAS_URL),
false,
'a disabled pack makes no request',
);
});
test('Ottawa frames stay inside the city image directory', () => {
assert.ok(
pinnedCameraUrl(
'https://traffic.ottawa.ca/opendata/camera?c=309',
OTTAWA_IMAGE_ORIGIN,
),
);
for (const url of [
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=309',
'https://traffic.ottawa.ca/map/camera?id=309',
'https://user:[email protected]/opendata/camera?c=309',
'https://traffic.ottawa.ca:8443/opendata/camera?c=309',
'http://traffic.ottawa.ca/opendata/camera?c=309',
])
assert.equal(pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN), null, url);
});
+231
View File
@@ -313,3 +313,234 @@ test('CCTV upstream frame fetch accepts only raster image types and sends their
assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
}
});
function multipartResponse(contentType, chunks, { onCancel = () => {} } = {}) {
let nextChunk = 0;
const body = new ReadableStream(
{
pull(controller) {
if (nextChunk >= chunks.length) {
controller.close();
return;
}
controller.enqueue(chunks[nextChunk++]);
},
cancel() {
onCancel();
},
},
{ highWaterMark: 0 },
);
return new Response(body, {
status: 200,
headers: { 'Content-Type': contentType },
});
}
function jpegPart(boundary, body, contentType = 'image/jpeg', length = body.length) {
return Buffer.concat([
Buffer.from(
'--' +
boundary +
'\r\nContent-Type: ' +
contentType +
'\r\nContent-Length: ' +
length +
'\r\n\r\n',
),
body,
Buffer.from('\r\n'),
]);
}
test('CCTV upstream frame fetch extracts only the first JPEG from MJPEG', async () => {
const boundary = 'DIGIEVER';
const first = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
const second = Buffer.from([0xff, 0xd8, 0x33, 0x44, 0xff, 0xd9]);
for (const type of [
'multipart/x-mixed-replace; boundary=DIGIEVER',
'multipart/x-mixed-replace; boundary="DIGIEVER"',
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes: 1024,
fetchImpl: async () =>
multipartResponse(
type,
[
jpegPart(boundary, first),
jpegPart(boundary, second),
Buffer.from('--' + boundary + '--\r\n'),
],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result?.contentType, 'image/jpeg', type);
assert.deepEqual(result?.body, first, type);
assert.equal(cancelled, true, type);
}
});
test('CCTV upstream frame fetch rejects malformed and non-JPEG MJPEG parts', async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
for (const [type, chunks] of [
['multipart/x-mixed-replace', []],
[
'multipart/x-mixed-replace; boundary=DIGIEVER',
[jpegPart('DIGIEVER', jpeg, 'image/png')],
],
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () =>
multipartResponse(type, chunks, { onCancel: () => { cancelled = true; } }),
},
);
assert.equal(result, null, type);
assert.equal(cancelled, true, type);
}
});
test('CCTV upstream frame fetch rejects truncated or oversized MJPEG parts', async () => {
const boundary = 'DIGIEVER';
const full = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
for (const [chunks, maxBytes] of [
[jpegPart(boundary, truncated, 'image/jpeg', full.length), 1024],
[jpegPart(boundary, full), 5],
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes,
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[chunks],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
}
});
test('CCTV upstream frame fetch does not cross into a later part when the first JPEG is truncated', async () => {
const boundary = 'DIGIEVER';
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
const valid = Buffer.from([0xff, 0xd8, 0x03, 0x04, 0xff, 0xd9]);
const wire = Buffer.concat([
Buffer.from(
'--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n',
),
truncated,
Buffer.from(
'\r\n--' +
boundary +
'\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
valid.length +
'\r\n\r\n',
),
valid,
Buffer.from('\r\n--' + boundary + '--\r\n'),
]);
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes: 1024,
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[wire],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
});
test('CCTV upstream frame fetch ignores MJPEG unless the source is registered as MJPEG', async () => {
const boundary = 'DIGIEVER';
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[jpegPart(boundary, jpeg)],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
});
test('CCTV upstream frame fetch accepts a CRLF preamble and a boundary that already starts with --', async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
for (const [type, wire] of [
[
'multipart/x-mixed-replace; boundary=DIGIEVER',
Buffer.concat([Buffer.from('\r\n'), jpegPart('DIGIEVER', jpeg)]),
],
[
'multipart/x-mixed-replace; boundary=--myboundary',
Buffer.concat([
Buffer.from(
'--myboundary\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
jpeg.length +
'\r\n\r\n',
),
jpeg,
Buffer.from('\r\n--myboundary\r\n'),
]),
],
]) {
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () => multipartResponse(type, [wire]),
},
);
assert.deepEqual(result?.body, jpeg, type);
}
});
test('CCTV MJPEG part without Content-Length ends at the next delimiter, not at an inner JPEG end marker', async () => {
const boundary = 'DIGIEVER';
// An EXIF thumbnail's own FF D9 sits inside the image.
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0xff, 0xd9, 0x02, 0x03, 0xff, 0xd9]);
const wire = Buffer.concat([
Buffer.from('--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
jpeg,
Buffer.from('\r\n--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
]);
// Delivered one byte at a time, as a trickling upstream would.
const chunks = [...wire].map((byte) => Buffer.from([byte]));
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () =>
multipartResponse('multipart/x-mixed-replace; boundary=' + boundary, chunks),
},
);
assert.deepEqual(result?.body, jpeg);
});
+87 -3
View File
@@ -182,6 +182,86 @@ test('loader deduplicates cameras that share a camera number', async (t) => {
]);
});
test('falls back to Highway Bureau MJPEG cameras when the Freeway catalog fails', async (t) => {
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
const requests = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url, options) => {
const href = String(url);
requests.push(href);
assert.equal(options.redirect, 'manual');
if (href === TAIWAN_FREEWAY_CCTV_URL)
return new Response('', { status: 503 });
if (href === highwayUrl) {
return new Response(
cctvXml(
block({
id: 'CCTV-14-0620-009-002',
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
lat: '24.9967',
lon: '121.4521',
}),
),
{ headers: { 'content-type': 'application/xml' } },
);
}
throw new Error('Unexpected Taiwan feed URL: ' + href);
});
const sources = await loadTaiwanSources();
assert.deepEqual(requests, [TAIWAN_FREEWAY_CCTV_URL, highwayUrl]);
assert.equal(sources.length, 1);
assert.equal(sources[0].id, 'tw-thb-cctv-14-0620-009-002');
assert.equal(sources[0].cityId, 'taiwan');
assert.equal(sources[0].provider, 'Highway Bureau, MOTC');
assert.equal(sources[0].feedType, 'mjpeg');
assert.equal(sources[0].sourceKind, 'taiwan-highway-motc');
assert.equal(new URL(sources[0].url).hostname, 'cctv-ss02.thb.gov.tw');
assert.equal(new URL(sources[0].url).pathname, '/T62-9K+020');
});
test('the Highway Bureau fallback registers only approved HTTPS camera servers', async (t) => {
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url) === TAIWAN_FREEWAY_CCTV_URL)
return new Response('', { status: 503 });
if (String(url) === highwayUrl) {
return new Response(
cctvXml(
block({
id: 'CCTV-14-0620-009-001',
stream: 'https://cctv-ss09.thb.gov.tw/T62-9K+019',
}),
block({
id: 'CCTV-14-0620-009-003',
stream: 'http://cctv-ss02.thb.gov.tw/T62-9K+021',
}),
block({
id: 'CCTV-14-0620-009-004',
stream: 'https://cctv-ss02.thb.gov.tw:8443/T62-9K+022',
}),
block({
id: 'CCTV-14-0620-009-002',
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
}),
),
{ headers: { 'content-type': 'application/xml' } },
);
}
throw new Error('Unexpected Taiwan feed URL: ' + String(url));
});
const sources = await loadTaiwanSources();
assert.deepEqual(
sources.map((source) => source.id),
['tw-thb-cctv-14-0620-009-002'],
);
});
test('a redirected or failed catalog contributes an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {});
for (const status of [302, 503]) {
@@ -214,13 +294,17 @@ test('the Taiwan pack is in the catalog by default', async (t) => {
);
});
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
test('CCTV_TAIWAN_ENABLED=0 makes no Taiwan catalog request', async (t) => {
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('freeway.gov.tw')),
false,
);
assert.equal(
requested.some((href) => href.includes('cctv-maintain.thb.gov.tw')),
false,
);
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
});
@@ -250,12 +334,12 @@ test('a failed download is not retried until the backoff ends', async (t) => {
t.mock.method(Date, 'now', () => 1_000_000);
assert.deepEqual(await loadTaiwanSources(), []);
assert.deepEqual(await loadTaiwanSources(), []);
assert.equal(calls, 1, 'the second refresh made no request');
assert.equal(calls, 2, 'the second refresh made no request');
Date.now.mock.mockImplementation(
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
);
await loadTaiwanSources();
assert.equal(calls, 2, 'the backoff ends after an hour');
assert.equal(calls, 4, 'the backoff ends after an hour');
});
test('a failed download serves the last good table', async (t) => {
+3 -3
View File
@@ -10,7 +10,7 @@ import {
TORONTO_RESCU_LIST_URL,
TORONTO_RESCU_IMAGE_ORIGIN,
TORONTO_MAX_CATALOG_BYTES,
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
@@ -418,6 +418,6 @@ test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t)
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
});
+316
View File
@@ -0,0 +1,316 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
DEFAULT_WORLD_CCTV_SOURCE_FILE,
WORLD_CCTV_SOURCE_COUNT,
} from '../../server/providers/cctv/constants.js';
import {
isMlitRiverAdjustmentImage,
loadWorldCameraSources,
} from '../../server/providers/cctv/worldCameras.js';
import {
loadQuebecSources,
QUEBEC_CAMERA_SOURCE_COUNT,
quebec511VideoUrl,
quebecBrowserVideoEnabled,
} from '../../server/providers/cctv/quebec.js';
import { cctvProxy } from '../../server/providers/cctv.js';
const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0xff, 0xd9]);
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** A temporary source root holding the given World and Québec catalogs. */
function sourceRoot(t, { world = [], quebec = null } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const worldFile = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
fs.mkdirSync(path.dirname(worldFile), { recursive: true });
fs.writeFileSync(worldFile, JSON.stringify(world));
if (quebec) {
fs.mkdirSync(path.join(root, 'public'), { recursive: true });
fs.writeFileSync(
path.join(root, 'public/quebec-cameras.json'),
JSON.stringify({ cameras: quebec }),
);
}
return root;
}
const oregonRow = (overrides = {}) => ({
id: 'world-or-764',
name: '3rd St at Harwood MP18.35',
provider: 'TripCheck (ODOT)',
lat: 44.3029,
lon: -120.8549,
sourceKind: 'tripcheck-odot',
url: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg',
snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg',
...overrides,
});
test('the shipped World catalog loads every curated row', (t) => {
quiet(t);
const sources = loadWorldCameraSources({ sourceRoot: REPO_ROOT });
assert.equal(sources.length, WORLD_CCTV_SOURCE_COUNT);
const byKind = Object.groupBy(sources, (source) => source.sourceKind);
// Iceland comes from the live Iceland pack, never a second World copy.
assert.equal(byKind['vegagerdin-road'], undefined);
assert.equal(byKind['tripcheck-odot'].length, 1176);
assert.equal(byKind['mlit-river'].length, 1054);
for (const source of byKind['mlit-river'])
assert.equal(source.frameRefreshMs, 10 * 60 * 1000);
});
test('the World loader accepts a pinned row and rejects anything outside its policy', (t) => {
quiet(t);
const rejected = [
['unknown kind', { sourceKind: 'nzta-journeys' }],
[
'Iceland duplicates the Iceland pack',
{
sourceKind: 'vegagerdin-road',
lat: 64.291877,
lon: -15.028035,
url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
snapshotUrl: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
},
],
['wrong id prefix', { id: 'world-jp-river-7075' }],
['outside bounds', { lat: 40, lon: -15 }],
['other host', { url: 'https://evil.test/roadcams/cams/a.jpg' }],
['other directory', { url: 'https://www.tripcheck.com/other/a.jpg' }],
['query string', { url: oregonRow().url + '?x=1' }],
['not a jpg', { url: 'https://www.tripcheck.com/roadcams/cams/a.png' }],
['plain http', { url: oregonRow().url.replace('https:', 'http:') }],
[
'url and snapshot differ',
{ snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/b.jpg' },
],
];
const root = sourceRoot(t, {
world: [
oregonRow(),
oregonRow(), // duplicate id
...rejected.map(([, overrides], i) =>
oregonRow({ id: `world-or-${9000 + i}`, ...overrides }),
),
],
});
const sources = loadWorldCameraSources({ sourceRoot: root });
assert.deepEqual(
sources.map((source) => source.id),
['world-or-764'],
);
assert.equal(sources[0].frameRefreshMs, 5 * 60 * 1000);
assert.equal(sources[0].feedType, 'image');
});
test('the World loader fails soft on a missing or malformed catalog', (t) => {
quiet(t);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
const file = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, '{not json');
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
});
test('only the exact MLIT adjustment image is recognised', () => {
assert.equal(isMlitRiverAdjustmentImage({ ok: true, body: JPEG }), false);
assert.equal(isMlitRiverAdjustmentImage(null), false);
assert.equal(isMlitRiverAdjustmentImage({ ok: false, body: JPEG }), false);
});
test('the shipped Québec catalog loads every location', (t) => {
quiet(t);
const sources = loadQuebecSources({ sourceRoot: REPO_ROOT });
assert.equal(sources.length, QUEBEC_CAMERA_SOURCE_COUNT);
for (const source of sources) {
assert.equal(source.url, '', 'the server never fetches Québec media');
assert.equal(source.snapshotUrl, '');
}
});
test('the Québec loader maps ids by kind, keeps https viewer links, and splits the MTQ credit', (t) => {
quiet(t);
const at = { lat: 45.51651, lon: -73.5502 };
const root = sourceRoot(t, {
quebec: [
{ id: '3735', kind: 'traffic', name: 'Route 136', viewerUrl: 'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735', ...at },
{ id: 'mtl-465', kind: 'montreal', name: 'Montréal 465', viewerUrl: 'http://ville.montreal.qc.ca/x', ...at },
{ id: 'web-1', kind: 'webcam', name: 'Tremblant', operator: 'Station Mont-Tremblant', viewerUrl: 'https://user:[email protected]/', ...at },
{ id: 'abc', kind: 'traffic', ...at }, // non-numeric traffic id
{ id: '3735', kind: 'traffic', ...at }, // duplicate
{ id: '9', kind: 'traffic', lat: 40, lon: -73.5 }, // outside Québec
],
});
const sources = loadQuebecSources({ sourceRoot: root });
const byId = new Map(sources.map((source) => [source.id, source]));
assert.deepEqual([...byId.keys()], ['qc-511-3735', 'qc-mtl-465', 'qc-web-1']);
const mtq = byId.get('qc-511-3735');
assert.equal(mtq.sourceKind, 'quebec-511-browser');
assert.match(mtq.credit, /Locations: MTQ · CC BY 4\.0/);
assert.match(mtq.credit, /Video: Québec 511/);
assert.doesNotMatch(mtq.license, /^Données Québec \/ MTQ, CC BY 4\.0$/);
assert.equal(byId.get('qc-mtl-465').sourceUrl, '', 'http viewer links are dropped');
assert.equal(byId.get('qc-web-1').sourceUrl, '', 'credentialed links are dropped');
assert.equal(
quebec511VideoUrl('qc-511-3735'),
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
);
assert.equal(quebec511VideoUrl('qc-mtl-465'), '');
});
test('Québec 511 browser video is opt-in', () => {
assert.equal(quebecBrowserVideoEnabled({}), false);
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: '0' }), false);
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: ' 1 ' }), true);
});
/** Mount the real proxy with a fixed fetch, and drive requests through it. */
function mount(t, root, respond) {
withEnv(t, {
CCTV_SOURCES_FILE: 'absent-source-file.json',
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: '0',
CCTV_MAX_SOURCES: undefined,
// A configured key makes any Street View fallback observable.
GOOGLE_MAPS_SERVER_API_KEY: 'test-google-key',
});
const requests = [];
t.mock.method(globalThis, 'fetch', async (url, init = {}) => {
requests.push(String(url));
return respond(String(url), init) || new Response('down', { status: 503 });
});
let handler;
cctvProxy({ sourceRoot: root }).configureServer({
middlewares: {
use(_route, fn) {
handler = fn;
},
},
});
const call = async (url) => {
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
on() {},
once() {},
};
await handler({ url, method: 'GET', headers: {}, on() {} }, res);
return res;
};
return { call, requests };
}
test('/sources lists Québec browser media only when the operator opts in', async (t) => {
quiet(t);
const root = sourceRoot(t, {
quebec: [{ id: '3735', kind: 'traffic', name: 'Route 136', lat: 45.5, lon: -73.5 }],
});
withEnv(t, { CCTV_QUEBEC_VIDEO_ENABLED: undefined });
const app = mount(t, root, () => null);
const off = JSON.parse((await app.call('/sources')).body).sources;
assert.equal(off.find((s) => s.id === 'qc-511-3735').browserMediaUrl, '');
process.env.CCTV_QUEBEC_VIDEO_ENABLED = '1';
const on = JSON.parse((await app.call('/sources')).body).sources;
assert.match(on.find((s) => s.id === 'qc-511-3735').browserMediaUrl, /camera\.ashx\?id=3735&format=mp4$/);
});
test('a World frame is fetched once per refresh floor and the cooldown never spends Street View', async (t) => {
quiet(t);
const root = sourceRoot(t, { world: [oregonRow()] });
let answer = () => new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
const app = mount(t, root, (url) =>
url === oregonRow().url ? answer() : null,
);
const frame = '/frame/world-or-764';
const upstreamHits = () =>
app.requests.filter((url) => url === oregonRow().url).length;
const first = await app.call(frame);
assert.equal(first.headers['X-CCTV-Source'], 'upstream-image');
const second = await app.call(frame);
assert.equal(second.headers['X-CCTV-Source'], 'upstream-image');
assert.equal(upstreamHits(), 1, 'the second view reuses the cached frame');
// A camera whose first fetch fails is inside its failure backoff: it gets
// the placeholder, not Google Street View, and no second upstream request.
const failRoot = sourceRoot(t, { world: [oregonRow()] });
answer = () => new Response('down', { status: 503 });
const failing = mount(t, failRoot, (url) =>
url === oregonRow().url ? answer() : null,
);
const failed = await failing.call(frame);
assert.notEqual(failed.headers['X-CCTV-Source'], 'upstream-image');
const retry = await failing.call(frame);
assert.equal(retry.headers['X-CCTV-Source'], 'synthetic');
assert.equal(
failing.requests.filter((url) => url === oregonRow().url).length,
1,
'the backoff holds the provider cadence',
);
assert.equal(
failing.requests.filter((url) => url.includes('maps.googleapis.com/maps/api/streetview')).length,
1,
'only the first miss, outside any cooldown, may try Street View',
);
});
test('Ottawa frames are signed with the certificate at fetch time only', async (t) => {
quiet(t);
const root = sourceRoot(t);
withEnv(t, { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' });
const app = mount(t, root, (url) => {
if (url === 'https://traffic.ottawa.ca/map/service/camera')
return Response.json({
cameras: [
{ camera_number: 16, name: 'Bank & Hunt Club', latitude: 45.35, longitude: -75.65, cameraOwner: 'CITY' },
],
});
if (url.startsWith('https://traffic.ottawa.ca/opendata/camera'))
return new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
return null;
});
const sources = JSON.parse((await app.call('/sources')).body).sources;
const ottawa = sources.find((source) => source.id === 'ottawa-16');
assert.ok(ottawa);
assert.doesNotMatch(JSON.stringify(sources), /CERT123/, 'no catalog field carries the certificate');
const frame = await app.call('/frame/ottawa-16');
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
await app.call('/frame/ottawa-16');
const hits = app.requests.filter((url) => url.includes('/opendata/camera'));
assert.deepEqual(hits, [
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
], 'one signed request per camera per minute');
});
+123 -5
View File
@@ -34,6 +34,14 @@ export const OPENMAPTILES_CREDIT = {
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
};
/** Conditional Salish Sea coverage; registered when its sightings first render. */
export const ACARTIA_CREDIT = Object.freeze({
key: 'acartia',
html:
'Wildlife sightings: <a href="https://acartia.io" target="_blank" rel="noopener">Acartia</a> (CC BY 4.0; each provider credited per sighting) · ' +
'<a href="https://www.bewhalewise.org" target="_blank" rel="noopener">Be Whale Wise</a>',
});
const osmDisplays = new WeakMap();
function updateOsmDisplay(viewer, state) {
@@ -135,10 +143,23 @@ export const DATA_CREDITS = [
{
key: 'celestrak',
html:
'Satellites (TLEs): CelesTrak ' +
'Satellites (TLEs and SATCAT): CelesTrak ' +
'(<a href="https://celestrak.org" target="_blank" rel="noopener">celestrak.org</a>), ' +
'Dr. T.S. Kelso',
},
{
key: 'ucs-satellite-database',
html:
'Satellite operators and purposes: ' +
'<a href="https://www.ucsusa.org/satellite_database" target="_blank" rel="noopener">UCS Satellite Database</a> ' +
'(UCS-Satellite-Database 5-1-2023), Union of Concerned Scientists',
},
{
key: 'imaging-sensors',
html:
'Imaging sensor swaths: operator and agency specifications, ' +
'cited per platform',
},
{
key: 'launch-library-2',
html:
@@ -181,6 +202,17 @@ export const DATA_CREDITS = [
' · Incident information: ' +
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
},
{
key: 'faa-sua',
html:
'Prohibited & restricted airspace: ' +
'<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,
{
@@ -280,6 +312,38 @@ export const DATA_CREDITS = [
'CCTV static data (MOTC 2.0), released under the ' +
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
},
{
key: 'ottawa-cctv',
html:
'Traffic cameras (Ottawa, with an access certificate): ' +
'<a href="https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data" target="_blank" rel="noopener">City of Ottawa Traffic Camera Images API</a>',
},
{
key: 'quebec-cctv',
html:
'Camera locations (Québec): Ministère des Transports et de la Mobilité durable via ' +
'<a href="https://www.donneesquebec.ca/" target="_blank" rel="noopener">Données Québec</a> ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
'Montréal camera locations © OpenStreetMap contributors (ODbL). ' +
'Video (when enabled) and linked webcam pages stay with ' +
'<a href="https://www.quebec511.info/" target="_blank" rel="noopener">Québec 511</a> and each operator, under their own terms',
},
{
key: 'tripcheck-cctv',
html:
'Road camera stills (Oregon): camera courtesy of ODOT, via ' +
'<a href="https://www.tripcheck.com/" target="_blank" rel="noopener">TripCheck</a>; ' +
'partner cameras (counties, cities, Caltrans, WSDOT) are named in the camera label',
},
{
key: 'mlit-river-cctv',
html:
'River camera stills (Japan): 出典:国土交通省 ' +
'<a href="https://www.river.go.jp/" target="_blank" rel="noopener">川の防災情報</a> ' +
'(Ministry of Land, Infrastructure, Transport and Tourism; ' +
'<a href="https://www.river.go.jp/kawabou/kwb_apend/html/caution.html" target="_blank" rel="noopener">terms</a>), ' +
'fetched on view, at most every ten minutes per camera',
},
{
key: 'calgary-cctv',
html:
@@ -302,6 +366,15 @@ export const DATA_CREDITS = [
'(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' +
'(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel',
},
{
key: 'iceland-cctv',
html:
'CCTV cameras &amp; frames (Iceland): road camera images, fetched live, from the ' +
'<a href="https://www.vegagerdin.is/" target="_blank" rel="noopener">Icelandic Road and Coastal Administration (Vegagerðin)</a> — ' +
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a> ' +
'(<a href="https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions" target="_blank" rel="noopener">terms</a>); ' +
'no endorsement by IRCA is implied',
},
{
key: 'kingcounty-cctv',
html:
@@ -311,6 +384,14 @@ export const DATA_CREDITS = [
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
},
{
key: 'houston-cctv',
html:
'CCTV cameras &amp; frames (Houston, when enabled): ' +
'<a href="https://traffic.houstontranstar.org/cctv/transtar/" target="_blank" rel="noopener">Houston TranStar</a>. ' +
"Copying, distributing or framing its images needs TxDOT's written permission under the " +
'<a href="https://traffic.houstontranstar.org/disclaimer.aspx" target="_blank" rel="noopener">TranStar Content Usage terms</a>',
},
{
key: 'hk-cctv',
html:
@@ -318,6 +399,14 @@ export const DATA_CREDITS = [
'<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' +
'(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)',
},
{
key: 'webcamromania-cctv',
html:
'Giroc camera (when configured): ' +
'<a href="https://webcamromania.ro/webcam-orase/webcam-giroc/" target="_blank" rel="noopener">Webcam Romania</a>. ' +
'Video rights remain with the camera operator; ' +
'<a href="https://webcamromania.ro/termeni-si-conditii/" target="_blank" rel="noopener">provider terms</a> apply.',
},
{
key: 'vegvesen-cctv',
html:
@@ -572,23 +661,52 @@ export const WOF_CREDIT = {
'<a href="https://github.com/bilawalsidhu/gods-eye-view/blob/main/src/data/local_data/wof_neighborhoods/ATTRIBUTION.md" target="_blank" rel="noopener">sources and licences</a>)',
};
/** Registered when a voice place search (a configured Overpass) first shows places. */
export const OSM_PLACES_CREDIT = {
key: 'osm-places',
html:
'Places found by voice: ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0)',
};
/**
* Per-feed transit credit, registered the first time that feed's vehicles
* render (see `src/data/transitFeeds.js` for the license of each).
* @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed
*
* Two optional registry fields serve licences that ask for more than a name:
* `sourceUrl` links the attribution to the publisher (Warsaw asks for a link
* to its API), and `creditDated` adds the UTC date of the data, from the feed
* header or else the fetch time (Warsaw and Poznań ask for the download or
* update date). A dated credit is keyed by its date, so a new day registers a
* new line instead of keeping yesterday's.
* @param {{ id: string, attribution: string, license: string, licenseUrl: string,
* sourceUrl?: string, creditDated?: boolean }} feed
* @param {{ feedTimestamp?: number|null, fetchedAt?: number|null }} [snapshot]
* `feedTimestamp` in epoch seconds, `fetchedAt` in epoch ms.
* @returns {{ key: string, html: string }}
*/
export function transitFeedCredit(feed) {
export function transitFeedCredit(feed, snapshot = null) {
const escape = (text) =>
String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
const source = feed.sourceUrl
? `<a href="${escape(feed.sourceUrl)}" target="_blank" rel="noopener">${escape(feed.attribution)}</a>`
: escape(feed.attribution);
const dataMs = Number.isFinite(snapshot?.feedTimestamp)
? snapshot.feedTimestamp * 1000
: snapshot?.fetchedAt;
const date =
feed.creditDated === true && Number.isFinite(dataMs) && dataMs > 0
? new Date(dataMs).toISOString().slice(0, 10)
: null;
return {
key: `transit-${feed.id}`,
key: date ? `transit-${feed.id}-${date}` : `transit-${feed.id}`,
html:
`Transit (${escape(feed.attribution)}): ` +
`Transit (${source}${date ? `, data of ${date}` : ''}): ` +
`<a href="${escape(feed.licenseUrl)}" target="_blank" rel="noopener">${escape(feed.license)}</a>`,
};
}
+76 -1
View File
@@ -1,6 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
import {
DATA_CREDITS,
NYC_DOT_DISCLAIMER,
transitFeedCredit,
} from './dataCredits.js';
import { getTransitFeed } from './transitFeeds.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
@@ -38,6 +43,29 @@ test('adsbdb is credited and carries its published route-data restriction', () =
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
});
test('the UCS Satellite Database is acknowledged with its version and webpage', () => {
// UCS database manual, section 3: references name the version (the Excel
// file's name) and link www.ucsusa.org/satellite_database.
const credit = DATA_CREDITS.find(
(entry) => entry.key === 'ucs-satellite-database',
);
assert.ok(credit, 'the satellite classes and operators come from UCS');
assert.match(credit.html, /UCS-Satellite-Database 5-1-2023/);
assert.match(
credit.html,
/href="https:\/\/www\.ucsusa\.org\/satellite_database"/,
);
});
test('the imaging sensor swaths credit operator and agency specifications', () => {
// Each sensor-pack entry cites its operator's or agency's own pages;
// no aggregator database is credited.
const credit = DATA_CREDITS.find((entry) => entry.key === 'imaging-sensors');
assert.ok(credit, 'the orbital zoom caps come from the sensor pack');
assert.match(credit.html, /operator and agency specifications/);
assert.doesNotMatch(credit.html, /OSCAR|wmo\.int/);
});
test('OpenStreetMap has one generic data credit with separate tile and names distributors', () => {
const osm = DATA_CREDITS.filter((entry) =>
entry.html.includes('openstreetmap.org/copyright'),
@@ -129,6 +157,18 @@ test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
assert.match(credit.html, /observe posted signage/);
});
test("the Houston camera credit names TranStar's terms and the permission they require", () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'houston-cctv');
assert.ok(credit, 'Houston TranStar camera frames must be credited');
assert.match(credit.html, /\(Houston, when enabled\)/);
assert.ok(
credit.html.includes(
'href="https://traffic.houstontranstar.org/disclaimer.aspx"',
),
);
assert.match(credit.html, /TxDOT's written permission/);
});
test('every Street Level imagery source is in the attribution lightbox', () => {
for (const key of [
'mapillary',
@@ -141,3 +181,38 @@ test('every Street Level imagery source is in the attribution lightbox', () => {
`${key} has no credit`,
);
});
test('the PR #1506 camera packs are credited with their sources and terms', () => {
const html = (key) => DATA_CREDITS.find((entry) => entry.key === key)?.html;
assert.match(html('ottawa-cctv') || '', /traffic\.ottawa\.ca/);
assert.match(html('quebec-cctv') || '', /CC BY 4\.0/);
assert.match(html('quebec-cctv') || '', /OpenStreetMap contributors/);
assert.match(html('tripcheck-cctv') || '', /courtesy of ODOT/);
// MLIT's terms ask for the source in this form, with the page link.
assert.match(html('mlit-river-cctv') || '', /出典:国土交通省/);
assert.match(
html('mlit-river-cctv') || '',
/href="https:\/\/www\.river\.go\.jp\//,
);
});
test('Warsaw and Poznań credits carry the data date their licences ask for', () => {
const warsaw = getTransitFeed('ztm-warszawa');
// 2026-11-09T06:00:00Z as a GTFS-RT header timestamp (seconds).
const credit = transitFeedCredit(warsaw, { feedTimestamp: 1_794_204_000 });
assert.match(credit.html, /, data of 2026-11-09\)/);
assert.match(credit.html, /href="http:\/\/api\.um\.warszawa\.pl"/);
assert.equal(credit.key, 'transit-ztm-warszawa-2026-11-09');
// Without a feed timestamp the fetch time is the download date.
const poznan = transitFeedCredit(getTransitFeed('ztm-poznan'), {
feedTimestamp: null,
fetchedAt: Date.UTC(2026, 10, 10, 12),
});
assert.match(poznan.html, /data of 2026-11-10/);
// Undated feeds keep one credit line for the session.
const mbta = transitFeedCredit(getTransitFeed('mbta'), {
feedTimestamp: 1_794_204_000,
});
assert.equal(mbta.key, 'transit-mbta');
assert.doesNotMatch(mbta.html, /data of/);
});
+45
View File
@@ -53,6 +53,12 @@ import {
isCyberContactSonarActive as isCyberSonarActive,
isCyberContactThemeActive as isCyberMapThemeActive,
} from '../cyberSonar.js';
import {
awarenessEchoActive,
awarenessEchoBracket,
awarenessEchoColor,
getAwarenessEchoField,
} from '../awarenessEcho.js';
/**
* @module detection
@@ -103,6 +109,8 @@ const SPARSE_ZONE_FRACTION = 0.5;
const LABEL_SOLVE_INTERVAL_MS = 125;
/** Alpha bands retain bracket batching while approximating a continuous radial fade. */
const BRACKET_ALPHA_STEPS = 4;
/** Finer bands for Global Context echo brackets, whose fade is animated. */
const ECHO_ALPHA_STEPS = 8;
/** Stable per-layer candidate safety cap; independent of density and camera bearing. */
const LAYER_CANDIDATE_CAP = 2600;
const LAYER_WEIGHTS = Object.freeze({
@@ -1335,6 +1343,11 @@ function _drawOverlay(frame) {
const tiers = _theme.tiers || null;
const colorFor = (key) => (tiers && tiers[key]) || _theme.line;
const bracketPaths = new Map();
// Global Context sonar echoes: a second bracket, banded by strength, around
// each flight or vessel a pulse front has just crossed.
// Resting pulses light nothing, so the per-contact echo work is skipped.
const echoField = awarenessEchoActive(now) ? getAwarenessEchoField() : null;
const echoPaths = echoField ? new Array(ECHO_ALPHA_STEPS + 1) : null;
const pathFor = (map, color, alpha) => {
const band = Math.max(
1,
@@ -1437,6 +1450,24 @@ function _drawOverlay(frame) {
halfH,
);
visibleCount++;
if (echoPaths) {
// Starts on the contact's bracket and spreads outward as it fades.
const echo = awarenessEchoBracket(
obj._layerId,
obj.position,
now,
bracketAlpha,
ECHO_ALPHA_STEPS,
);
if (echo)
appendCornerBracket(
(echoPaths[echo.band] ??= new Path2D()),
sx,
sy,
halfW + echo.spreadPx,
halfH + echo.spreadPx,
);
}
if (obj.type === 'AIR')
aircraftBracketSectors[detectionHorizontalSector(sx, width)]++;
if (bracketAlpha >= 1) bracketOpacityCounts.full++;
@@ -1576,6 +1607,20 @@ function _drawOverlay(frame) {
_ctx.stroke(entry.path);
}
}
if (echoPaths) {
_ctx.lineWidth = echoField.echo.lineWidth;
_ctx.strokeStyle = awarenessEchoColor(
_theme.line,
_theme === THEME_MAP._default,
);
for (let band = 1; band <= ECHO_ALPHA_STEPS; band++) {
if (!echoPaths[band]) continue;
_ctx.globalAlpha =
fade * (band / ECHO_ALPHA_STEPS) * bracketPresentationOpacity;
_ctx.stroke(echoPaths[band]);
}
_ctx.lineWidth = bracketWidth;
}
const renderEntries = _labelArbiter.renderEntries(candidateMap, now);
const fadingCount = countFadingRenderEntries(renderEntries);
+107
View File
@@ -0,0 +1,107 @@
// src/data/detectionAwarenessEcho.test.mjs — Global Context sonar echoes pulse
// a contact's own detection bracket: a second bracket that starts on it and
// spreads outward as it fades, in the detection style's own colour.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
awarenessEchoBracket,
awarenessEchoColor,
clearAwarenessEchoField,
setAwarenessEchoField,
} from '../awarenessEcho.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
const STEPS = 8;
const pulse = { count: 3, periodMs: 9000, startFraction: 0.02 };
const echo = {
fadeMs: 1400,
layerIds: ['flights', 'military', 'ais-live-vessels'],
spreadPx: 8,
color: 'rgb(98, 181, 255)',
};
const center = Cesium.Cartesian3.fromDegrees(3, 27, 0);
const field = { center, radiusM: 250_000, pulse, echo };
/** The moment pulse 0's front reaches the ground under `position`. */
function arrivalAt(position) {
const ground = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface(position);
const d = Cesium.Cartesian3.distance(center, ground);
return (
((d / 250_000 - pulse.startFraction) / (1 - pulse.startFraction)) *
pulse.periodMs +
pulse.periodMs * 3
);
}
test('the echo bracket pulses outward from the contact bracket and fades', (t) => {
t.after(clearAwarenessEchoField);
setAwarenessEchoField(field);
const position = Cesium.Cartesian3.fromDegrees(3.5, 27, 10_000);
const arrival = arrivalAt(position);
const at = (nowMs, bracketAlpha = 1) =>
awarenessEchoBracket('flights', position, nowMs, bracketAlpha, STEPS);
// The moment the front arrives: on the bracket, strongest band.
const fresh = at(arrival);
assert.ok(fresh);
assert.ok(Math.abs(fresh.spreadPx) < 0.01, `${fresh.spreadPx}`);
assert.equal(fresh.band, STEPS);
// Half way through the fade: dimmer and spread outward, but not past
// the field's spread.
const half = at(arrival + echo.fadeMs / 2);
assert.ok(half.spreadPx > echo.spreadPx * 0.5);
assert.ok(half.spreadPx < echo.spreadPx);
assert.ok(half.band < STEPS && half.band >= 1);
// The bracket's own dimming (keyhole fade, Cyber sonar sweep) carries
// over, and the echo is never drawn brighter than its bracket: a bracket
// too dim for even one band draws no echo.
assert.equal(at(arrival, 0.25).band, 2);
assert.equal(at(arrival, 0.05), null);
for (let alpha = 0.05; alpha <= 1; alpha += 0.05) {
const echoed = at(arrival + echo.fadeMs / 3, alpha);
if (echoed)
assert.ok(
echoed.band / STEPS <= alpha + 0.5 / STEPS + 1e-9,
`band ${echoed.band} for a bracket at ${alpha}`,
);
}
// Faded out, or between fronts: no echo bracket.
assert.equal(at(arrival + echo.fadeMs + 5), null);
});
test('no echo field, or a layer that does not echo, draws nothing extra', (t) => {
t.after(clearAwarenessEchoField);
const position = Cesium.Cartesian3.fromDegrees(3.01, 27, 0);
clearAwarenessEchoField();
for (let now = 0; now < pulse.periodMs; now += 25)
assert.equal(
awarenessEchoBracket('flights', position, now, 1, STEPS),
null,
);
setAwarenessEchoField(field);
for (let now = 0; now < pulse.periodMs; now += 25)
for (const layerId of ['cctv', 'satellites', 'traffic'])
assert.equal(
awarenessEchoBracket(layerId, position, now, 1, STEPS),
null,
);
});
test('echoes take the detection style colour except on the default style', (t) => {
t.after(clearAwarenessEchoField);
assert.equal(
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
null,
);
setAwarenessEchoField(field);
assert.equal(
awarenessEchoColor(DETECTION_THEME_MAP._default.line, true),
echo.color,
);
// CRT, NVG and FLIR keep their monochrome palettes.
for (const style of ['retro', 'surveillance', 'thermal']) {
const { line } = DETECTION_THEME_MAP[style];
assert.equal(awarenessEchoColor(line, false), line, style);
}
});
+233 -3
View File
@@ -24,6 +24,10 @@ import {
setOverlayEntries,
} from '../overlays/worldOverlay.js';
import { DETECTION_THEME_MAP } from '../overlays/worldOverlayTokens.js';
import {
clearAwarenessEchoField,
setAwarenessEchoField,
} from '../awarenessEcho.js';
import { composeLabel, resolveTier } from './detectionDraw.js';
test('detection diagnostics count rendered fading rows instead of absent selected identities', () => {
@@ -50,8 +54,9 @@ class MockEvent {
}
class MockPath2D {
moveTo() {}
lineTo() {}
constructor() { this.points = []; }
moveTo(x, y) { this.points.push([x, y]); }
lineTo(x, y) { this.points.push([x, y]); }
roundRect() {}
arcTo() {}
closePath() {}
@@ -98,7 +103,7 @@ function mockContext(target, trace) {
// set at some point during the frame. globalAlpha rides along for the same
// reason: the backdrop feather is an alpha, not a colour.
fill(path) { record('fill', path, this.fillStyle, this.globalAlpha); },
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth); },
stroke(path) { record('stroke', path, this.strokeStyle, this.lineWidth, this.globalAlpha); },
fillRect(...args) { record('fillRect', ...args); },
fillText(...args) { record('fillText', ...args); },
drawImage(...args) { record('drawImage', ...args); },
@@ -627,6 +632,231 @@ test('surveillance military tier reaches the dedicated render target as shipped
}
});
test('Global Context echo brackets reach the canvas in the detection style colour', () => {
const env = installEnvironment();
// Fronts every millisecond with a long fade, so the contact is lit on
// whatever frame paints; a distinctive width picks the echo stroke out.
const echo = {
layerIds: ['military'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
};
const echoStrokes = () =>
env.detectionCtx.calls
.filter(([name, , , width]) => name === 'stroke' && width === echo.lineWidth)
.map(([, , style]) => style);
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo,
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [militaryLayer()], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
assert.ok(echoStrokes().length > 0, 'the echo bracket is painted');
assert.ok(echoStrokes().every((style) => style === echo.color));
// CRT, NVG and FLIR keep their own palettes.
for (const style of ['retro', 'surveillance', 'thermal']) {
env.detectionCtx.calls.length = 0;
setDetectionStyle(style);
settleFrame(env);
const strokes = echoStrokes();
assert.ok(strokes.length > 0, style);
assert.ok(
strokes.every((stroke) => stroke === DETECTION_THEME_MAP[style].line),
`${style} echoes take the style line colour, got ${strokes}`,
);
}
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('a Global Context echo bracket is drawn outside its contact bracket and dimmer than it', () => {
const env = installEnvironment();
const echo = {
layerIds: ['military'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
};
// Fronts every millisecond keep the echo at full strength; a level of 0.5
// halves it, so it sits 4 px out and draws at half the bracket's alpha.
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo,
level: () => 0.5,
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [militaryLayer()], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
env.detectionCtx.calls.length = 0;
settleFrame(env);
const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke');
const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth);
assert.ok(echoStroke, 'the echo bracket is painted');
const bracketStroke = strokes.find(([, path, style, width]) =>
path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color);
assert.ok(bracketStroke, 'the contact bracket is painted');
const extent = (path) => {
const xs = path.points.map(([x]) => x);
const ys = path.points.map(([, y]) => y);
return { halfW: (Math.max(...xs) - Math.min(...xs)) / 2, halfH: (Math.max(...ys) - Math.min(...ys)) / 2 };
};
const echoBox = extent(echoStroke[1]);
const bracketBox = extent(bracketStroke[1]);
assert.ok(Math.abs(echoBox.halfW - bracketBox.halfW - echo.spreadPx / 2) < 1e-3,
`echo ${echoBox.halfW} vs bracket ${bracketBox.halfW}`);
assert.ok(Math.abs(echoBox.halfH - bracketBox.halfH - echo.spreadPx / 2) < 1e-3);
const echoAlpha = echoStroke[4];
const bracketAlpha = bracketStroke[4];
assert.ok(echoAlpha > 0 && echoAlpha < bracketAlpha, `${echoAlpha} vs ${bracketAlpha}`);
assert.ok(Math.abs(echoAlpha - bracketAlpha / 2) < 1e-6, `${echoAlpha} vs ${bracketAlpha}`);
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('under the Cyber sonar sweep an echo is never brighter than its own bracket', () => {
const env = installEnvironment();
// The cyber theme with its HUD open turns the contact sonar on; its sweep
// dims brackets it is not passing over.
env.document.documentElement = {
// The lowest sweep floor, so dimmed brackets drop a whole alpha band.
dataset: { uiTheme: 'cyber', cyberSonarOpacity: '35' },
style: { setProperty() {}, removeProperty() {} },
};
const hud = env.document.createElement('div');
hud.id = 'intel-hud';
hud.classList = { contains: (name) => name === 'active' };
env.document.body.appendChild(hud);
const echo = {
layerIds: ['military'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
};
const contact = {
id: 'military',
getDetectableObjects: () => [{
position: new Cesium.Cartesian3(0.5, 0.3, 6_356_752),
sourceId: 'sonar-pin',
id: 'SONAR',
metric: 'FL300',
type: 'AIR',
tier: 'military',
}],
};
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo,
level: () => 1,
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [contact], () => {});
setMode('DENSE');
setDetectionStyle('normal');
settleFrame(env);
let dimmed = 0;
let compared = 0;
for (let frame = 0; frame < 40; frame++) {
env.detectionCtx.calls.length = 0;
settleFrame(env);
const strokes = env.detectionCtx.calls.filter(([name]) => name === 'stroke');
const echoStroke = strokes.find(([, , style, width]) => style === echo.color && width === echo.lineWidth);
const bracketStroke = strokes.find(([, path, style, width]) =>
path instanceof MockPath2D && path.points.length && width !== echo.lineWidth && style !== echo.color);
if (!bracketStroke) continue;
const bracketAlpha = bracketStroke[4];
if (bracketAlpha < 0.99) dimmed++;
if (!echoStroke) continue;
compared++;
assert.ok(
echoStroke[4] <= bracketAlpha + 1e-9,
`echo ${echoStroke[4]} over bracket ${bracketAlpha} on frame ${frame}`,
);
}
assert.ok(dimmed > 0, 'the sweep dimmed the bracket on some frame');
assert.ok(compared > 0, 'an echo was painted');
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('resting Global Context pulses skip echo work for the whole frame, not per contact', () => {
const env = installEnvironment();
// Six echo-eligible flights on screen; the identity camera maps x/y
// straight to device coordinates.
const flights = Array.from({ length: 6 }, (_, index) => ({
position: new Cesium.Cartesian3(-0.5 + index * 0.2, 0, 6_356_752),
sourceId: `flight-${index}`,
id: `ECHO${index}`,
metric: `FL${300 + index}`,
type: 'AIR',
}));
const layer = { id: 'flights', getDetectableObjects: () => flights };
let levelCalls = 0;
try {
setAwarenessEchoField({
center: Cesium.Cartesian3.fromDegrees(0, 89, 0),
radiusM: 250_000,
pulse: { count: 1000, periodMs: 1000, startFraction: 0 },
echo: {
layerIds: ['flights'],
fadeMs: 60_000,
color: 'rgb(1, 2, 3)',
spreadPx: 8,
lineWidth: 3.25,
},
level: () => {
levelCalls++;
return 0;
},
});
initWorldOverlay(env.viewer);
initDetection(env.viewer, [layer], () => {});
setMode('DENSE');
settleFrame(env);
levelCalls = 0;
settleFrame(env);
const painted = getDetectionDiagnostics().visibleCount;
assert.equal(painted, flights.length, 'every flight is on screen');
// One check per painted frame, however many contacts are on screen.
assert.ok(levelCalls >= 1, 'the frame asks whether echoes are live');
assert.ok(levelCalls < painted, `${levelCalls} level checks for ${painted} contacts`);
assert.ok(
!env.detectionCtx.calls.some(
([name, , style]) => name === 'stroke' && style === 'rgb(1, 2, 3)',
),
'no echoes while the pulses rest',
);
} finally {
clearAwarenessEchoField();
env.cleanup();
}
});
test('pathological detection paint holds alternate frames without freezing shared lanes', () => {
// Opts into the telemetry banner: the held-frame assertions count banner paints.
const env = installEnvironment({ search: '?detectDebug=1' });
+292
View File
@@ -0,0 +1,292 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { droneAirspaceProxy } from '../../server/providers/droneAirspace.js';
const feature = (id = 310, type = 'P') => ({
properties: {
NAME: `${type}-${id}`,
TYPE_CODE: type,
OBJECTID: id,
UPPER_DESC: 'TNI',
},
geometry: {
type: 'Polygon',
coordinates: [
[
[-77, 38],
[-76, 38],
[-76, 39],
[-77, 38],
],
],
},
});
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = droneAirspaceProxy(options);
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/drone-airspace');
handler = callback;
},
},
});
return async (url = '/', method = 'GET', peer = 'local', headers = {}) => {
const res = {
headers: {},
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
this.status ??= this.statusCode;
this.body = JSON.parse(body);
},
};
await handler(
{ url, method, headers, socket: { remoteAddress: peer } },
res,
);
return res;
};
}
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: paging follows offsets and normalizes the combined snapshot`, async () => {
const calls = [];
const request = install(
{
now: () => 1234,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.redirect, 'error');
return Response.json({
features: [feature(calls.length)],
exceededTransferLimit: calls.length < 3,
});
},
},
hook,
);
const res = await request('/?url=https://invalid.example&resultOffset=999');
assert.equal(res.status, 200);
assert.equal(res.body.fetchedAt, 1234);
assert.equal(res.body.rows.length, 3);
assert.equal(calls.length, 3);
assert.equal(calls[0].searchParams.get('where'), "TYPE_CODE IN ('P','R')");
assert.equal(calls[0].searchParams.get('f'), 'geojson');
assert.equal(calls[0].searchParams.has('resultOffset'), false);
assert.equal(calls[1].searchParams.get('resultOffset'), '1');
});
}
/**
* Serve one good snapshot, then let the upstream answer `bad` after the TTL:
* the bad answer must not replace it, so the client gets the last good rows
* marked stale, and a cold proxy answers 502 rather than caching the bad one.
*/
async function assertRejectedSnapshot(bad) {
let now = 1000;
let broken = false;
let calls = 0;
const request = install({
now: () => now,
fetchImpl: async () => {
calls += 1;
return broken ? bad(calls) : Response.json({ features: [feature(7)] });
},
});
const first = await request();
assert.equal(first.status, 200);
broken = true;
now += 3_600_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
assert.deepEqual(
stale.body.rows.map((row) => row.stableId),
['7'],
);
calls = 0;
const cold = install({ fetchImpl: async () => bad(++calls) });
const res = await cold();
assert.equal(res.status, 502);
assert.equal(res.body.error, 'drone_airspace_unavailable');
}
test('an empty upstream snapshot keeps the last good one', () =>
assertRejectedSnapshot(() => Response.json({ features: [] })));
test('a page whose every feature is invalid keeps the last good one', () =>
assertRejectedSnapshot(() =>
Response.json({
features: [
{
...feature(8),
properties: { ...feature(8).properties, TYPE_CODE: 'W' },
},
{ ...feature(9), geometry: null },
],
}),
));
test('paging that exhausts its page limit still flagged keeps the last good one', () =>
assertRejectedSnapshot((call) =>
Response.json({
features: [feature(100 + call)],
exceededTransferLimit: true,
}),
));
test('a flagged page that adds no features keeps the last good one', () =>
assertRejectedSnapshot((call) =>
Response.json(
call % 2
? { features: [feature(100 + call)], exceededTransferLimit: true }
: { features: [], properties: { exceededTransferLimit: true } },
),
));
test('a cached snapshot is reused until TTL, then refreshed', async () => {
let now = 1000;
let fetches = 0;
const request = install({
now: () => now,
fetchImpl: async () => {
fetches += 1;
return Response.json({ features: [feature(fetches)] });
},
});
const first = await request();
now += 3_599_000;
const cached = await request();
now += 2000;
const refreshed = await request();
assert.equal(first.body.rows[0].stableId, '1');
assert.equal(cached.body.rows[0].stableId, '1');
assert.equal(refreshed.body.rows[0].stableId, '2');
assert.equal(fetches, 2);
});
test('a failed refresh serves the last good snapshot as stale', async () => {
let now = 1000;
let fail = false;
const request = install({
now: () => now,
fetchImpl: async () => {
if (fail) return new Response('nope', { status: 503 });
return Response.json({ features: [feature(7)] });
},
});
const first = await request();
fail = true;
now += 3_600_001;
const stale = await request();
assert.equal(first.status, 200);
assert.equal(stale.status, 200);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.rows[0].stableId, '7');
});
test('method, unknown routes and rate limits are rejected', async () => {
const request = install({
fetchImpl: async () => Response.json({ features: [feature()] }),
});
assert.equal((await request('/', 'POST')).status, 405);
assert.equal((await request('/nope')).status, 404);
const limited = install({
fetchImpl: async () => Response.json({ features: [feature()] }),
});
let last;
for (let i = 0; i < 61; i++) last = await limited('/', 'GET', '10.0.0.9');
assert.equal(last.status, 429);
});
test('a cold upstream failure is 502, not an empty snapshot', async () => {
const request = install({
fetchImpl: async () => new Response('nope', { status: 503 }),
});
const res = await request();
assert.equal(res.status, 502);
assert.equal(res.body.error, 'drone_airspace_unavailable');
});
test('a stale snapshot is served for at most a day, then refused', async () => {
let now = 1_000_000_000;
let up = true;
const request = install({
now: () => now,
fetchImpl: async () => {
if (!up) throw new Error('FAA unreachable');
return Response.json({ features: [feature(7)] });
},
});
const fresh = await request();
assert.equal(fresh.status, 200);
up = false;
now += 23 * 60 * 60 * 1000;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
now += 2 * 60 * 60 * 1000;
assert.equal((await request()).status, 502, 'a day-old snapshot is refused');
});
test('a cross-site page is refused before it spends quota or reaches FAA', async () => {
let fetches = 0;
const request = install({
fetchImpl: async () => {
fetches += 1;
return Response.json({ features: [feature()] });
},
});
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' },
{ host, origin: 'https://evil.example' },
{ host, origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' },
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
];
// More refusals than the 60/min per-client quota, all from one peer.
for (let i = 0; i < 64; i++) {
const headers = refused[i % refused.length];
const res = await request('/', 'GET', '10.0.0.5', headers);
assert.equal(res.status, 403, JSON.stringify(headers));
}
assert.equal(fetches, 0, 'refusals cost no upstream call');
const app = await request('/', 'GET', '10.0.0.5', {
host,
origin: `http://${host}`,
'sec-fetch-site': 'same-origin',
});
assert.equal(app.status, 200, 'the app keeps its full quota');
assert.equal(fetches, 1);
});
test('the app served behind a reverse proxy still gets the zones', async () => {
const request = install({
fetchImpl: async () => Response.json({ features: [feature()] }),
});
const host = 'gev.example.com';
const res = await request('/', 'GET', '127.0.0.1', {
host,
origin: `https://${host}`,
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.9',
'x-forwarded-proto': 'https',
});
assert.equal(res.status, 200);
assert.equal(res.body.rows.length, 1);
});
+22 -6
View File
@@ -45,7 +45,7 @@ test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () =
assert.equal(card.details.length, 1);
assert.equal(card.details[0], 'high · 2h · N20');
assert.equal(card.selected, false);
assert.equal(card.accent, accentForSeverity('red'), 'FRP 1520 is red-hot');
assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot');
assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched');
});
@@ -69,11 +69,27 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
assert.equal(selected.title, 'FIRE · 10 MW');
});
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => {
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
assert.match(card.details[0], /SNPP$/);
assert.equal(card.title, '▲ 0.8 MW');
assert.notEqual(card.accent, accentForSeverity('red'));
assert.equal(card.accent, accentForSeverity('ember'));
});
test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => {
const accentAt = (frp) =>
buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent;
assert.equal(accentAt(4.9), accentForSeverity('ember'));
assert.equal(accentAt(5), accentForSeverity('red'));
assert.equal(accentAt(20), accentForSeverity('flame'));
assert.equal(accentAt(50), accentForSeverity('orange'));
assert.equal(accentAt(100), accentForSeverity('gold'));
assert.equal(accentAt(300), accentForSeverity('white-hot'));
// Confidence never moves the color: a low-confidence 864 MW fire stays red.
assert.equal(
buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent,
accentForSeverity('white-hot'),
);
});
test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => {
@@ -93,12 +109,12 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
const candidate = {
cell: { count: 14, maxFrp: 210.4, newestAcqMs: NOW - 3 * H, night: 2 },
position: { x: 0, y: 0, z: 0 },
accent: accentForSeverity('orange'),
accent: accentForSeverity('gold'),
};
const card = buildCellCard(candidate, NOW);
assert.equal(card.title, '14 FIRES');
assert.equal(card.details[0], 'max 210 MW · new 3h');
assert.equal(card.accent, accentForSeverity('orange'));
assert.equal(card.accent, accentForSeverity('gold'));
});
test('buildCellCard: the count picks the plural form of the language', () => {
@@ -150,7 +166,7 @@ test('buildCellCard: singular noun and missing-age omission', () => {
const card = buildCellCard(candidate, NOW);
assert.equal(card.title, '1 FIRE');
assert.equal(card.details[0], 'max 9.9 MW');
assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow');
assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop');
});
test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => {
+209
View File
@@ -0,0 +1,209 @@
/**
* Snap Map–style heat raster for the Active Fires far-zoom bands: detections
* are linearly binned on an equirectangular grid (row 0 = north, column
* 0 = 180°W), blurred, normalized on a log scale and mapped through
* HEAT_PALETTE. Pure: no Cesium, no DOM, so the whole pipeline is unit-tested
* in node.
*/
/** Below this normalized heat nothing is painted: empty land stays empty. */
export const HEAT_FLOOR = 0.03;
/**
* The FRP marker ramp as a continuous glow, from a red edge through orange
* and gold to a pale white-hot core. The edge is a bright red, not the
* markers' dark ember, and starts more than half opaque: most of the world
* sits low on a scale set by the hottest regions, and a dark translucent red
* vanishes into forest and savanna imagery.
*/
export const HEAT_PALETTE = Object.freeze([
{ at: 0.0, rgb: [232, 58, 30], alpha: 0 },
{ at: 0.08, rgb: [232, 58, 30], alpha: 0.58 },
{ at: 0.25, rgb: [242, 86, 34], alpha: 0.7 },
{ at: 0.45, rgb: [248, 122, 40], alpha: 0.76 },
{ at: 0.65, rgb: [252, 166, 52], alpha: 0.82 },
{ at: 0.82, rgb: [254, 208, 92], alpha: 0.87 },
{ at: 1.0, rgb: [255, 245, 190], alpha: 0.92 },
]);
export function heatColorAt(t) {
if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0];
const v = Math.min(1, t);
let i = 1;
while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1;
const a = HEAT_PALETTE[i - 1];
const b = HEAT_PALETTE[i];
const k = (v - a.at) / (b.at - a.at || 1);
const mix = (p, q) => Math.round(p + (q - p) * k);
return [
mix(a.rgb[0], b.rgb[0]),
mix(a.rgb[1], b.rgb[1]),
mix(a.rgb[2], b.rgb[2]),
Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255),
];
}
/** Weight of a detection with no or zero FRP, in megawatts. */
export const HEAT_MIN_WEIGHT_MW = 0.5;
/**
* Heat is radiated power, not a head count: each detection adds its FRP in
* megawatts. Weak fires carry little power, so raising the minimum-FRP slider
* removes most detections without dimming the areas where strong fires burn
* (a count-based heat collapsed there and the hot spots faded).
*/
const defaultWeight = (fire) =>
Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0);
/**
* Linear binning (Wand 1994): each detection's weight is shared between the
* four pixel centres around it in proportion to closeness, instead of
* dropping whole into one pixel. Spots then sit at the true position rather
* than snapping by up to half a pixel (~10 km), and a lone weak fire blurs
* into a round spot instead of a square or a plus sign. Longitude wraps;
* rows beyond the poles fold back onto the edge row.
*/
export function binFires(fires, { width, height, weight = defaultWeight }) {
const grid = new Float32Array(width * height);
const row = (y) => Math.min(height - 1, Math.max(0, y)) * width;
const col = (x) => ((x % width) + width) % width;
for (const fire of fires) {
if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue;
const fx = ((fire.lon + 180) / 360) * width - 0.5;
const fy = ((90 - fire.lat) / 180) * height - 0.5;
const x0 = Math.floor(fx);
const y0 = Math.floor(fy);
const tx = fx - x0;
const ty = fy - y0;
const w = weight(fire);
const top = row(y0);
const bottom = row(y0 + 1);
const left = col(x0);
const right = col(x0 + 1);
grid[top + left] += w * (1 - tx) * (1 - ty);
grid[top + right] += w * tx * (1 - ty);
grid[bottom + left] += w * (1 - tx) * ty;
grid[bottom + right] += w * tx * ty;
}
return grid;
}
function kernel(sigma) {
const radius = Math.max(1, Math.ceil(sigma * 3));
const k = new Float32Array(radius * 2 + 1);
let sum = 0;
for (let i = -radius; i <= radius; i++) {
k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma));
sum += k[i + radius];
}
for (let i = 0; i < k.length; i++) k[i] /= sum;
return { k, radius };
}
export function blurGaussian(grid, width, height, sigma) {
const { k, radius } = kernel(sigma);
const tmp = new Float32Array(grid.length);
const out = new Float32Array(grid.length);
for (let y = 0; y < height; y++) {
const row = y * width;
for (let x = 0; x < width; x++) {
const v = grid[row + x];
if (v === 0) continue;
for (let i = -radius; i <= radius; i++) {
const xx = (((x + i) % width) + width) % width; // longitude wraps
tmp[row + xx] += v * k[i + radius];
}
}
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const v = tmp[y * width + x];
if (v === 0) continue;
for (let i = -radius; i <= radius; i++) {
const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp
out[yy * width + x] += v * k[i + radius];
}
}
}
return out;
}
/** Bins of the log-density histogram used to find the reference quantile. */
const QUANTILE_BINS = 4096;
/**
* The p-quantile of the non-zero values, from a histogram of log1p(value):
* one linear pass instead of sorting ~350k burning pixels (a sort cost
* 200 ms+ and dominated the build). Bin width on the log axis is
* max/4096, far finer than the 256-step palette it feeds.
*/
function percentileOfNonZero(values, p) {
let count = 0;
let max = 0;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v > 0) {
count += 1;
if (v > max) max = v;
}
}
if (!count) return 0;
const logMax = Math.log1p(max);
const bins = new Uint32Array(QUANTILE_BINS);
const toBin = (QUANTILE_BINS - 1) / logMax;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1;
}
const target = Math.floor(p * (count - 1));
let seen = 0;
for (let b = 0; b < QUANTILE_BINS; b++) {
seen += bins[b];
if (seen > target) return Math.expm1((b + 1) / toBin);
}
return max;
}
/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */
const LUT_STEPS = 256;
let paletteLut = null;
function heatLut() {
if (!paletteLut) {
paletteLut = new Uint8ClampedArray(LUT_STEPS * 4);
for (let i = 0; i < LUT_STEPS; i++) {
paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4);
}
}
return paletteLut;
}
export function buildHeatRaster(
fires,
{ width, height, sigmaPx, percentile = 0.995 },
) {
const density = blurGaussian(
binFires(fires, { width, height }),
width,
height,
sigmaPx,
);
const reference = percentileOfNonZero(density, percentile);
const rgba = new Uint8ClampedArray(width * height * 4);
if (reference > 0) {
const lut = heatLut();
const scale = (LUT_STEPS - 1) / Math.log1p(reference);
for (let i = 0; i < density.length; i++) {
const d = density[i];
if (d <= 0) continue;
const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale));
const l = step * 4;
if (lut[l + 3] === 0) continue;
const o = i * 4;
rgba[o] = lut[l];
rgba[o + 1] = lut[l + 1];
rgba[o + 2] = lut[l + 2];
rgba[o + 3] = lut[l + 3];
}
}
return { width, height, rgba, reference };
}
+118
View File
@@ -0,0 +1,118 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
HEAT_MIN_WEIGHT_MW,
HEAT_PALETTE,
heatColorAt,
binFires,
blurGaussian,
buildHeatRaster,
} from './firmsHeatRaster.js';
const fire = (lat, lon, frp = 10) => ({ lat, lon, frp });
test('palette runs ember to white-hot and starts fully transparent', () => {
assert.equal(HEAT_PALETTE[0].at, 0);
assert.equal(HEAT_PALETTE.at(-1).at, 1);
assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]);
assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted');
const low = heatColorAt(0.15);
const high = heatColorAt(1);
assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is red');
assert.ok(low[3] >= 0.5 * 255, 'low heat stays visible on any imagery');
assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler');
assert.ok(high[3] > low[3], 'hotter is more opaque');
for (let i = 1; i <= 100; i++) {
const [r, , b] = heatColorAt(i / 100);
assert.ok(r >= b, `no cold hue at ${i}%`);
}
});
test('binFires puts a detection at a pixel centre wholly in that pixel', () => {
// 4×2 grid: pixel centres at lon −135/−45/45/135, lat ±45.
const grid = binFires([fire(45, -135, 1), fire(-45, 135, 1)], {
width: 4,
height: 2,
});
assert.equal(grid[0], 1, 'north-west pixel');
assert.equal(grid[7], 1, 'south-east pixel');
assert.equal(
grid.reduce((a, b) => a + b, 0),
2,
);
});
test('binFires shares a detection between the pixels around it', () => {
const between = binFires([fire(45, 0, 1)], { width: 4, height: 2 });
assert.equal(between[1], 0.5, 'half to the pixel west of it');
assert.equal(between[2], 0.5, 'half to the pixel east of it');
const quarter = binFires([fire(45, -112.5, 1)], { width: 4, height: 2 });
assert.equal(quarter[0], 0.75, 'closer centre takes more');
assert.equal(quarter[1], 0.25);
const wrapped = binFires([fire(45, 180, 1)], { width: 4, height: 2 });
assert.equal(wrapped[3], 0.5, 'the antimeridian wraps');
assert.equal(wrapped[0], 0.5);
const polar = binFires([fire(90, -135, 1)], { width: 4, height: 2 });
assert.equal(polar[0], 1, 'beyond the last row folds back onto it');
});
test('binFires weights each detection by its radiative power', () => {
const grid = binFires(
[fire(45, 45, 250), fire(45, 45, 0), fire(45, 45, Number.NaN)],
{ width: 4, height: 2 },
);
assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW);
});
test('filtering out weak fires keeps a strong fire hot', () => {
const strong = fire(0.1, 0.1, 300);
const weak = Array.from({ length: 40 }, () => fire(0.1, 0.1, 2));
const reference = Array.from({ length: 10 }, () => fire(20.1, 20.1, 100));
const options = { width: 360, height: 180, sigmaPx: 2 };
const pixel = ((90 - 0.1) | 0) * 360 + ((0.1 + 180) | 0);
const red = (r) => r.rgba[pixel * 4];
const alpha = (r) => r.rgba[pixel * 4 + 3];
const unfiltered = buildHeatRaster([strong, ...weak, ...reference], options);
const filtered = buildHeatRaster([strong, ...reference], options);
assert.ok(alpha(filtered) > 0);
assert.ok(
alpha(filtered) >= alpha(unfiltered) * 0.9,
`alpha ${alpha(unfiltered)} -> ${alpha(filtered)}`,
);
assert.ok(
red(filtered) >= red(unfiltered) * 0.9,
`red ${red(unfiltered)} -> ${red(filtered)}`,
);
});
test('blurGaussian keeps mass and wraps across the antimeridian', () => {
const w = 16;
const h = 8;
const grid = new Float32Array(w * h);
grid[3 * w + 0] = 1;
const out = blurGaussian(grid, w, h, 1.5);
const sum = out.reduce((a, b) => a + b, 0);
assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`);
assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge');
});
test('buildHeatRaster is transparent where there are no fires', () => {
const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], {
width: 360,
height: 180,
sigmaPx: 2,
});
assert.equal(r.rgba.length, 360 * 180 * 4);
const alphaAt = (lat, lon) => {
const x = Math.floor(((lon + 180) / 360) * 360);
const y = Math.floor(((90 - lat) / 180) * 180);
return r.rgba[(y * 360 + x) * 4 + 3];
};
assert.ok(alphaAt(40, -120) > 0, 'blob over the fires');
assert.equal(alphaAt(-30, 60), 0, 'empty far away');
});
test('buildHeatRaster with no fires is fully transparent', () => {
const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 });
assert.ok(r.rgba.every((v) => v === 0));
});
+212
View File
@@ -0,0 +1,212 @@
/**
* @module firmsIntensity
* @description Fire intensity policy shared by the Active Fires layer and the
* agent fire tool: the six-stop ember → white-hot FRP color ramp, the log-scale
* slider that doubles as its legend, and the one filter every consumer
* (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs
* before anything is rendered or selected. Pure — no Cesium, no DOM.
*
* The ramp (`FRP_RAMP`, stepped, not interpolated) runs like a fire cooling
* down: a dim ember for weak fires through red, orange and gold to a pale
* white-hot core, lightness rising with power, so the brightest marks are the
* strongest fires. No cold hues: ~90% of detections are under 20 MW and a
* blue start painted most of the map blue. The thresholds are GEV's own, set
* on a real week's percentiles so every color appears: Portolan's
* 0/10/50/200/500/1000 MW leaves one hottest fire a week (HAL-675).
*/
/**
* Stepped FRP ramp, weakest first. A detection takes the LAST stop whose
* `minFrp` it reaches. Names key the glow-sprite cache and the card accents.
*/
export const FRP_RAMP = Object.freeze([
Object.freeze({ name: 'ember', minFrp: 0, hex: '#9e2a1b' }),
Object.freeze({ name: 'red', minFrp: 5, hex: '#d2401f' }),
Object.freeze({ name: 'flame', minFrp: 20, hex: '#f06a28' }),
Object.freeze({ name: 'orange', minFrp: 50, hex: '#fb9a2e' }),
Object.freeze({ name: 'gold', minFrp: 100, hex: '#fdc84a' }),
Object.freeze({ name: 'white-hot', minFrp: 300, hex: '#fff3b0' }),
]);
/** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */
export const FRP_SLIDER_TICKS = Object.freeze(
FRP_RAMP.map((stop) => stop.minFrp),
);
/** Minimum-FRP values the share codec accepts, in whole megawatts. */
export const MIN_FRP_MW_MIN = 0;
export const MIN_FRP_MW_MAX = 10_000;
/** Confidence switch positions; VIIRS reports only low / nominal / high. */
export const FIRMS_CONFIDENCE_MODES = Object.freeze(['all', 'nominal', 'high']);
/**
* Normalized-confidence floor for a switch position. The thresholds match
* `confidenceBucket` in the layer model (0.3 / 0.6 / 0.9 after firmsAdapt).
*/
const CONFIDENCE_FLOOR = Object.freeze({ all: 0, nominal: 0.45, high: 0.75 });
export const DEFAULT_FIRE_FILTER = Object.freeze({
minFrp: 0,
confidence: 'all',
});
/**
* The ramp stop for a fire radiative power.
* @param {number} frp Megawatts; non-finite or negative reads as 0.
* @returns {{name: string, minFrp: number, hex: string}}
*/
export function frpRampStop(frp) {
const value = Number.isFinite(frp) ? Math.max(0, frp) : 0;
let stop = FRP_RAMP[0];
for (const candidate of FRP_RAMP) {
if (value >= candidate.minFrp) stop = candidate;
else break;
}
return stop;
}
/** `#rrggbb` → `[r, g, b]` in 0..255. */
export function hexToRgb(hex) {
const text = String(hex).replace('#', '');
return [0, 2, 4].map((offset) =>
Number.parseInt(text.slice(offset, offset + 2), 16),
);
}
/**
* Log-scale slider axis: position 0 means "no minimum" (0 MW), and the rest
* of the track runs 1 MW → SLIDER_MAX_MW logarithmically. The axis floor is 1
* MW because the median detection is ~6 MW and a sub-megawatt floor hides
* nothing worth a tick.
*/
export const SLIDER_MAX_MW = 1000;
const LOG_MAX = Math.log10(SLIDER_MAX_MW);
/**
* Track position in 0..1 for a minimum FRP.
* @param {number} minFrp Megawatts.
* @returns {number}
*/
export function sliderPositionForFrp(minFrp) {
if (!Number.isFinite(minFrp) || minFrp < 1) return 0;
return Math.min(1, Math.log10(minFrp) / LOG_MAX);
}
/**
* Minimum FRP for a track position, rounded to whole megawatts so a slider
* value and its share-link form are the same number.
* @param {number} position 0..1.
* @returns {number}
*/
export function frpForSliderPosition(position) {
const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0;
if (p <= 0) return 0;
const frp = Math.pow(10, p * LOG_MAX);
return Math.max(1, Math.round(frp));
}
/**
* CSS `linear-gradient` painting the stepped ramp along the log axis, so the
* slider track IS the legend: the color under the thumb is the color of the
* weakest fire still shown.
* @returns {string}
*/
export function rampTrackGradient() {
const parts = [];
FRP_RAMP.forEach((stop, index) => {
const start = sliderPositionForFrp(stop.minFrp) * 100;
const next = FRP_RAMP[index + 1];
const end = next ? sliderPositionForFrp(next.minFrp) * 100 : 100;
parts.push(`${stop.hex} ${start.toFixed(2)}% ${end.toFixed(2)}%`);
});
return `linear-gradient(90deg, ${parts.join(', ')})`;
}
/**
* Minimum FRP → whole megawatts inside the codec bounds, or null.
* @param {*} value
* @returns {?number}
*/
export function normalizeMinFrp(value) {
const number =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== ''
? Number(value)
: NaN;
if (!Number.isFinite(number)) return null;
const rounded = Math.round(number);
return rounded >= MIN_FRP_MW_MIN && rounded <= MIN_FRP_MW_MAX
? rounded
: null;
}
/**
* Confidence mode → one of FIRMS_CONFIDENCE_MODES, or null.
* @param {*} value
* @returns {?string}
*/
export function normalizeConfidenceMode(value) {
const text = String(value ?? '')
.trim()
.toLowerCase();
return FIRMS_CONFIDENCE_MODES.includes(text) ? text : null;
}
/**
* Sanitize a partial filter against the current one; unknown or invalid
* fields keep their current value.
* @param {Object} [params]
* @param {{minFrp: number, confidence: string}} [current]
* @returns {{minFrp: number, confidence: string}}
*/
export function normalizeFireFilter(
params = {},
current = DEFAULT_FIRE_FILTER,
) {
const minFrp = normalizeMinFrp(params?.minFrp);
const confidence = normalizeConfidenceMode(params?.confidence);
return {
minFrp: minFrp === null ? current.minFrp : minFrp,
confidence: confidence === null ? current.confidence : confidence,
};
}
/**
* Whether one fire passes the filter. `confidence` is the normalized 0..1
* value firmsAdapt produces; `frp` is megawatts.
* @param {{frp: number, confidence: number}} fire
* @param {{minFrp: number, confidence: string}} filter
* @returns {boolean}
*/
export function firePassesFilter(fire, filter = DEFAULT_FIRE_FILTER) {
const frp = Number.isFinite(fire?.frp) ? fire.frp : 0;
if (frp < (filter.minFrp || 0)) return false;
const floor = CONFIDENCE_FLOOR[filter.confidence] ?? 0;
if (floor <= 0) return true;
const confidence = Number.isFinite(fire?.confidence) ? fire.confidence : 0;
return confidence >= floor;
}
/**
* The fires that pass the filter, in input order. Returns the SAME array when
* the filter is the default, so callers can tell "nothing hidden" cheaply.
* @template T
* @param {Array<T>} fires Internal records (normalized confidence).
* @param {{minFrp: number, confidence: string}} filter
* @returns {Array<T>}
*/
export function filterFires(fires, filter = DEFAULT_FIRE_FILTER) {
if (!Array.isArray(fires)) return [];
if (isDefaultFireFilter(filter)) return fires;
return fires.filter((fire) => firePassesFilter(fire, filter));
}
/** True when the filter hides nothing. */
export function isDefaultFireFilter(filter) {
return (
!(filter?.minFrp > 0) &&
(filter?.confidence === 'all' || filter?.confidence == null)
);
}
+221
View File
@@ -0,0 +1,221 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_FIRE_FILTER,
FIRMS_CONFIDENCE_MODES,
FRP_RAMP,
FRP_SLIDER_TICKS,
filterFires,
firePassesFilter,
frpForSliderPosition,
frpRampStop,
hexToRgb,
isDefaultFireFilter,
normalizeConfidenceMode,
normalizeFireFilter,
normalizeMinFrp,
rampTrackGradient,
sliderPositionForFrp,
} from './firmsIntensity.js';
test('the ramp has six ember → white-hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW', () => {
assert.deepEqual(
FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]),
[
['ember', 0, '#9e2a1b'],
['red', 5, '#d2401f'],
['flame', 20, '#f06a28'],
['orange', 50, '#fb9a2e'],
['gold', 100, '#fdc84a'],
['white-hot', 300, '#fff3b0'],
],
);
assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]);
assert.ok(Object.isFrozen(FRP_RAMP));
});
test('every stop is warm and each is lighter than the one below', () => {
const luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
let previous = -1;
for (const stop of FRP_RAMP) {
const rgb = hexToRgb(stop.hex);
assert.ok(rgb[0] >= rgb[2], `${stop.name} has no cold hue`);
assert.ok(
luma(rgb) > previous,
`${stop.name} is lighter than the stop below`,
);
previous = luma(rgb);
}
});
test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => {
assert.equal(frpRampStop(0).name, 'ember');
assert.equal(frpRampStop(4.99).name, 'ember');
assert.equal(frpRampStop(5).name, 'red');
assert.equal(frpRampStop(19.9).name, 'red');
assert.equal(frpRampStop(20).name, 'flame');
assert.equal(frpRampStop(50).name, 'orange');
assert.equal(frpRampStop(100).name, 'gold');
assert.equal(frpRampStop(299.9).name, 'gold');
assert.equal(frpRampStop(300).name, 'white-hot');
assert.equal(frpRampStop(1246).name, 'white-hot');
assert.equal(frpRampStop(NaN).name, 'ember');
assert.equal(frpRampStop(-3).name, 'ember');
assert.equal(frpRampStop(undefined).name, 'ember');
});
test('hex stops convert to the "r, g, b" accents the cards paint', () => {
assert.deepEqual(hexToRgb('#9e2a1b'), [158, 42, 27]);
assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]);
});
test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round-trip', () => {
assert.equal(sliderPositionForFrp(0), 0);
assert.equal(sliderPositionForFrp(0.5), 0);
assert.equal(sliderPositionForFrp(1), 0);
assert.ok(Math.abs(sliderPositionForFrp(10) - 1 / 3) < 1e-9);
assert.ok(Math.abs(sliderPositionForFrp(100) - 2 / 3) < 1e-9);
assert.equal(sliderPositionForFrp(1000), 1);
assert.equal(sliderPositionForFrp(5000), 1, 'clamped past the axis end');
assert.equal(frpForSliderPosition(0), 0);
assert.equal(frpForSliderPosition(1), 1000);
assert.equal(frpForSliderPosition(-1), 0);
assert.equal(frpForSliderPosition(2), 1000);
for (const frp of FRP_SLIDER_TICKS) {
assert.equal(
frpForSliderPosition(sliderPositionForFrp(frp)),
frp,
`tick ${frp} MW round-trips through the axis`,
);
}
// Ticks spread along the track instead of bunching at one end.
const positions = FRP_SLIDER_TICKS.map(sliderPositionForFrp);
for (let i = 1; i < positions.length; i += 1) {
assert.ok(positions[i] - positions[i - 1] >= 0.09, `tick gap ${i}`);
}
});
test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => {
const gradient = rampTrackGradient();
assert.match(gradient, /^linear-gradient\(90deg, /);
assert.match(gradient, /#9e2a1b 0\.00% 23\.3\d%/);
assert.match(gradient, /#d2401f 23\.3\d% 43\.3\d%/);
assert.match(gradient, /#fff3b0 82\.5\d% 100\.00%\)$/);
assert.equal(gradient.match(/#/g).length, FRP_RAMP.length);
});
test('minimum FRP normalizes to whole megawatts inside the codec bounds', () => {
assert.equal(normalizeMinFrp(0), 0);
assert.equal(normalizeMinFrp(19.6), 20);
assert.equal(normalizeMinFrp('100'), 100);
assert.equal(normalizeMinFrp(10000), 10000);
assert.equal(normalizeMinFrp(10001), null);
assert.equal(normalizeMinFrp(-1), null);
assert.equal(normalizeMinFrp('abc'), null);
assert.equal(normalizeMinFrp(''), null);
assert.equal(normalizeMinFrp(null), null);
assert.equal(normalizeMinFrp(NaN), null);
});
test('confidence modes are exactly all / nominal / high', () => {
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
assert.equal(normalizeConfidenceMode(' High '), 'high');
assert.equal(normalizeConfidenceMode('nominal'), 'nominal');
assert.equal(normalizeConfidenceMode('low'), null);
assert.equal(normalizeConfidenceMode(0.9), null);
assert.equal(normalizeConfidenceMode(undefined), null);
});
test('a partial filter patches the current one and invalid fields keep their value', () => {
assert.deepEqual(normalizeFireFilter(), DEFAULT_FIRE_FILTER);
assert.deepEqual(normalizeFireFilter({ minFrp: 20 }), {
minFrp: 20,
confidence: 'all',
});
assert.deepEqual(
normalizeFireFilter(
{ confidence: 'high' },
{ minFrp: 50, confidence: 'all' },
),
{ minFrp: 50, confidence: 'high' },
);
assert.deepEqual(
normalizeFireFilter(
{ minFrp: 'nope', confidence: 'medium' },
{ minFrp: 50, confidence: 'nominal' },
),
{ minFrp: 50, confidence: 'nominal' },
);
assert.equal(isDefaultFireFilter(DEFAULT_FIRE_FILTER), true);
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'all' }), true);
assert.equal(isDefaultFireFilter({ minFrp: 1, confidence: 'all' }), false);
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'high' }), false);
});
test('the filter keeps a fire at or above the floor and at or above the confidence mode', () => {
const low = { frp: 864, confidence: 0.3 };
const nominal = { frp: 20, confidence: 0.6 };
const high = { frp: 2, confidence: 0.9 };
assert.equal(firePassesFilter(low), true, 'default shows everything');
assert.equal(
firePassesFilter(nominal, { minFrp: 20, confidence: 'all' }),
true,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 21, confidence: 'all' }),
false,
);
assert.equal(
firePassesFilter(low, { minFrp: 0, confidence: 'nominal' }),
false,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 0, confidence: 'nominal' }),
true,
);
assert.equal(
firePassesFilter(high, { minFrp: 0, confidence: 'nominal' }),
true,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 0, confidence: 'high' }),
false,
);
assert.equal(firePassesFilter(high, { minFrp: 0, confidence: 'high' }), true);
assert.equal(
firePassesFilter(high, { minFrp: 5, confidence: 'high' }),
false,
);
// Missing numbers read as 0, never as a pass-through.
assert.equal(firePassesFilter({}, { minFrp: 1, confidence: 'all' }), false);
assert.equal(
firePassesFilter({ frp: 10 }, { minFrp: 0, confidence: 'nominal' }),
false,
);
});
test('filterFires returns the same array for the default filter and preserves order otherwise', () => {
const fires = [
{ frp: 864, confidence: 0.3 },
{ frp: 20, confidence: 0.6 },
{ frp: 2, confidence: 0.9 },
{ frp: 120, confidence: 0.9 },
];
assert.equal(filterFires(fires), fires);
assert.equal(filterFires(fires, { minFrp: 0, confidence: 'all' }), fires);
assert.deepEqual(
filterFires(fires, { minFrp: 20, confidence: 'all' }).map((f) => f.frp),
[864, 20, 120],
);
assert.deepEqual(
filterFires(fires, { minFrp: 0, confidence: 'high' }).map((f) => f.frp),
[2, 120],
);
assert.deepEqual(
filterFires(fires, { minFrp: 100, confidence: 'nominal' }).map(
(f) => f.frp,
),
[120],
);
assert.deepEqual(filterFires(null, { minFrp: 1, confidence: 'all' }), []);
});
Loaded 100 of 738 files, more files were not shown because too many files have changed in this diff. Show more