Merge upstream/main into fix/taiwan-highway-cctv-fallback

This commit is contained in:
Said Turksever committed 2026-11-07 17:46:53 +00:00
commit cc3d390911
474 files changed
+68164 -2182

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();
});
+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([], [])),
+15 -2
View File
@@ -1,7 +1,7 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createApplicationCyclones } from './layers/cyclones.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
@@ -15,6 +15,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 +29,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 +45,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. */
@@ -130,6 +135,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 +157,7 @@ export function createApplicationCatalog({
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
createApplicationOsmPlaces(),
vessels,
installations,
createApplicationAwareness({
@@ -169,7 +182,7 @@ export function createApplicationCatalog({
id: 'weather-lightning',
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
createApplicationCyclones({ feed: sources.cyclones }),
...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
+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,
});
}
+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 });
}
+30
View File
@@ -0,0 +1,30 @@
import * as Cesium from 'cesium';
import { createWildlifeSightingsLayer } from '../../layers/wildlifeSightings/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as sprites from '../../data/spriteOrder.js';
import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as focus from '../../worldFocus.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Bind the wildlife layer to the application's scene and selection owners. */
export function createApplicationWildlifeSightings(options) {
return createWildlifeSightingsLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
sprites,
render,
context,
focus,
...options,
});
}
+2
View File
@@ -2,6 +2,7 @@ import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import {
createApplicationViewer,
installFreeLookHeightLimit,
installTrackpadPinchZoom,
} from '../app/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js';
@@ -58,6 +59,7 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
defer(installFreeLookHeightLimit(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
+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 },
+15 -1
View File
@@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
installScopeMask,
destroyScopeMask,
isScopeMaskEnabled,
} from '../scopeMask.js';
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
import { installGlobeInViewRecovery } from './viewer.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import {
installRenderGovernor,
@@ -44,6 +50,14 @@ export function createApplicationTools({
.get('bhote-koshi-2026')
?.module.attachSceneController(sceneDirector);
defer(() => sceneDirector.destroy());
defer(
installGlobeInViewRecovery(viewer, {
// The scope mask hides everything outside its keyhole.
keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0),
// An authored scene frames its own shots, holds included.
paused: () => sceneDirector.running,
}),
);
onSceneDirector?.(sceneDirector);
const annotations = initAnnotations({
viewer,
+230
View File
@@ -1,6 +1,11 @@
import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js';
import {
FREE_LOOK_MAX_HEIGHT_METERS,
globeOutOfFrame,
isFreeGlobeCamera,
} from '../globeInView.js';
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
@@ -103,6 +108,231 @@ export function installTrackpadPinchZoom(
};
}
/**
* Allow Cesium's free look only below `maxHeight` and return its disposer.
* Orbit, tilt and zoom stay available at every height. A value other code
* sets later is kept as the one to allow below that height, and restored on
* dispose; a `false` written while the camera is above `maxHeight` cannot be
* told apart from this limit's own write, so dispose restores the earlier
* value then. `enableLook` gates the Shift+drag binding only: below its trackball
* height Cesium also turns the camera in place for a drag that starts on the
* stars. That height is lowered to `maxHeight` too, so above it such a drag
* orbits the globe, as it does above Cesium's own 7,500 km.
*/
export function installFreeLookHeightLimit(
viewer,
maxHeight = FREE_LOOK_MAX_HEIGHT_METERS,
) {
const scene = viewer?.scene;
const controller = scene?.screenSpaceCameraController;
const camera = viewer?.camera;
if (!controller || !camera || !scene?.preUpdate)
throw new TypeError('A complete Cesium viewer is required');
const originalTrackBallHeight = controller.minimumTrackBallHeight;
const trackBallHeight = Math.min(originalTrackBallHeight, maxHeight);
controller.minimumTrackBallHeight = trackBallHeight;
let wanted = controller.enableLook;
let written;
const sync = () => {
if (written !== undefined && controller.enableLook !== written)
wanted = controller.enableLook;
const height = camera.positionCartographic?.height;
written = wanted && !(Number.isFinite(height) && height > maxHeight);
controller.enableLook = written;
};
sync();
const removeListener = scene.preUpdate.addEventListener(sync);
let active = true;
return () => {
if (!active) return;
active = false;
removeListener();
if (controller.enableLook === written) controller.enableLook = wanted;
if (controller.minimumTrackBallHeight === trackBallHeight)
controller.minimumTrackBallHeight = originalTrackBallHeight;
};
}
// How often a moving camera is sampled to see whether it has come to rest, how
// far a pose may drift between samples and still count as at rest, and how
// long after the camera last moved the check gives up if Cesium never raises
// moveEnd.
const SETTLE_SAMPLE_MS = 250;
const SETTLE_POSITION_TOLERANCE_M = 1e-3;
const SETTLE_DIRECTION_TOLERANCE = 1e-9;
const SETTLE_GIVE_UP_MS = 5 * 60_000;
/**
* Turn the camera back to the planet when a move ends above `minHeight` with
* no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about
* one sample cell thick can count as none), and return the disposer. Views with
* more surface, such as an oblique orbital shot, are left as they are. This
* catches a saved or shared view that looked away, or any other move that ends
* looking away from the planet.
* Heading and position are kept. Tracked cameras, locked ones (non-identity
* transform), a camera flight in progress, a pointer held down on the canvas
* (a drag held still), the 2D and Columbus modes, and any time `paused`
* returns true (an authored scene playing) are left alone; a check they defer
* runs once they end, however long they last.
* `keyholeRadius`
* returns the radius of the visible circle when a scope mask hides the rest of
* the frame (see `globeFrameShare`), or 0; it is read at every check.
*
* Cesium's `moveEnd` alone is not enough: Cesium compares the camera with the
* last frame's to the last rounding error, and at some of these poses the
* camera drifts by a rounding error every frame, so Cesium never sees it stop.
* So each `moveStart` also starts a check of its own that samples the pose
* every 250 ms, with a tolerance for that drift, and once the pose has held
* for half a second after moving runs the same recovery. It keeps watching
* until Cesium raises `moveEnd`, since under that drift Cesium raises no new
* `moveStart` for a later move, and gives up five minutes after the camera
* last moved.
* `setTimeout` and `clearTimeout` can be injected for tests.
*/
export function installGlobeInViewRecovery(
viewer,
{
minHeight = FREE_LOOK_MAX_HEIGHT_METERS,
keyholeRadius = () => 0,
paused = () => false,
setTimeout: schedule = globalThis.setTimeout,
clearTimeout: unschedule = globalThis.clearTimeout,
} = {},
) {
const scene = viewer?.scene;
const camera = viewer?.camera;
if (!scene || !camera?.moveEnd || !camera?.moveStart)
throw new TypeError('A complete Cesium viewer is required');
// A pointer held on the globe is a gesture in progress, even when it holds
// still for half a second: the check waits until every pointer pressed on
// it (each finger of a pinch) is released. A release the page never sees,
// outside the window, is taken as given when the window loses focus, the
// page is hidden, the canvas loses the pointer's capture, or the pointer
// moves with no button down.
const canvas = scene.canvas;
const releaseTarget = canvas?.ownerDocument ?? canvas;
const releaseWindow = canvas?.ownerDocument?.defaultView;
const heldPointers = new Set();
const onPointerDown = (event) => heldPointers.add(event?.pointerId);
const onPointerUp = (event) => heldPointers.delete(event?.pointerId);
const onPointerMove = (event) => {
if (event?.buttons === 0) heldPointers.delete(event.pointerId);
};
const releaseAll = () => heldPointers.clear();
const onVisibilityChange = () => {
if (canvas.ownerDocument.visibilityState === 'hidden') releaseAll();
};
const listeners = [
[canvas, 'pointerdown', onPointerDown],
[canvas, 'lostpointercapture', onPointerUp],
[releaseTarget, 'pointerup', onPointerUp],
[releaseTarget, 'pointercancel', onPointerUp],
[releaseTarget, 'pointermove', onPointerMove],
[canvas?.ownerDocument, 'visibilitychange', onVisibilityChange],
[releaseWindow, 'blur', releaseAll],
];
for (const [target, type, listener] of listeners)
target?.addEventListener?.(type, listener);
// Returns false while a flight, a paused scene or a held pointer defers the
// check, so the caller can try again once it ends.
const recover = () => {
if (!isFreeGlobeCamera(viewer)) return true;
// Cesium has no public running-flight flag.
if (camera._currentFlight || paused() || heldPointers.size > 0)
return false;
const height = camera.positionCartographic?.height;
if (
!(height > minHeight) ||
!globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() })
)
return true;
camera.setView({
orientation: {
heading: camera.heading,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
},
});
scene.requestRender?.();
return true;
};
const lastPosition = new Cesium.Cartesian3();
const lastDirection = new Cesium.Cartesian3();
let settleTimer = null;
let settleElapsed = 0;
let restingSamples = 0;
let movedSinceCheck = false;
const stopSettling = () => {
if (settleTimer !== null) unschedule(settleTimer);
settleTimer = null;
};
const sample = () => {
settleTimer = null;
settleElapsed += SETTLE_SAMPLE_MS;
const atRest =
Cesium.Cartesian3.equalsEpsilon(
camera.positionWC,
lastPosition,
0,
SETTLE_POSITION_TOLERANCE_M,
) &&
Cesium.Cartesian3.equalsEpsilon(
camera.directionWC,
lastDirection,
0,
SETTLE_DIRECTION_TOLERANCE,
);
restingSamples = atRest ? restingSamples + 1 : 0;
if (!atRest) {
movedSinceCheck = true;
settleElapsed = 0;
}
// Two quiet samples in a row after a move: at rest for half a second, as
// for moveEnd.
if (restingSamples >= 2 && movedSinceCheck) {
if (recover()) movedSinceCheck = false;
// A flight or a playing scene bounds its own deferral.
else settleElapsed = 0;
}
if (settleElapsed >= SETTLE_GIVE_UP_MS) return;
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
};
const startSettling = () => {
stopSettling();
settleElapsed = 0;
restingSamples = 0;
movedSinceCheck = true;
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
};
const removeMoveEnd = camera.moveEnd.addEventListener(() => {
if (recover()) stopSettling();
// Deferred: keep sampling so the check runs once the flight or scene ends.
else if (settleTimer === null) startSettling();
});
const removeMoveStart = camera.moveStart.addEventListener(startSettling);
let active = true;
return () => {
if (!active) return;
active = false;
stopSettling();
removeMoveEnd();
removeMoveStart();
for (const [target, type, listener] of listeners)
target?.removeEventListener?.(type, listener);
heldPointers.clear();
};
}
/** Cesium.Viewer options of the standard globe viewer. */
export function applicationViewerOptions(creditContainer) {
return {
+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');
});
+10 -1
View File
@@ -223,11 +223,20 @@ test('validated voice camera destinations share the UI navigation authority faca
'move camera',
);
ordered(move, [
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
'if (argsError) return argsError;',
'const start = () => {',
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
], 'move validation before handoff');
const moveArgs = body(
cameraVerbs,
/export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/,
'move camera argument checks',
);
assert.ok(
moveArgs.includes("if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))"),
'unknown motions are refused before handoff',
);
const route = body(
cameraVerbs,
+773
View File
@@ -0,0 +1,773 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
adjustOrbitRange,
getActiveCameraMotion,
initCameraVerbs,
interruptCameraMotion,
moveCamera,
} from './cameraVerbs.js';
import { createGevActionRunner } from './voice/gevActions.js';
const radians = Cesium.Math.toRadians;
const degrees = Cesium.Math.toDegrees;
/** Real Cesium camera over London with no renderer: the voice fly-to frame. */
function harness(
t,
{ height = 4000, heading = 0, pitch = -90, pivot = true } = {},
) {
const realPerformance = globalThis.performance;
let nowMs = 0;
globalThis.performance = { now: () => nowMs };
const canvas = Object.assign(new EventTarget(), {
clientWidth: 1280,
clientHeight: 800,
width: 1280,
height: 800,
});
const ellipsoid = Cesium.Ellipsoid.WGS84;
const scene = {
canvas,
drawingBufferWidth: 1280,
drawingBufferHeight: 800,
frameState: { mode: Cesium.SceneMode.SCENE3D },
mapProjection: new Cesium.GeographicProjection(ellipsoid),
globe: { ellipsoid },
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
tweens: [],
requestRender() {},
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-0.1276, 51.5072, height),
orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 },
});
const viewer = {
camera,
scene,
trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(),
clock: { onTick: new Cesium.Event() },
};
// The app's view target: the ground point under the screen centre.
const viewTarget = () => {
const ray = new Cesium.Ray(camera.positionWC, camera.directionWC);
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
};
const target = viewTarget();
initCameraVerbs(viewer, pivot ? viewTarget : () => undefined);
t.after(() => {
interruptCameraMotion('test-cleanup');
globalThis.performance = realPerformance;
});
const tick = (seconds = 1 / 30) => {
nowMs += seconds * 1000;
viewer.clock.onTick.raiseEvent(viewer.clock);
};
const finish = () => {
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
};
return { viewer, camera, target, tick, finish, viewTarget };
}
const pitchDeg = (camera) => degrees(camera.pitch);
/** Tick until done() holds; a regression fails here instead of hanging. */
const tickUntil = (h, done, frames = 600) => {
for (let i = 0; i < frames && !done(); i += 1) h.tick();
assert.ok(done(), `not reached in ${frames} frames`);
};
const headingDeg = (camera) =>
degrees(Cesium.Math.negativePiToPi(camera.heading));
/** Metres between the original centre and the ground now under the crosshair. */
function centreDrift(h) {
return Cesium.Cartesian3.distance(h.viewTarget(), h.target);
}
test('tilt up leaves a top-down view and keeps the place centred', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(result.ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -75) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
});
test('continuous tilt up from top-down keeps moving', (t) => {
const h = harness(t);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
true,
);
for (let i = 0; i < 30; i += 1) h.tick();
assert.equal(getActiveCameraMotion()?.kind, 'tilt');
assert.ok(pitchDeg(h.camera) > -82, `pitch ${pitchDeg(h.camera)}`);
});
test('a requested angle tilts to that pitch', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
assert.equal(result.ok, true);
assert.equal(result.direction, 'up');
assert.equal(result.pitchDeg, -45);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -45) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('a bare tilt settles on the default oblique angle', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt' });
assert.equal(result.ok, true);
assert.equal(result.pitchDeg, -45);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
});
test('a requested angle can tilt back down toward top-down', (t) => {
const h = harness(t, { pitch: -30 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -80 });
assert.equal(result.direction, 'down');
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
});
test('requested angles clamp to the tilt limits', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -2 }).pitchDeg, -5);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a positive angle is read as the same angle below the horizon', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 45 }).pitchDeg, -45);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
});
for (const pitch of ['', null, false, ' ', 'steep'])
test(`a blank or non-numeric angle ${JSON.stringify(pitch)} is ignored`, (t) => {
harness(t);
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
// No angle and no direction: the default oblique view, never the horizon.
assert.equal(result.pitchDeg, -45);
});
test('a requested angle the camera already holds is a no-op', (t) => {
harness(t, { pitch: -45 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(getActiveCameraMotion(), null);
});
for (const pitch of [-90, -89]) {
test(`a top-down view asked for ${pitch}° stays put`, (t) => {
// The tool description calls -89 top-down; the clamp must not tilt a
// -90° view up 1° and report it as a move up.
const h = harness(t);
const before = pitchDeg(h.camera);
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(result.pitchDeg, Math.round(before));
assert.equal(getActiveCameraMotion(), null);
assert.equal(pitchDeg(h.camera), before);
});
}
test('tilt down from top-down still refuses honestly', (t) => {
harness(t);
const result = moveCamera({ motion: 'tilt', direction: 'down' });
assert.equal(result.ok, false);
assert.match(result.error, /straight down/);
});
test('a tilt up nudge stops at the horizon limit', (t) => {
const h = harness(t, { pitch: -12 });
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('without a ground pivot a tilt still leaves top-down in place', (t) => {
const h = harness(t, { pivot: false });
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -75) < 0.5);
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-6);
});
for (const heading of [0, 30, -120])
test(`tilt from top-down keeps the ${heading}° heading`, (t) => {
const h = harness(t, { heading, height: 67000 });
// Top-down float noise must not pick a new heading for the pivot.
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.finish();
assert.ok(
Math.abs(headingDeg(h.camera) - heading) < 1,
`heading ${headingDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 50, `centre drifted ${centreDrift(h)} m`);
});
test('an oblique tilt keeps its heading toward the pivot', (t) => {
const h = harness(t, { heading: 75, pitch: -60 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -30 }).ok, true);
h.finish();
assert.ok(Math.abs(headingDeg(h.camera) - 75) < 0.5);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('a pivoting tilt yields when tracking takes the camera', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.tick();
const entity = { id: 'tracked' };
h.viewer.trackedEntity = entity;
h.viewer.trackedEntityChanged.raiseEvent(entity);
assert.equal(getActiveCameraMotion(), null);
});
/** Metres between the camera and the point it pivots about. */
const pivotRange = (h) =>
Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
const heightM = (camera) =>
Cesium.Cartographic.fromCartesian(camera.positionWC).height;
test('a zoom during a pivoting tilt changes the range it holds', (t) => {
const h = harness(t);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
true,
);
h.tick();
const before = pivotRange(h);
assert.deepEqual(
adjustOrbitRange(0.5),
{ kind: 'tilt', changed: true },
'the tilt must take the zoom',
);
for (let i = 0; i < 5; i += 1) h.tick();
// The next frame's lookAt must not put the old range back.
assert.ok(
Math.abs(pivotRange(h) - before / 2) < 1,
`range ${pivotRange(h)} m, wanted ${before / 2} m`,
);
});
test('a zoom with no pivot to hold is left to the camera', (t) => {
harness(t, { pivot: false });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
assert.equal(adjustOrbitRange(0.5), null);
});
test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => {
// A close top-down view: swinging 250 m of range to -5° would end ~22 m up.
const h = harness(t, { height: 250 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -5 }).ok, true);
let lowest = Infinity;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
lowest = Math.min(lowest, heightM(h.camera));
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(lowest > 149, `camera dropped to ${lowest} m`);
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
});
test('a continuous tilt up from close in keeps its clearance', (t) => {
const h = harness(t, { height: 250 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a city-scale pivot tilt keeps the place centred to the horizon limit', (t) => {
const h = harness(t);
moveCamera({ motion: 'tilt', pitchDeg: -5 });
h.finish();
// 4 km of range clears the floor at every pitch: all pivot, no drift.
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('pitchDeg against a contradicting direction is refused', (t) => {
harness(t, { pitch: -45 });
// "tilt down 10 degrees": -10° is an absolute angle, which is up from -45°.
const result = moveCamera({
motion: 'tilt',
direction: 'down',
pitchDeg: 10,
});
assert.equal(result.ok, false);
assert.match(result.error, /absolute angle/);
assert.equal(getActiveCameraMotion(), null);
});
test('pitchDeg with a direction that agrees still tilts', (t) => {
const h = harness(t, { pitch: -45 });
const result = moveCamera({
motion: 'tilt',
direction: 'down',
pitchDeg: -80,
});
assert.equal(result.ok, true);
assert.equal(result.direction, 'down');
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
});
test('the pitchDeg and direction check uses the camera pitch', (t) => {
// At regional range the Earth curves away and the pivot pitch is a degree
// or two shallower than the camera's (-30°); -29° is still up from it.
harness(t, { height: 150000, pitch: -30 });
const down = moveCamera({ motion: 'tilt', direction: 'down', pitchDeg: -29 });
assert.equal(down.ok, false);
assert.match(down.error, /tilts up, not down/);
const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 });
assert.equal(up.ok, true);
assert.equal(up.direction, 'up');
});
test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => {
const h = harness(t, { height: 1000 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -30);
adjustOrbitRange(1 / 2.4);
adjustOrbitRange(1 / 2.4);
h.tick();
const afterZoom = heightM(h.camera);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(
heightM(h.camera) > afterZoom - 1,
`camera fell from ${afterZoom} m to ${heightM(h.camera)} m`,
);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a zoom in during a close pivot tilt never pushes the camera out', (t) => {
// 62 m of range, under the 80 m zoom floor.
const h = harness(t, { height: 40, pitch: -40 });
moveCamera({ motion: 'tilt', direction: 'down', mode: 'continuous' });
h.tick();
const before = pivotRange(h);
assert.ok(before < 80, `range ${before} m`);
adjustOrbitRange(1 / 1.6);
h.tick();
assert.ok(pivotRange(h) <= before + 0.01, `range grew to ${pivotRange(h)} m`);
});
test('a zoom out during a far orbit never pulls the camera in', (t) => {
// 15,000 km up, beyond the 5,000 km zoom cap.
const h = harness(t, { height: 15_000_000 });
assert.equal(moveCamera({ motion: 'orbit', mode: 'continuous' }).ok, true);
h.tick();
const before = pivotRange(h);
assert.ok(before > 5_000_000, `range ${before} m`);
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'orbit', changed: false });
h.tick();
assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`);
});
test('an out-of-range pitchDeg is clamped to the tilt limits', (t) => {
// The schema carries no range; 120 reads as 120° below the horizon.
const h = harness(t, { pitch: -45 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 120 }).ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -89) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('a zoom in during a pivot tilt stops at the clearance', (t) => {
// 400 m of range at -40°, then "zoom in a lot" twice: unfloored, that left
// the camera about 107 m up, then at the 80 m zoom floor.
const h = harness(t, { height: 400 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -40);
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true });
// At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached.
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false });
// The camera must take that range, not just report it.
h.tick();
const floor = 150 / Math.sin(radians(40));
assert.ok(
Math.abs(pivotRange(h) - floor) < floor * 0.02,
`range ${pivotRange(h)} m, wanted about ${floor} m`,
);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a voice zoom during a pivot tilt says what it changed', async (t) => {
const h = harness(t, { height: 400 });
const runner = createGevActionRunner({
viewer: h.viewer,
styleManager: {},
dataManager: { layers: new Map(), getAll: () => [] },
});
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
h.tick();
const zoom = { direction: 'in', amount: 'lot' };
const first = await runner('adjust_camera_zoom', zoom);
assert.equal(first.ok, true);
assert.equal(first.tiltRangeAdjusted, true);
assert.equal('orbitRadiusAdjusted' in first, false);
// 400 m → 167 m → the 150 m clearance; a third zoom in moves nothing, and
// says so.
assert.equal((await runner('adjust_camera_zoom', zoom)).ok, true);
const third = await runner('adjust_camera_zoom', zoom);
assert.equal(third.ok, false);
assert.match(third.error, /tilt is already at its closest range/);
});
test('from globe range a tilt happens in place', (t) => {
// A pivot 2,000 km out would swing the camera thousands of km.
const h = harness(t, { height: 2_000_000 });
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3);
});
for (const height of [100_000, 400_000])
test(`from ${height / 1000} km a tilt to -5° ends with the camera at -5°`, (t) => {
// The pivot pitch is shallower than the camera's by up to 4° at this
// range; the camera reached only -5.9° and -8.6° when the swing stopped
// at a pivot pitch of -5°.
const h = harness(t, { height });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.pitchDeg, -5);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(nudge.ok, false);
assert.match(nudge.error, /upper tilt limit/);
});
// From 550 km out the camera's -5° lies beyond the place's own horizon: the
// swing ran into its clearance, let go of the pivot and tilted the rest in
// place, which put the crosshair on the sky (28 to 63 km up), and a return
// to top-down then landed 600 to 900 km from the place.
for (const height of [600_000, 700_000, 900_000]) {
test(`from ${height / 1000} km a tilt to -5° keeps the place centred`, (t) => {
const h = harness(t, { height });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.ok, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
// The reply names the angle the camera reached, not the one asked for.
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
assert.ok(
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
);
assert.match(result.note, /horizon/);
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(nudge.ok, false);
assert.match(nudge.error, /upper tilt limit/);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -89 }).ok, true);
h.finish();
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
// Back over the place at the range it began with (-89° is ~1° off nadir).
const range = Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
assert.ok(
Math.abs(range - height) < height * 0.01,
`top-down ${range} m from the place`,
);
});
test(`a continuous tilt up from ${height / 1000} km stops with the place centred`, (t) => {
const h = harness(t, { height });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
for (let i = 0; i < 1200 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(h.viewTarget(), `the crosshair is on the sky at frame ${i}`);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must stop at its limit');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
});
}
test('a pitchDeg tilt toward the horizon limit says it is already there', (t) => {
const h = harness(t, { height: 900_000 });
moveCamera({ motion: 'tilt', pitchDeg: 5 });
h.finish();
const again = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(again.ok, false);
assert.match(again.error, /upper tilt limit/);
});
test('a regional tilt-up nudge goes to the camera horizon limit', (t) => {
// 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can
// still tilt up 2°.
const h = harness(t, { height: 20000, pitch: -7 });
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('a voice tilt from a tracked view judges its limit from the camera pitch', (t) => {
const h = harness(t, { height: 20000, pitch: -5.2 });
h.viewer.trackedEntity = { id: 'tracked' };
// Voice releases the follow camera before the motion starts.
const release = (start) => {
h.viewer.trackedEntity = undefined;
return start();
};
const result = moveCamera({ motion: 'tilt', direction: 'up' }, release);
assert.equal(result.ok, false);
assert.match(result.error, /upper tilt limit/);
});
test('a zoom out during a pivoting tilt stops at the pivot range limit', (t) => {
// 900 km top-down, "tilt to 10°", then "zoom out a lot" twice: capped at
// the orbit's 5,000 km, the swing reached 2,386 km up at a real -44°.
const h = harness(t, { height: 900_000 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -10 }).ok, true);
h.tick();
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: true });
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: false });
let farthest = 0;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
farthest = Math.max(farthest, pivotRange(h));
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(farthest <= 1_000_001, `swung ${farthest} m from the pivot`);
assert.ok(
Math.abs(pitchDeg(h.camera) - -10) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('an unchanged angle still stops a running motion through navigation', (t) => {
const h = harness(t);
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -45.3);
let handoffs = 0;
const navigate = (start) => {
handoffs += 1;
return start();
};
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }, navigate);
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(handoffs, 1, 'the request must take navigation');
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
const held = pitchDeg(h.camera);
for (let i = 0; i < 30; i += 1) h.tick();
assert.equal(pitchDeg(h.camera), held);
});
for (const direction of ['left', 'right'])
test(`a tilt ${direction} is refused`, (t) => {
harness(t, { pitch: -45 });
const result = moveCamera({ motion: 'tilt', direction });
assert.equal(result.ok, false);
assert.match(result.error, /tilt needs up or down/);
const angled = moveCamera({ motion: 'tilt', direction, pitchDeg: -30 });
assert.equal(angled.ok, false);
assert.match(angled.error, /pitchDeg alone/);
assert.equal(getActiveCameraMotion(), null);
});
// The 1,000 km pivot cutoff includes its boundary: at exactly 1,000 km the
// pivot range came back a hair over it, so the tilt ran in place and put the
// crosshair on the sky.
test('from exactly 1,000 km a tilt to -5° still pivots about the place', (t) => {
const h = harness(t, { height: 1_000_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.ok, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
assert.ok(
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
);
assert.match(result.note, /horizon/);
});
// A zoom mid-tilt moves the pivot's horizon limit: the zoom's reply names the
// angle the tilt now ends at, and the camera ends there.
test('a zoom out during a tilt to an angle reports the angle it now ends at', (t) => {
const h = harness(t, { height: 400_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
assert.equal(result.pitchDeg, -5);
assert.equal(result.note, undefined);
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.kind, 'tilt');
assert.equal(zoom.changed, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(zoom.pitchDeg < -5, `zoom reported ${zoom.pitchDeg}°`);
assert.ok(
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
test('a zoom in during a horizon-limited tilt reports the angle it now reaches', (t) => {
const h = harness(t, { height: 900_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = adjustOrbitRange(1 / 2.4);
assert.equal(zoom.changed, true);
h.finish();
assert.equal(zoom.pitchDeg, -5);
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`camera at ${pitchDeg(h.camera)}°`,
);
});
test('a voice zoom during a tilt to an angle passes on the new end angle', async (t) => {
const h = harness(t, { height: 400_000 });
const runner = createGevActionRunner({
viewer: h.viewer,
styleManager: {},
dataManager: { layers: new Map(), getAll: () => [] },
});
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = await runner('adjust_camera_zoom', {
direction: 'out',
amount: 'lot',
});
assert.equal(zoom.ok, true);
assert.equal(zoom.tiltRangeAdjusted, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// Late in the tilt the camera is already past the angle the new range's
// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8°): past it
// the place is behind the Earth, so the camera steps down to that limit with
// the zoom, then holds it with no further jump, and the zoom's reply names
// the angle it ends at, not the one it stood at.
test('a late zoom out during a tilt steps to the new limit and reports it', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.changed, true);
h.tick();
assert.equal(zoom.pitchDeg, Math.round(pitchDeg(h.camera)));
let last = pitchDeg(h.camera);
let maxStep = 0;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
maxStep = Math.max(maxStep, Math.abs(pitchDeg(h.camera) - last));
last = pitchDeg(h.camera);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(maxStep < 0.1, `pitch jumped ${maxStep}° in one frame`);
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
// Replies give whole degrees: the reported angle is the reached one rounded.
assert.equal(
zoom.pitchDeg,
Math.round(pitchDeg(h.camera)),
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// Easing from the camera's old pitch at the zoomed-out range put the camera
// below the place's horizon: the first frame's crosshair hit the ground
// 186 km from London, and London stayed hidden for ~11 frames.
test('a late zoom out during a tilt keeps the place under the crosshair', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.changed, true);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
const hit = h.viewTarget();
assert.ok(hit, `frame ${i}: the crosshair is on the sky`);
const drift = centreDrift(h);
assert.ok(
drift < 100,
`frame ${i}: crosshair ${Math.round(drift)} m off the place`,
);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(
Math.abs(pivotRange(h) - 1_000_000) < 1,
`range ${pivotRange(h)} m`,
);
assert.equal(
zoom.pitchDeg,
Math.round(pitchDeg(h.camera)),
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// The zoom out left the pivot pitch level with the place, so the zoom in's
// clearance floor came out infinite and it was refused at 1,000 km.
test('a zoom in right after a late zoom out brings the tilt back in', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const range = pivotRange(h);
assert.equal(adjustOrbitRange(2.5).changed, true);
const zoomIn = adjustOrbitRange(0.4);
assert.equal(zoomIn.changed, true, 'the zoom in must not be refused');
h.finish();
assert.ok(
Math.abs(pivotRange(h) - range) < 1,
`range ${pivotRange(h)} m, wanted ${range} m`,
);
assert.ok(
centreDrift(h) < 100,
`crosshair ${centreDrift(h)} m off the place`,
);
});
+748 -83
View File
@@ -20,11 +20,19 @@
import * as Cesium from 'cesium';
import { GLOBE_VIEW } from './locations.js';
import {
globeFrameShare,
isFreeGlobeCamera,
lookAngleWithinFrame,
lookStepLosesGlobe,
} from './globeInView.js';
import {
projectEarthDiscToViewport,
earthDiscScreenRadius,
getKeyholeGeometry,
KEYHOLE_OUTER_RADIUS,
} from './celestialRing.js';
import { isScopeMaskEnabled } from './scopeMask.js';
import {
holdContinuousRender,
releaseContinuousRender,
@@ -48,6 +56,33 @@ const ONCE = {
};
const PITCH_MIN = Cesium.Math.toRadians(-89);
const PITCH_MAX = Cesium.Math.toRadians(-5);
/** A bare "tilt the camera" / "oblique view" settles here. */
const TILT_DEFAULT_PITCH_DEG = -45;
/** °/s for a tilt to a requested angle; short ones keep the once duration. */
const TILT_TO_DEG_S = 30;
/**
* A pivoting tilt keeps the range, so its height above the pivot is
* range × sin|pitch|. It swings no lower than this; the rest tilts in place.
*/
const TILT_MIN_CLEARANCE_M = 150;
/**
* Beyond this range a pivot would swing the camera thousands of km about the
* crosshair point, so the tilt happens in place instead.
*/
const TILT_PIVOT_MAX_RANGE_M = 1_000_000;
/**
* A pivot range rebuilt from the camera's position comes back a hair off the
* range it was set at: a range this close past the cutoff still counts as on
* it.
*/
const TILT_PIVOT_RANGE_SLACK_M = 1;
/**
* The shallowest a pivot swing goes when its clearance is not what stops it.
* From several hundred km the camera's own horizon limit lies past the
* place's horizon; tilting the rest in place there would lift the crosshair
* onto the sky, so the swing stops with the place this far above it.
*/
const TILT_MIN_PIVOT_ELEVATION = Cesium.Math.toRadians(0.5);
/* ── Route dolly: cinematic tuning ──────────────────────────────────────────
* Every knob an owner may want to retune lives in this block. The shaping is
@@ -966,6 +1001,17 @@ let _trackingRemover = null;
let _inputRemovers = [];
/** Monotonic id for the motion currently running; 0 means "no motion". */
let _motionSeq = 0;
/** Canvas grabs, touches and wheel turns since load. */
let _manualInputCount = 0;
/**
* Lets a caller holding a motion back (behind a fly-to) see whether the user
* took the camera meanwhile; Cesium does not cancel a flight on input.
* @returns {number} Manual camera inputs so far.
*/
export function manualCameraInputCount() {
return _manualInputCount;
}
/**
* Install a motion and stamp it with a fresh id. The id is what lets a caller
@@ -1008,6 +1054,187 @@ export function interruptCameraMotionIfActive(
return interruptCameraMotion(reason);
}
// The frame share that in-place voice turns measure their limit against, with
// the pose the last voice turn left the camera in. A turn that starts where
// the last one left off keeps that baseline (raised if the planet now fills
// more of the frame), so repeating a request cannot take a little more each
// time. Once anything else has moved or turned the camera, a turn starts over
// from what is on screen.
let _lookBaseline = null;
// The pre-check's proof that a half-degree turn from this pose keeps the planet
// in view: which way, from which position and direction. A motion that starts from that
// pose turns at least that far, so a request reported as done really moves
// even when a smaller first step lands in a dip of the sampled share.
const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5);
let _provenLook = null;
/** The part of the frame a viewer can see: the scope keyhole when it is on. */
function visibleFrame() {
return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 };
}
/** The frame share an in-place turn from `cam`'s pose is limited against. */
function lookBaselineShare(cam) {
const share = globeFrameShare(cam, visibleFrame());
const continues =
_lookBaseline &&
Cesium.Cartesian3.equalsEpsilon(
_lookBaseline.position,
cam.positionWC,
0,
1,
) &&
Cesium.Cartesian3.equalsEpsilon(
_lookBaseline.direction,
cam.directionWC,
0,
1e-9,
);
_lookBaseline = {
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
share: continues ? Math.max(_lookBaseline.share, share) : share,
};
return _lookBaseline.share;
}
/**
* One in-place look step of motion `m`. From orbit a free globe camera turns
* only as far as keeps the planet in frame, measured against the baseline the
* turn started from, then the motion ends there.
*/
function lookWithinFrame(cam, m, look, angle) {
let allowed = angle;
if (isFreeGlobeCamera(_viewer)) {
if (m.globeShareAtStart === undefined) {
m.globeShareAtStart = lookBaselineShare(cam);
m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
m.turnedRad = 0;
}
_provenLook = null;
allowed = lookAngleWithinFrame(cam, look, angle, {
...visibleFrame(),
startShare: m.globeShareAtStart,
});
const owed = Math.min(angle, m.provenTurnRad - m.turnedRad);
if (allowed < owed) allowed = owed;
m.turnedRad += allowed;
}
if (allowed > 0) cam[look](allowed);
// The pose this turn leaves, for a turn that continues from it.
if (_lookBaseline)
Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction);
if (allowed < angle) interruptCameraMotion('globe-out-of-frame');
}
/**
* The in-place look a pitch change from `fromPitch` to `goalPitch` makes:
* 'lookUp', 'lookDown', or null when it does not move. A tilt is judged by
* the way its pitch moves, whatever form or direction word it came in.
*/
function tiltLook(fromPitch, goalPitch) {
if (!Number.isFinite(fromPitch) || !Number.isFinite(goalPitch)) return null;
if (goalPitch > fromPitch) return 'lookUp';
return goalPitch < fromPitch ? 'lookDown' : null;
}
/** Tilt `m` in place to `goalPitch`, as far as the frame limit allows. */
function tiltInPlaceWithinFrame(cam, m, goalPitch) {
const look = tiltLook(cam.pitch, goalPitch);
if (look) lookWithinFrame(cam, m, look, Math.abs(goalPitch - cam.pitch));
}
/**
* The pitch an in-place tilt from `cam`'s pose toward `goalPitch` reaches
* before the frame limit stops it, judged as its steps will be: `goalPitch`
* itself when the planet stays in frame the whole way, or the camera is not a
* free globe camera. The steps run on a copy of the camera, each looking by
* the pitch still to go as the tilt's frames do, because a rolled camera's
* look turns its pitch by less than the look angle.
*/
function tiltPitchWithinFrame(cam, goalPitch) {
const look = tiltLook(cam.pitch, goalPitch);
if (!look || !isFreeGlobeCamera(_viewer)) return goalPitch;
const frame = { ...visibleFrame(), startShare: lookBaselineShare(cam) };
// A proven half degree is turned whatever the sampled share says.
let owed = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
const probe = Cesium.Camera.clone(cam);
for (let i = 0; i < 32; i += 1) {
if (tiltLook(probe.pitch, goalPitch) !== look) return goalPitch;
const angle = Math.abs(goalPitch - probe.pitch);
if (angle < 1e-6) return goalPitch;
const allowed = Math.max(
lookAngleWithinFrame(probe, look, angle, frame),
Math.min(angle, owed),
);
owed -= allowed;
if (allowed > 0) probe[look](allowed);
if (allowed < angle) return probe.pitch;
}
return probe.pitch;
}
/** The reply's note for a tilt the frame limit stops short of its angle. */
function tiltFrameNote(pitch) {
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this height a shallower angle would turn the globe out of view. Zoom in to tilt further.`;
}
/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */
function lookProven(cam, look) {
return (
_provenLook?.look === look &&
Cesium.Cartesian3.equalsEpsilon(
_provenLook.position,
cam.positionWC,
0,
1,
) &&
Cesium.Cartesian3.equalsEpsilon(
_provenLook.direction,
cam.directionWC,
0,
1e-9,
)
);
}
/**
* The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft'
* or 'lookRight') must pass from orbit: refused when even half a degree would
* cross the frame limit, otherwise proven for that half degree from this pose.
* With `released`, the turn runs through a navigation that first releases a
* tracked or locked camera and keeps its world pose. The frame measure and the
* turn are the same in any reference frame, so such a camera is judged as it
* is, before the release, and a refusal leaves tracking as it was. Otherwise a
* camera that is not a free globe camera is not checked. Returns the refusal,
* or null.
*/
function checkLookFromOrbit(look, { released = false } = {}) {
_provenLook = null;
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null;
const cam = _viewer.camera;
if (!released && !isFreeGlobeCamera(_viewer)) return null;
if (
lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, {
...visibleFrame(),
startShare: lookBaselineShare(cam),
})
)
return {
ok: false,
action: 'move_camera',
error:
'From this height that would turn the globe out of view — zoom in first, or turn the other way.',
};
_provenLook = {
look,
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
};
return null;
}
function clearLookAt() {
try {
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
@@ -1052,7 +1279,9 @@ export function interruptCameraMotion(reason = 'interrupt') {
if (
wasActive &&
!tracking &&
(_active.kind === 'orbit' || _active.kind === 'route')
(_active.kind === 'orbit' ||
_active.kind === 'route' ||
(_active.kind === 'tilt' && _active.hpr))
)
clearLookAt();
// Instant, not eased: the user grabbed the camera, or the flight is over.
@@ -1104,7 +1333,7 @@ function onTick() {
let scale = 1;
if (m.mode === 'once') {
m.elapsed = (m.elapsed || 0) + dt;
const t = Math.min(1, m.elapsed / ONCE.durationS);
const t = Math.min(1, m.elapsed / (m.durationS || ONCE.durationS));
scale = 1 - (1 - t) * (1 - t); // ease-out progress share
if (t >= 1) {
interruptCameraMotion('once-complete');
@@ -1173,30 +1402,20 @@ function onTick() {
else if (m.direction === 'right') cam.moveRight(step);
else if (m.direction === 'up') cam.moveUp(step);
else cam.moveDown(step);
} else if (
m.kind === 'tilt' ||
m.kind === 'rotate' ||
m.kind === 'rotate_globe'
) {
} else if (m.kind === 'tilt') {
tickTilt(cam, m, scale, dt);
} else if (m.kind === 'rotate' || m.kind === 'rotate_globe') {
const degS = TILT_DEG_S[m.speed];
const stepRad =
m.mode === 'once'
? Cesium.Math.toRadians(
m.kind === 'tilt' ? ONCE.tiltDeg : ONCE.rotateDeg,
) *
(scale - (m.lastScale || 0))
? Cesium.Math.toRadians(ONCE.rotateDeg) * (scale - (m.lastScale || 0))
: Cesium.Math.toRadians(degS) * dt;
m.lastScale = scale;
if (m.kind === 'rotate_globe') {
// Tracking can acquire the camera without going through navigation.
// Yield without resetting its pose/frame: the world-space arc proof
// only describes Cesium's rotation while the frame is identity.
if (
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
_viewer.trackedEntity ||
_isCockpitActive?.() ||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
) {
if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) {
interruptCameraMotion('globe-camera-owner-changed');
return;
}
@@ -1218,18 +1437,14 @@ function onTick() {
return;
}
cam.rotate(m.rotationAxis, angle);
} else if (m.kind === 'tilt') {
const up = m.direction === 'up';
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky.
const next = cam.pitch + (up ? stepRad : -stepRad);
if (next > PITCH_MAX || next < PITCH_MIN) {
interruptCameraMotion('tilt-clamp');
return;
}
if (up) cam.lookUp(stepRad);
else cam.lookDown(stepRad);
} else if (m.direction === 'left') cam.lookLeft(stepRad);
else cam.lookRight(stepRad);
} else {
lookWithinFrame(
cam,
m,
m.direction === 'left' ? 'lookLeft' : 'lookRight',
stepRad,
);
}
} else if (m.kind === 'route') {
// The dolly's shaping lives in advanceRouteFlight(); the tick only
// applies the pose and honours the same completion path as before.
@@ -1252,6 +1467,186 @@ function onTick() {
}
}
/**
* One frame of tilt. With a ground pivot the camera swings about it (lookAt),
* so the place under the crosshair stays centred; without one it looks up or
* down in place. Every angle and limit is the camera's own pitch, which at
* regional range is several degrees steeper than the pivot's (the Earth
* curves away), so the swing solves for the pivot pitch that gives it. Pitch
* is clamped to [-89°, -5°] only in the direction of travel: a top-down view
* sits at -90°, below the limit, and must still be able to tilt up out of it.
* A pivot swing toward the horizon lowers the camera, so it stops at
* TILT_MIN_CLEARANCE_M and the rest tilts in place. Farther out it stops with
* the camera TILT_MIN_PIVOT_ELEVATION above the place's horizon instead, and
* keeps the place centred: tilting in place there would lift the crosshair
* onto the sky.
*/
function tickTilt(cam, m, scale, dt) {
const from = cam.pitch;
const want =
m.mode === 'once'
? m.startPitch + (m.goalPitch - m.startPitch) * scale
: from +
Cesium.Math.toRadians(TILT_DEG_S[m.speed]) *
dt *
(m.direction === 'up' ? 1 : -1);
const up = want > from;
const limited = up
? Math.min(want, Math.max(from, PITCH_MAX))
: Math.max(want, Math.min(from, PITCH_MIN));
let atLimit = false;
if (m.hpr) {
// Apply a range a zoom set since the last frame, even when the pitch
// then has nowhere to go (a zoom in to the clearance ends the pivot).
cam.lookAt(m.target, m.hpr);
// Up toward the horizon the swing lowers the camera: hold the clearance,
// and keep the place below the horizon.
const cap = up
? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range))
: Infinity;
for (let i = 0; i < 3 && Math.abs(limited - cam.pitch) > 1e-6; i += 1) {
const pitch = Math.min(
cap,
Math.max(-Cesium.Math.PI_OVER_TWO, m.hpr.pitch + limited - cam.pitch),
);
if (pitch === m.hpr.pitch) break;
m.hpr.pitch = pitch;
cam.lookAt(m.target, m.hpr);
}
// A once motion that just completed released the lookAt frame before
// this last step re-took it.
if (_active !== m) clearLookAt();
if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) {
if (tiltClearanceBinds(m.hpr.range)) {
// Down to its clearance: let go of the pivot and tilt the rest in
// place, which keeps the height.
if (_active === m) clearLookAt();
m.hpr = null;
m.target = null;
} else {
// The place is at its horizon limit: stop here, still centred.
atLimit = true;
}
}
}
if (!m.hpr) tiltInPlaceWithinFrame(cam, m, limited);
if (m.mode !== 'once' && (limited !== want || atLimit))
interruptCameraMotion('tilt-clamp');
}
/** The refusal for a tilt up the camera has no room left for. */
function tiltUpLimitRefusal() {
return {
ok: false,
action: 'move_camera',
error:
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
};
}
/** Shallowest pivot pitch that keeps the clearance at this range. */
function tiltPivotPitchMax(range) {
return Math.min(
-Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)),
-TILT_MIN_PIVOT_ELEVATION,
);
}
/**
* Whether the clearance, not the horizon, stops a swing at this range. Only
* then does the rest of the tilt happen in place: that close in (under ~17
* km) the camera's -5° limit stays well below its own horizon.
*/
function tiltClearanceBinds(range) {
return TILT_MIN_CLEARANCE_M / range >= Math.sin(TILT_MIN_PIVOT_ELEVATION);
}
/**
* The shallowest camera pitch a tilt starting from this frame can reach with
* the place under the crosshair still in view: PITCH_MAX, or less where a
* far pivot meets its horizon limit first.
*/
function tiltUpLimit({ target, pivot }) {
if (!pivot || tiltClearanceBinds(pivot.range)) return PITCH_MAX;
return Math.min(
PITCH_MAX,
pivotCameraPitch(
target,
pivot,
Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)),
),
);
}
/** The camera pitch lookAt gives for this pivot at the given pivot pitch. */
function pivotCameraPitch(target, { heading, range }, pitch) {
const offset = new Cesium.Cartesian3(
-range * Math.cos(pitch) * Math.sin(heading),
-range * Math.cos(pitch) * Math.cos(heading),
-range * Math.sin(pitch),
);
const eye = Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
offset,
new Cesium.Cartesian3(),
);
const look = Cesium.Cartesian3.subtract(target, eye, new Cesium.Cartesian3());
Cesium.Cartesian3.normalize(look, look);
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
const upAtEye = ellipsoid.geodeticSurfaceNormal(eye, new Cesium.Cartesian3());
return Math.asin(
Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1),
);
}
/**
* Where a tilt starts. It swings about the ground under the crosshair so the
* place stays centred. It tilts in place with no ground there, from globe
* range, or while an entity is tracked (the follow camera owns that frame;
* voice releases it before moving, so a voice tilt still pivots).
*/
function tiltStartFrame(cam) {
const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer);
let pivot = target ? tiltPivot(cam, target) : null;
if (pivot && !tiltPivotInReach(pivot.range)) pivot = null;
return { target, pivot, from: cam.pitch };
}
/** Whether a tilt may pivot at this range: up to and including the cutoff. */
function tiltPivotInReach(range) {
return range <= TILT_PIVOT_MAX_RANGE_M + TILT_PIVOT_RANGE_SLACK_M;
}
/** The reply's note for a tilt the place's horizon stops short of its angle. */
function tiltHorizonNote(pitch) {
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`;
}
/** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */
function tiltPivot(cam, target) {
const local = Cesium.Matrix4.multiplyByPoint(
Cesium.Matrix4.inverseTransformation(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
new Cesium.Matrix4(),
),
cam.positionWC,
new Cesium.Cartesian3(),
);
const range = Cesium.Cartesian3.magnitude(local);
if (!(range > 1) || local.z <= 0) return null;
// Near straight overhead the offset's heading is float noise (it flipped a
// north-up London view to south); keep the camera's own.
const heading =
Math.hypot(local.x, local.y) > range * 0.01
? Math.atan2(-local.x, -local.y)
: cam.heading;
return new Cesium.HeadingPitchRange(
heading,
-Math.asin(Math.min(1, local.z / range)),
range,
);
}
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
function globeRotationHasClearance(position) {
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
@@ -1413,6 +1808,7 @@ export function initCameraVerbs(
_isCockpitActive =
typeof isCockpitActive === 'function' ? isCockpitActive : null;
_viewer = viewer;
_lookBaseline = null;
if (_tickRemover) _tickRemover();
_tickRemover = viewer.clock.onTick.addEventListener(onTick);
if (_trackingRemover) _trackingRemover();
@@ -1421,18 +1817,108 @@ export function initCameraVerbs(
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
if (entity && _active?.kind === 'rotate_globe')
interruptCameraMotion('globe-camera-owner-changed');
// A pivoting tilt's lookAt would fight the follow camera every frame.
else if (entity && _active?.kind === 'tilt' && _active.hpr)
interruptCameraMotion('tilt-camera-owner-changed');
});
for (const rm of _inputRemovers) rm();
_inputRemovers = [];
const canvas = viewer.scene.canvas;
// ANY manual camera input reclaims control — the cancelFlight reflex.
for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
const h = () => interruptCameraMotion('manual-input');
const h = () => {
_manualInputCount += 1;
interruptCameraMotion('manual-input');
};
canvas.addEventListener(evt, h, { passive: true });
_inputRemovers.push(() => canvas.removeEventListener(evt, h));
}
}
/**
* A tilt's angle: the one asked for, the default oblique view when a bare
* "tilt the camera" names neither angle nor direction, or null for a nudge.
*/
function requestedTiltPitch(args, direction) {
// Only a real number or numeric text: Number('') and Number(null) are 0,
// which would swing the camera to the horizon.
const raw = args.pitchDeg;
const requested =
typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')
? Number(raw)
: NaN;
// The camera never looks above the horizon, so a positive angle can only
// mean the same angle below it ("tilt 45 degrees" sent as 45).
if (Number.isFinite(requested))
return Cesium.Math.clamp(
Cesium.Math.toRadians(-Math.abs(requested)),
PITCH_MIN,
PITCH_MAX,
);
return direction === null
? Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG)
: null;
}
/**
* Argument errors move_camera refuses whatever the camera is doing, so a
* caller holding a motion back behind a flight can refuse it before waiting.
* @param {Object} args
* @returns {Object|null} The refusal, or null when the arguments are usable.
*/
export function moveCameraArgsError(args = {}) {
const motion = String(args.motion || '').toLowerCase();
const direction = args.direction
? String(args.direction).toLowerCase()
: null;
if (motion === 'stop') return null;
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
return {
ok: false,
action: 'move_camera',
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
};
}
if (
motion !== 'orbit' &&
motion !== 'rotate_globe' &&
(motion !== 'tilt' || requestedTiltPitch(args, direction) === null) &&
!['left', 'right', 'up', 'down'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: `${motion} needs a direction (${motion === 'tilt' ? 'up/down' : motion === 'rotate' ? 'left/right' : 'left/right/up/down'}).`,
};
}
if (motion === 'tilt' && (direction === 'left' || direction === 'right')) {
return {
ok: false,
action: 'move_camera',
error: 'tilt needs up or down, or pitchDeg alone for an angle.',
};
}
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
return {
ok: false,
action: 'move_camera',
error: 'rotate needs left or right.',
};
}
if (
motion === 'rotate_globe' &&
direction !== null &&
!['left', 'right'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: 'rotate_globe needs left or right.',
};
}
return null;
}
/**
* move_camera implementation. Returns the house result shape; rejections are
* plain-English `ok:false` errors.
@@ -1465,54 +1951,66 @@ export function moveCamera(args = {}, runNavigation = null) {
};
return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
}
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
return {
ok: false,
action: 'move_camera',
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
};
}
if (
motion !== 'orbit' &&
motion !== 'rotate_globe' &&
!['left', 'right', 'up', 'down'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`,
};
}
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
return {
ok: false,
action: 'move_camera',
error: 'rotate needs left or right.',
};
}
if (
motion === 'rotate_globe' &&
direction !== null &&
!['left', 'right'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: 'rotate_globe needs left or right.',
};
}
if (motion === 'tilt') {
// Honest limits: at the clamp a tilt is a no-op — say so instead of
// reporting success and letting the model gaslight itself (field test).
const pitch = _viewer.camera.pitch;
if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) {
const argsError = moveCameraArgsError(args);
if (argsError) return argsError;
const tiltToPitch =
motion === 'tilt' ? requestedTiltPitch(args, direction) : null;
if (motion === 'tilt' && tiltToPitch !== null) {
// Angles and limits are the camera's own pitch, whatever the swing pivots
// about; releasing the follow camera does not change it.
const from = _viewer.camera.pitch;
// -89 or -90 means top-down, which a -90° view already is: the clamp
// must not tilt it up 1°.
const atTopDown = tiltToPitch <= PITCH_MIN && from <= PITCH_MIN;
if (
atTopDown ||
Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)
) {
// Still a camera request: it stops a running motion and releases the
// follow camera, as a tilt that moves would.
const hold = () => {
interruptCameraMotion('replaced');
return {
ok: true,
action: 'move_camera',
motion,
pitchDeg: Math.round(
Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch),
),
unchanged: true,
};
};
return typeof runNavigation === 'function' ? runNavigation(hold) : hold();
}
// pitchDeg is an absolute angle, not a step: "tilt down 10 degrees" sent
// as {direction:'down', pitchDeg:10} would tilt up from -45°.
const towards = tiltToPitch > from ? 'up' : 'down';
if ((direction === 'up' || direction === 'down') && direction !== towards) {
const target = Math.round(Cesium.Math.toDegrees(tiltToPitch));
const now = Math.round(Cesium.Math.toDegrees(from));
return {
ok: false,
action: 'move_camera',
error:
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
error: `pitchDeg is an absolute angle, not a step: ${target}° from ${now}° tilts ${towards}, not ${direction}. Send direction alone for a nudge, or pitchDeg alone for an angle.`,
};
}
if (
towards === 'up' &&
from >=
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
)
return tiltUpLimitRefusal();
} else if (motion === 'tilt') {
// Honest limits: at the clamp a tilt is a no-op — say so instead of
// reporting success and letting the model gaslight itself (field test).
const pitch = _viewer.camera.pitch;
if (
direction === 'up' &&
pitch >=
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
) {
return tiltUpLimitRefusal();
}
if (
direction === 'down' &&
pitch <= PITCH_MIN + Cesium.Math.toRadians(0.5)
@@ -1524,6 +2022,34 @@ export function moveCamera(args = {}, runNavigation = null) {
};
}
}
// A tilt is judged by the way its pitch will move: toward its angle, or a
// nudge up or down; a rotate turns left only when told to, as it runs.
const look =
motion === 'tilt'
? tiltLook(
_viewer.camera.pitch,
tiltToPitch ??
_viewer.camera.pitch +
Cesium.Math.toRadians(ONCE.tiltDeg) *
(direction === 'up' ? 1 : -1),
)
: motion === 'rotate'
? direction === 'left'
? 'lookLeft'
: 'lookRight'
: null;
// Honest limits from orbit too: a stopped turn rests within 0.05° of its
// limit, so one that cannot go half a degree further is refused rather than
// run and reported as done, and one that can is then turned at least that
// far. Near the limit the sampled share wobbles as the planet's edge crosses
// the grid, so a turn that would keep the planet can occasionally be refused
// too.
// A camera that is tracked or locked now is judged as the navigation that
// runs the turn will release it, so a refusal keeps the tracking.
const lookRefusal =
look &&
checkLookFromOrbit(look, { released: typeof runNavigation === 'function' });
if (lookRefusal) return lookRefusal;
if (
(motion === 'rotate_globe' ||
(motion === 'orbit' && typeof runNavigation !== 'function')) &&
@@ -1607,14 +2133,70 @@ export function moveCamera(args = {}, runNavigation = null) {
error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
};
}
// The angle a tilt to an angle stops at short of it, and why.
let stoppedAt = null;
let stopNote = null;
const start = () => {
interruptCameraMotion('replaced');
// Checked again only if the release left the camera somewhere other than
// where the check above expected it.
if (look && !lookProven(_viewer.camera, look)) {
const refusal = checkLookFromOrbit(look);
if (refusal) return refusal;
}
const state = {
kind: motion,
direction: direction || 'right',
speed,
mode,
};
if (motion === 'tilt') {
const frame = tiltStartFrame(_viewer.camera);
const { target, pivot, from } = frame;
if (pivot) {
state.target = target;
state.hpr = pivot;
}
state.startPitch = from;
const upLimit = tiltUpLimit(frame);
if (tiltToPitch !== null) {
// From far out the place reaches its horizon before the camera
// reaches the asked angle: stop there, and say so.
let goal =
tiltToPitch > from
? Math.min(tiltToPitch, Math.max(from, upLimit))
: tiltToPitch;
if (goal !== tiltToPitch) stopNote = tiltHorizonNote(goal);
// From orbit an in-place tilt stops where the planet would leave the
// frame: aim it there, and say so.
if (!pivot) {
const framed = tiltPitchWithinFrame(_viewer.camera, goal);
if (framed !== goal) {
goal = framed;
stopNote = tiltFrameNote(goal);
}
}
state.mode = 'once';
state.direction = goal > from ? 'up' : 'down';
state.goalPitch = goal;
// A zoom mid-tilt moves the horizon limit; it re-judges the goal
// against the angle asked for.
state.askedPitch = tiltToPitch;
state.durationS = Math.max(
ONCE.durationS,
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
);
if (goal !== tiltToPitch) stoppedAt = goal;
} else {
// A nudge stops at the limit it travels toward.
const step =
Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1);
state.goalPitch =
step > 0
? Math.min(from + step, Math.max(from, upLimit))
: Math.max(from + step, Math.min(from, PITCH_MIN));
}
}
if (motion === 'rotate_globe') {
// Convert an inherited ground-target frame without changing the world
// pose. Rotation must pivot about Earth, never the previous local target.
@@ -1633,9 +2215,11 @@ export function moveCamera(args = {}, runNavigation = null) {
}
if (motion === 'orbit') {
if (_viewer.scene.tweens.length > 0) {
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
// and capture at the destination; a target existing right now is
// meaningless (the globe is always under the crosshair).
// A camera flight is in progress — ALWAYS arm and capture at the
// destination; a target existing right now is meaningless (the globe
// is always under the crosshair). The voice runner already holds an
// orbit until its own fly-to lands; this covers any other
// flight.
setActiveMotion({
kind: 'orbit',
direction: direction || 'right',
@@ -1699,7 +2283,11 @@ export function moveCamera(args = {}, runNavigation = null) {
motion,
direction: state.direction,
speed,
mode,
mode: state.mode,
...(tiltToPitch !== null && {
pitchDeg: Math.round(Cesium.Math.toDegrees(stoppedAt ?? tiltToPitch)),
}),
...(stopNote !== null && { note: stopNote }),
};
};
const preserveCameraFlight =
@@ -1714,16 +2302,93 @@ export function moveCamera(args = {}, runNavigation = null) {
/**
* Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom
* fight the per-frame lookAt (field test: "zoom out did literally nothing").
* A tilt swinging about its pivot holds a range the same way.
* @param {number} factor >1 zooms out (radius grows), <1 zooms in.
* @returns {boolean} true when an orbit consumed the zoom.
* @returns {{kind: string, changed: boolean, pitchDeg?: number,
* note?: string}|null} the motion that consumed the zoom, and whether its
* range moved; null when none did. A tilt to an angle whose end the new
* range moves also names the angle it now ends at (pitchDeg), with a note.
*/
export function adjustOrbitRange(factor) {
if (!_active || _active.kind !== 'orbit' || !_active.hpr) return false;
_active.hpr.range = Math.max(
80,
Math.min(5_000_000, _active.hpr.range * factor),
if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr)
return null;
// A tilt's zoom in stops at the range that keeps its clearance. The limits
// never move a view the wrong way: a zoom in leaves a closer view in place,
// and a zoom out leaves a farther one.
const { kind, hpr } = _active;
const floor =
kind === 'tilt'
? Math.max(80, TILT_MIN_CLEARANCE_M / Math.abs(Math.sin(hpr.pitch)))
: 80;
// A tilt pivots only within TILT_PIVOT_MAX_RANGE_M; farther out the swing
// would carry the camera thousands of km.
const ceiling = kind === 'tilt' ? TILT_PIVOT_MAX_RANGE_M : 5_000_000;
const range = hpr.range;
hpr.range = Math.max(
Math.min(floor, range),
Math.min(Math.max(ceiling, range), range * factor),
);
return true;
// Under half a metre counts as not moved, as for a plain zoom.
const changed = Math.abs(hpr.range - range) >= 0.5;
return { kind, changed, ...(changed && retargetTiltAfterZoom(_active)) };
}
/**
* A zoom changed the range a tilt to an angle holds, and with it the place's
* horizon limit: aim the tilt at the angle asked for or the new limit,
* whichever comes first, from where the camera is now. The pivot pitch is
* re-solved so the new range keeps the camera's pitch, and a limit the camera
* is already past is eased down to rather than jumped to. Returns the end
* angle and a note for the reply when that end moved, otherwise nothing.
*/
function retargetTiltAfterZoom(m) {
if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined)
return {};
let from = _viewer.camera.pitch;
if (!(m.askedPitch > from)) return {};
if (m.hpr) {
// At the same pivot pitch a longer range looks down more steeply (the
// Earth curves away): find the pivot pitch that holds this camera pitch.
// It stays below the place's horizon limit (or where the pivot already
// was): past it the place drops behind the Earth, so a camera the zoom
// leaves past that limit steps down to it with the zoom.
const cap = Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range));
for (let i = 0; i < 4; i += 1) {
const error = from - pivotCameraPitch(m.target, m.hpr, m.hpr.pitch);
if (Math.abs(error) < 1e-7) break;
const pitch = Cesium.Math.clamp(
m.hpr.pitch + error,
-Cesium.Math.PI_OVER_TWO,
cap,
);
if (pitch === m.hpr.pitch) break;
m.hpr.pitch = pitch;
}
from = Math.min(from, pivotCameraPitch(m.target, m.hpr, m.hpr.pitch));
}
const limit =
m.hpr && !tiltClearanceBinds(m.hpr.range)
? Math.min(
PITCH_MAX,
pivotCameraPitch(m.target, m.hpr, tiltPivotPitchMax(m.hpr.range)),
)
: PITCH_MAX;
const goal = Math.min(m.askedPitch, limit);
if (Math.abs(goal - m.goalPitch) < 1e-6) return {};
m.goalPitch = goal;
m.startPitch = from;
m.elapsed = 0;
m.durationS = Math.max(
ONCE.durationS,
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
);
const limited = goal !== m.askedPitch;
return {
pitchDeg: Math.round(Cesium.Math.toDegrees(goal)),
note: limited
? tiltHorizonNote(goal)
: `The tilt now reaches ${Math.round(Cesium.Math.toDegrees(goal))}°.`,
};
}
/**
+19 -6
View File
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
ui.indexOf("if (normalized === 'exit') {"),
);
assert.ok(branch, 'cockpit enter branch is missing');
const gateIndex = branch.indexOf('isEntryAllowed?.()');
const gateIndex = branch.indexOf('this._cockpitEntryRefusal()');
const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
assert.ok(
gateIndex >= 0,
'entry consults the same gate the manual chip uses',
'entry refuses on the gate it can check before tracking',
);
assert.ok(
retargetIndex > gateIndex,
@@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
ui.indexOf("if (normalized === 'enter') {"),
ui.indexOf("if (normalized === 'exit') {"),
);
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/);
assert.match(branch, /Contacts must be active to enter Cockpit/);
assert.match(branch, /Contacts is still starting up/);
// Contacts is not required: only a Context change in flight or no aircraft
// layer to ride shuts the gate before tracking, and the entry itself then
// applies the manual chip's gate to the aircraft it rides.
assert.match(
branch,
/const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/,
);
assert.doesNotMatch(branch, /Contacts must be active/);
const refusal = ui.slice(
ui.indexOf(' _cockpitEntryRefusal() {'),
ui.indexOf(' getCockpitState() {'),
);
assert.match(refusal, /Context is still changing/);
assert.match(refusal, /No aircraft layer is on/);
assert.doesNotMatch(refusal, /[Cc]ontacts/);
});
test('cockpit state cannot report entryAllowed while already active', () => {
@@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => {
);
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
assert.match(state, /'contacts-starting'/);
assert.match(state, /'contacts-inactive'/);
assert.match(state, /'subject-layer-off'/);
assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required');
assert.match(state, /'no-tracked-aircraft'/);
});
+34
View File
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
],
});
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
const f = fixture(t);
const crowded = {
...snapshot(),
cohorts: [
{
id: 'flights',
count: 6,
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
id: `n${n}`,
label: `N${n}`,
distanceM: n * 1000,
})),
},
],
};
updateCockpitSignals.call(f.owner, crowded, 0, {
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
});
await nextTick();
assert.equal(f.owner.signalItems.length, 5);
assert.equal(f.owner.signalItems[0].key, 'feed-status');
assert.equal(f.owner.signalItems[0].tone, 'warning');
assert.equal(f.owner.signalItems[0].target, null);
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
updateCockpitSignals.call(f.owner, crowded, 0);
assert.equal(
f.owner.signalItems.some((item) => item.key === 'feed-status'),
false,
'with the feed back, the row goes',
);
});
test('rows render the signals the controller presents', async (t) => {
const f = fixture(t);
await render(f, [
+23 -16
View File
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
return restored;
}
/** The layers an aircraft Cockpit subject can come from. */
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
'flights',
'military',
]);
/**
* Cockpit entry belongs to the operational Contacts context bundle. A tracked
* aircraft alone is not sufficient: both observed-flight feeds must still be
* active so the cockpit's surrounding-contact picture is not presented as a
* complete context view when one source is disabled.
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
* (Live Flights for a civilian, Military Flights for a military aircraft) must
* be on. Contacts and the other feeds are not required. When a source the
* surrounding picture uses is off, the Cockpit's Contact card and Live
* Signals say the picture is partial instead of blocking entry, so a partial
* picture is never presented as complete. A Context mode change in flight
* still owns the camera, so entry waits it out.
*
* @param {object} input Current context and dependency visibility.
* @param {string|null} input.contextMode Active Context mode.
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled.
* @param {object} input Current subject and Context state.
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
* ('flights' or 'military'), or null with none tracked.
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
* @returns {boolean} Whether cockpit entry may be offered or activated.
*/
export function cockpitEntryAllowed({
contextMode,
subjectLayerId,
subjectLayerEnabled,
contextModeChanging,
flightsEnabled,
militaryEnabled,
}) {
return (
contextMode === 'flights' &&
!contextModeChanging &&
Boolean(flightsEnabled) &&
Boolean(militaryEnabled)
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
Boolean(subjectLayerEnabled) &&
!contextModeChanging
);
}
+18 -23
View File
@@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued
}
});
test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => {
test("cockpit entry needs only the tracked aircraft's own layer", () => {
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: true,
}), true);
}), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: 'military',
subjectLayerEnabled: true,
contextModeChanging: false,
}), true, 'a military aircraft needs Military Flights alone');
assert.equal(cockpitEntryAllowed({
subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: true,
flightsEnabled: true,
militaryEnabled: true,
}), false, 'Cockpit remains unavailable until Contacts activation settles');
}), false, 'Cockpit remains unavailable until a Context mode change settles');
assert.equal(cockpitEntryAllowed({
contextMode: null,
subjectLayerId: 'military',
subjectLayerEnabled: false,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: true,
}), false, 'ordinary layer selection is not an operational Contacts context');
}), false, "the ridden aircraft's own layer is required");
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: null,
subjectLayerEnabled: false,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: false,
}), false, 'the military contact feed is required');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
contextModeChanging: false,
flightsEnabled: false,
militaryEnabled: true,
}), false, 'the civilian contact feed is required');
}), false, 'there is no aircraft to ride');
});
test('context teardown guard restores coordination state after success and failure', async () => {
+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;
+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'],
+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({
+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 {
DEFAULT_CCTV_MAX_SOURCES,
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 shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 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(
+45
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) {
@@ -181,6 +189,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,
{
@@ -302,6 +321,15 @@ export const DATA_CREDITS = [
'(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' +
'(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel',
},
{
key: 'iceland-cctv',
html:
'CCTV cameras &amp; frames (Iceland): road camera images, fetched live, from the ' +
'<a href="https://www.vegagerdin.is/" target="_blank" rel="noopener">Icelandic Road and Coastal Administration (Vegagerðin)</a> — ' +
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a> ' +
'(<a href="https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions" target="_blank" rel="noopener">terms</a>); ' +
'no endorsement by IRCA is implied',
},
{
key: 'kingcounty-cctv',
html:
@@ -311,6 +339,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:
@@ -572,6 +608,15 @@ 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).
+12
View File
@@ -129,6 +129,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',
+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' }), []);
});
+151
View File
@@ -15,6 +15,7 @@ import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { HEAT_REBUILD_MS } from '../layers/firms/policy.js';
const LAYER_SOURCE = readLayerSource(
path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'),
@@ -392,3 +393,153 @@ test('disable cancels a pending source and a late response cannot repopulate the
assert.equal(h.layer.getStats().loading, false);
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
});
test('the heatmap is preloaded under the markers before the first zoom-out', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const priorDocument = globalThis.document;
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }),
putImageData() {},
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
}),
};
const h = harness({
withDataSource: true,
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000),
});
h.viewer.camera = { positionCartographic: { height: 1_000_000 } };
h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true };
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
const added = [];
try {
h.layer.attachShellServices({
imageryHost: () => ({
kind: 'globe',
collection: {
addImageryProvider: (provider) => {
const layer = { provider, show: true, alpha: 1 };
added.push(layer);
return layer;
},
remove() {},
raiseToTop() {},
},
}),
});
h.layer._renderLodForTest('close');
assert.equal(added.length, 0, 'the markers paint first; the build trails');
t.mock.timers.tick(HEAT_REBUILD_MS);
assert.equal(added.length, 1, 'then the heatmap is draped');
assert.equal(added[0].alpha, 0, 'invisible until the hand-off');
assert.equal(added[0].show, true, 'but mapped onto the globe tiles');
} finally {
h.layer.destroy(h.viewer);
h.cleanup();
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
}
});
test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', () => {
// A canvas stub for the heat drape and the detection glow sprites.
const priorDocument = globalThis.document;
const restoreDocument = () => {
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
};
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, hh) => ({
data: new Uint8ClampedArray(w * hh * 4),
}),
putImageData() {},
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
}),
};
const h = harness({
fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })],
withDataSource: true,
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000),
});
const added = [];
const removed = [];
let markers = null;
// The fade reads the camera height; the cull reads scene.camera.
const setHeight = (height) => {
h.viewer.camera = { positionCartographic: { height } };
};
setHeight(12_000_000);
h.viewer.scene.primitives = {
add(primitive) {
markers = primitive;
},
remove() {},
raiseToTop() {},
contains: () => true,
};
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
try {
const collection = {
addImageryProvider: (provider) => {
const layer = { provider, show: true, alpha: 1 };
added.push(layer);
return layer;
},
remove: (layer, destroy) => removed.push({ layer, destroy }),
raiseToTop() {},
};
h.layer.attachShellServices({
imageryHost: () => ({ kind: 'globe', collection }),
});
h.layer._renderLodForTest('global');
assert.equal(added.length, 1, 'one heat imagery layer');
assert.equal(added[0].show, true);
assert.equal(added[0].alpha, 1);
assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities');
assert.ok(h.published.length > 0, 'the far band still paints cards');
assert.equal(markers.length, 2, 'the markers are built under the heatmap');
assert.equal(markers.show, false, 'and hidden while the heatmap is whole');
h.layer._renderLodForTest('regional');
assert.equal(added.length, 1, 'switching far bands reuses the raster');
setHeight(1_000_000);
h.layer._renderLodForTest('close');
assert.equal(added.length, 1, 'the markers band keeps the same heat layer');
assert.equal(added[0].show, true, 'still mapped onto the globe tiles');
assert.equal(added[0].alpha, 0, 'faded out under the markers');
assert.equal(markers.show, true);
assert.equal(markers.get(0).color.alpha, 1);
// Mid hand-off both are drawn, half each, in either band.
setHeight(4_500_000);
h.layer._applyHandoffFadeForTest();
assert.equal(added[0].alpha, 0.5);
assert.equal(markers.show, true);
assert.equal(markers.get(0).color.alpha, 0.5);
h.layer._renderLodForTest('regional');
assert.equal(added[0].alpha, 0.5);
assert.equal(markers.get(1).color.alpha, 0.5, 'rebuilt markers get the fade');
h.layer.disable();
assert.equal(added[0].show, false, 'disable hides the heatmap');
h.layer.destroy(h.viewer);
assert.deepEqual(
removed.map((entry) => [entry.layer, entry.destroy]),
[[added[0], true]],
);
} finally {
h.cleanup();
restoreDocument();
}
});
+14 -9
View File
@@ -4,6 +4,8 @@
* dedicated card canvas moved into the shared world-overlay host.
*/
import { FRP_RAMP, hexToRgb } from './firmsIntensity.js';
/** Shipped ambient FIRMS card ceiling. Selected fires bypass this cohort. */
export const FIRMS_AMBIENT_COHORT_LIMIT = 18;
export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
@@ -23,20 +25,23 @@ export function fireDetectionKey(fire) {
return `firms:${lat}:${lon}:${acq}:${source}`;
}
/** Severity accent palette — matches the FIRMS glow-sprite color stops. */
const ACCENT_RGB = Object.freeze({
red: '224, 82, 82',
orange: '240, 178, 62',
yellow: '244, 227, 108',
});
/**
* Severity accent palette — the FIRMS glow-sprite color stops (the six-stop
* FRP ramp in firmsIntensity.js) as "r, g, b" strings for card accents.
*/
const ACCENT_RGB = Object.freeze(
Object.fromEntries(
FRP_RAMP.map((stop) => [stop.name, hexToRgb(stop.hex).join(', ')]),
),
);
/**
* Severity stop name → "r, g, b" accent string (defaults to yellow).
* @param {string} stopName - 'red' | 'orange' | 'yellow'.
* Severity stop name → "r, g, b" accent string (defaults to the weakest stop).
* @param {string} stopName - A FRP_RAMP stop name ('ember' … 'white-hot').
* @returns {string}
*/
export function accentForSeverity(stopName) {
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
return ACCENT_RGB[stopName] || ACCENT_RGB[FRP_RAMP[0].name];
}
/**
+10 -5
View File
@@ -9,11 +9,16 @@ import {
formatFrpMw,
} from './firmsLabels.js';
test('FIRMS formatting helpers retain the shipped severity palette', () => {
assert.equal(accentForSeverity('red'), '224, 82, 82');
assert.equal(accentForSeverity('orange'), '240, 178, 62');
assert.equal(accentForSeverity('yellow'), '244, 227, 108');
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
test('FIRMS card accents are the six FRP ramp stops, weakest as the fallback', () => {
// Ember #9e2a1b … white-hot #fff3b0, lightness rising with power.
assert.equal(accentForSeverity('ember'), '158, 42, 27');
assert.equal(accentForSeverity('red'), '210, 64, 31');
assert.equal(accentForSeverity('flame'), '240, 106, 40');
assert.equal(accentForSeverity('orange'), '251, 154, 46');
assert.equal(accentForSeverity('gold'), '253, 200, 74');
assert.equal(accentForSeverity('white-hot'), '255, 243, 176');
assert.equal(accentForSeverity('blue'), accentForSeverity('ember'));
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('ember'));
});
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
+5
View File
@@ -1,5 +1,10 @@
# Test fixtures
- `photon-moscow-search.json` - Photon responses for `Москва`, captured
2026-11-05 with and without a proximity bias near the Tajikistan namesake.
The fixture is trimmed to the fields the keyless geocoder consumes and is
used only by offline search tests. Data from OpenStreetMap contributors,
ODbL 1.0 (https://www.openstreetmap.org/copyright).
- `tomtom-flow-austin-12-935-1686.pbf` — one real TomTom traffic-flow vector
tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin
z12 x935 y1686, captured 2026-07-16 from
+110
View File
@@ -0,0 +1,110 @@
{
"biased": {
"features": [
{
"geometry": {
"type": "Point",
"coordinates": [71.8339931, 38.948412]
},
"properties": {
"name": "Москва",
"country": "Тоҷикистон"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [37.6063916, 55.625578]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [37.6174782, 55.7505412]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [37.0536908, 55.7098009]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [32.163334, 56.917778]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
}
]
},
"unbiased": {
"features": [
{
"geometry": {
"type": "Point",
"coordinates": [37.6063916, 55.625578]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [37.6174782, 55.7505412]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [37.0536908, 55.7098009]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [32.163334, 56.917778]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [71.8339931, 38.948412]
},
"properties": {
"name": "Москва",
"country": "Тоҷикистон"
}
}
]
}
}
+4 -2
View File
@@ -139,6 +139,7 @@ export function createFlowSegmentIndex(flowSegments) {
roadType,
level,
closure,
flow,
};
const key = cellKey(
Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M),
@@ -158,7 +159,8 @@ export function createFlowSegmentIndex(flowSegments) {
const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M;
/**
* Nearest direction-compatible segment within 35 m of a projected point.
* `radiusFor(segment)` may tighten the radius per segment (never widen it).
* `radiusFor(segment)` may tighten the radius per segment (never widen it);
* 0 leaves the segment out.
*/
function nearest(px, py, bearing, radiusFor = null) {
const cx = Math.floor(px / CELL_SIZE_M);
@@ -177,7 +179,7 @@ export function createFlowSegmentIndex(flowSegments) {
candidate = true; // within radius, bearing not yet checked
if (radiusFor) {
const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg));
if (d2 > radius * radius) continue;
if (!(radius > 0) || d2 > radius * radius) continue;
}
const diff = bearingDiffDeg(seg.bearing, bearing);
if (
+1 -1
View File
@@ -82,7 +82,7 @@ test('infrastructure factory preserves identity and creates independent state wi
test('dataset URLs still name the complete bundled sources', () => {
for (const [file, count] of [
['datacenters', 4351],
['datacenters', 4352],
['dams', 704],
]) {
const lines = readFileSync(
+51 -1
View File
@@ -298,12 +298,46 @@ function satelliteFilterOption() {
}
const OPTION_GROUPS = Object.freeze({
// Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on
// the codec side; the panel's log slider rounds to it. Both default to
// "show everything": many of the strongest fires are rated low confidence.
// The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in
// firmsIntensity.js (pinned by layerState.test); spelled out here because
// this file runs in isolation in the token-ledger tests.
'local-firms': Object.freeze([
boundedIntegerOption('minFrp', 'f', 0, { min: 0, max: 10000 }),
enumOption('confidence', 'c', 'all', ['all', 'nominal', 'high'], {
all: 'a',
nominal: 'n',
high: 'h',
}),
]),
'wildlife-sightings': Object.freeze([
booleanOption('confirmed', 'c', false),
enumOption(
'taxa',
't',
'all',
['all', 'Aves', 'Mammalia', 'whales', 'Insecta', 'Plantae'],
{
all: 'a',
Aves: 'b',
Mammalia: 'm',
whales: 'w',
Insecta: 'i',
Plantae: 'p',
},
),
]),
traffic: Object.freeze([
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
tomtom: 't',
osm: 'o',
hybrid: 'h',
}),
// Street Traffic row toggles: flow-colored road lines, moving dots.
booleanOption('lines', 'l', false),
booleanOption('dots', 'd', true),
]),
'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
@@ -598,6 +632,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
optionOwner: 'cctv',
}),
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
Object.freeze({
id: 'drone-airspace',
token: '8',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
Object.freeze({
id: 'fire-perimeters',
@@ -621,7 +660,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
token: 'd',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }),
Object.freeze({
id: 'local-firms',
token: 'w',
disposition: 'enabled+options',
optionOwner: 'local-firms',
}),
Object.freeze({
id: 'military',
token: 'm',
@@ -709,6 +753,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'weather-satellite',
}),
Object.freeze({
id: 'wildlife-sightings',
token: '9',
disposition: 'enabled+options',
optionOwner: 'wildlife-sightings',
}),
Object.freeze({
id: 'wind',
token: 'k',
+139 -15
View File
@@ -29,6 +29,7 @@ import radioLayer from './radio.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js';
function deferred() {
let resolve;
@@ -202,8 +203,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 34);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 34);
assert.equal(REGISTERED_LAYER_IDS.length, 36);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 36);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,18 +243,18 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '8');
assert.equal(nextLayerStateToken(), '00');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '8',
bravo: '9',
alpha: '9',
bravo: '00',
}),
'00',
'01',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries([...'89'].map((digit) => [`prior-${digit}`, digit])),
...Object.fromEntries([...'9'].map((digit) => [`prior-${digit}`, digit])),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
assert.equal(
@@ -305,8 +306,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '8',
next: '9',
future: '00',
next: '01',
}),
true,
);
@@ -314,12 +315,13 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
() =>
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
future: '01',
}),
/next free token 8/,
/next free token 00/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
delete beforeLastDigit['wildlife-sightings'];
assert.equal(
validateLayerStateAllocations(beforeLastDigit, {
...beforeLastDigit,
@@ -339,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '8', competing: '8' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '9', competing: '9' },
),
/next free token 9/,
/next free token 00/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2694,6 +2696,48 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
);
});
test('wildlife confirmed and every taxa preset round-trip through the full share codec', () => {
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'wildlife-sightings').token,
'9',
);
for (const taxa of [
'all',
'Aves',
'Mammalia',
'whales',
'Insecta',
'Plantae',
]) {
const state = normalizeLayerState({
enabledLayerIds: ['wildlife-sightings'],
options: { 'wildlife-sightings': { confirmed: true, taxa } },
});
const encoded = encode(state);
assert.doesNotMatch(encoded, /undefined/);
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encoded)),
state,
);
}
});
test('drone restrictions uses digit 8 without colliding with fire perimeters', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=8.2'));
assert.deepEqual(decoded.enabledLayerIds, [
'drone-airspace',
'fire-perimeters',
]);
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'drone-airspace').token,
'8',
);
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
decoded,
);
});
test('GNSS interference uses digit 3 without colliding with other digit tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1'));
assert.deepEqual(decoded.enabledLayerIds, [
@@ -2991,9 +3035,89 @@ test('Clear All cancels a saved restore still waiting to start', async () => {
gate.resolve();
const results = await restore;
for (const id of ['earthquakes', 'flights']) {
assert.equal(results.find((result) => result.layerId === id).succeeded, false);
assert.equal(
results.find((result) => result.layerId === id).succeeded,
false,
);
}
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.equal(enabledLayerParam(sink), '');
coordinator.destroy();
});
/** Active Fires options decoded from a v2 link with these `lo` assignments. */
function firmsOptions(lo) {
const params = new URLSearchParams([
['v', '2'],
['l', 'w'],
]);
if (lo) params.set('lo', lo);
return decodeLayerStateParams(params).options['local-firms'];
}
test('Active Fires: the intensity filter rides share token w and round-trips', () => {
const state = createDefaultLayerState();
assert.deepEqual(state.options['local-firms'], {
minFrp: 0,
confidence: 'all',
});
state.enabledLayerIds = ['local-firms'];
state.options['local-firms'] = { minFrp: 20, confidence: 'high' };
const encoded = encode(state);
assert.equal(new URLSearchParams(encoded).get('l'), 'w');
// Other owners write their own explicit defaults into `lo` too (flights 3D,
// wind overlay); the fire assignments ride beside them.
const assignments = new URLSearchParams(encoded).get('lo').split('_');
assert.ok(assignments.includes('w.f.20'), assignments.join(' '));
assert.ok(assignments.includes('w.c.h'), assignments.join(' '));
const decoded = decodeLayerStateParams(new URLSearchParams(encoded));
assert.deepEqual(decoded.options['local-firms'], {
minFrp: 20,
confidence: 'high',
});
assert.deepEqual(
parseStoredLayerState(serializeStoredLayerState(state)).options[
'local-firms'
],
state.options['local-firms'],
);
// Defaults are omitted from the link: a plain `l=w` is "show everything".
const plain = createDefaultLayerState();
plain.enabledLayerIds = ['local-firms'];
assert.equal(
new URLSearchParams(encode(plain))
.get('lo')
.split('_')
.filter((assignment) => assignment.startsWith('w.')).length,
0,
);
assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' });
});
test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => {
// The codec spells its bounds out (it runs in isolation in the ledger
// tests); they must stay the layer's.
assert.equal(MIN_FRP_MW_MAX, 10000);
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
assert.equal(firmsOptions('w.f.300').minFrp, 300);
assert.equal(firmsOptions('w.f.10000').minFrp, 10000);
assert.equal(firmsOptions('w.f.10001').minFrp, 0, 'out of bounds → default');
assert.equal(firmsOptions('w.f.-5').minFrp, 0);
assert.equal(
firmsOptions('w.f.2.5').minFrp,
0,
'a third segment is rejected',
);
assert.equal(firmsOptions('w.c.a').confidence, 'all');
assert.equal(firmsOptions('w.c.n').confidence, 'nominal');
assert.equal(firmsOptions('w.c.h').confidence, 'high');
assert.equal(
firmsOptions('w.c.x').confidence,
'all',
'unknown code → default',
);
assert.deepEqual(firmsOptions('w.c.n_w.f.5'), {
minFrp: 5,
confidence: 'nominal',
});
});
+44 -33
View File
@@ -97,6 +97,10 @@ function withPublishedBase(
}
}
const preWildlifeRows = reservationRows.filter(
([id]) => id !== 'wildlife-sightings',
);
function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
mkdirSync(path.dirname(providerCodec), { recursive: true });
@@ -104,7 +108,12 @@ function writeCodecFixture(cwd, rows, entries) {
providerCodec,
readFileSync(new URL('./idSwitches.js', import.meta.url)),
);
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
let source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
if (!rows.some(([id]) => id === 'wildlife-sightings'))
source = source.replace(
/ Object\.freeze\(\{\s*id: 'wildlife-sightings',[\s\S]*?\}\),\n/,
'',
);
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2);
const additions = entries
@@ -128,7 +137,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 34);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 36);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{
@@ -139,6 +148,8 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
's2-ghostmaps': '5',
'sea-marks': '6',
'ski-pistes': '7',
'drone-airspace': '8',
'wildlife-sightings': '9',
},
);
assert.throws(
@@ -164,9 +175,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '8']])
parseLayerStateTokenReservations([...reservationRows, ['future', '00']])
.future,
'8',
'00',
);
assert.throws(
() =>
@@ -184,14 +195,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '8']] },
{ rows: [...preWildlifeRows, ['retired-layer', '9']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '9');
assert.equal(nextLayerStateToken(published), '00');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '9',
newcomer: '00',
}),
true,
);
@@ -199,15 +210,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '8',
competing: '9',
}),
/next free token 9/,
/next free token 00/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '8',
newcomer: '9',
}),
/changed or removed: retired-layer/,
);
@@ -215,8 +226,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '8']] }, (cwd) => {
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
withPublishedBase({ rows: [...preWildlifeRows, ['pr-a', '9']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -237,8 +248,8 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '8']],
[{ id: 'pr-b', token: '8' }],
[...preWildlifeRows, ['pr-b', '9']],
[{ id: 'pr-b', token: '9' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -246,10 +257,10 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '8'], ['pr-b', '8']],
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '9']],
[
{ id: 'pr-a', token: '8' },
{ id: 'pr-b', token: '8' },
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '9' },
],
);
const staleRebase = runCheck();
@@ -257,16 +268,16 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '9');
const validBaselineRows = [...reservationRows, ['pr-a', '8']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '8' }]);
assert.equal(nextLayerStateToken(published), '00');
const validBaselineRows = [...preWildlifeRows, ['pr-a', '9']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 9/);
assert.match(helper.stdout, /pr-b: 00/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -274,22 +285,22 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '8'], ['pr-b', '9']],
[...preWildlifeRows, ['pr-a', '9'], ['pr-b', '00']],
[
{ id: 'pr-a', token: '8' },
{ id: 'pr-b', token: '9' },
{ id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '00' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /35 published, 1 new/);
assert.match(corrected.stdout, /36 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'89'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...preWildlifeRows,
...digitRows,
]);
const beforeNine = { ...allDigits };
@@ -339,7 +350,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'89'].map((digit) => [
const priorDigits = [...'9'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -348,7 +359,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
token,
}));
const fixtureRows = [
...reservationRows,
...preWildlifeRows,
...priorDigits,
...fixtureLayers.map(({ id, token }) => [id, token]),
];
@@ -357,13 +368,13 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
assert.equal(codec.validateLayerStateRegistry(), true);
assert.equal(
nextLayerStateToken(
parseLayerStateTokenReservations([...reservationRows, ...priorDigits]),
parseLayerStateTokenReservations([...preWildlifeRows, ...priorDigits]),
),
'00',
);
assert.equal(
validateLayerStateAllocations(
LAYER_STATE_TOKEN_RESERVATIONS,
parseLayerStateTokenReservations(preWildlifeRows),
codec.LAYER_STATE_TOKEN_RESERVATIONS,
),
true,
@@ -376,7 +387,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 79);
assert.equal(params.get('l').length, 81);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -394,7 +405,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 35);
assert.equal(Object.keys(published).length, 37);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -7,6 +7,7 @@
["bikeshare", "b"],
["cctv", "c"],
["directions", "n"],
["drone-airspace", "8"],
["earthquakes", "e"],
["fire-perimeters", "2"],
["flights", "f"],
@@ -32,5 +33,6 @@
["weather-lightning", "l"],
["weather-radar", "v"],
["weather-satellite", "o"],
["wildlife-sightings", "9"],
["wind", "k"]
]
+149 -1
View File
@@ -90,6 +90,8 @@ async function createRealLocalLayerHarness({
globalThis.window = { dispatchEvent() {} };
let sampleHeightImpl = sampleHeight;
const sampleCalls = { count: 0 };
// The camera frustum, reduced to the one answer ground sampling asks of it.
let baseVisibility = Cesium.Intersect.INSIDE;
const overlayHost = {
setVisible: (...args) => hostCalls.push(['visible', ...args]),
setEntries: (...args) => hostCalls.push(['entries', ...args]),
@@ -107,7 +109,19 @@ async function createRealLocalLayerHarness({
},
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000),
frustum: { fov: Math.PI / 3 },
directionWC: new Cesium.Cartesian3(),
upWC: new Cesium.Cartesian3(),
frustum: {
fov: Math.PI / 3,
// No planes: everything is in view. One plane no point satisfies:
// nothing is.
computeCullingVolume: () => ({
planes:
baseVisibility === Cesium.Intersect.OUTSIDE
? [new Cesium.Cartesian4(0, 0, 0, -1)]
: [],
}),
},
moveEnd,
flyTo() {},
},
@@ -151,6 +165,10 @@ async function createRealLocalLayerHarness({
sampleCalls,
/** Swap scene.sampleHeight mid-test (e.g. tiles finally arrive). */
setSampleHeight(next) { sampleHeightImpl = next; },
/** Put the feature's base inside (true) or outside the camera frustum. */
setBaseInView(inView) {
baseVisibility = inView ? Cesium.Intersect.INSIDE : Cesium.Intersect.OUTSIDE;
},
cleanup() {
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
@@ -736,6 +754,136 @@ test('ground sampling waits for visible globe tiles before caching a height', as
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
});
test('an unseen base is not sampled and arms no retry until it is in view', async (t) => {
// Out of view the scene has no surface under the base, so the depth sample
// would pass through the Earth (about -6,344 km) and fail on every retry.
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
});
_resetRenderGovernorForTest();
const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
installRenderGovernor({ scene: governorScene });
governorScene.renders = 0; // discard the governor's own install settling frame
t.mock.timers.enable({ apis: ['setTimeout'] });
const clock = installFakeClock(t);
t.after(() => {
env.layer.destroy(env.viewer);
env.cleanup();
_resetRenderGovernorForTest();
});
env.viewer.scene.globe = { show: true, tilesLoaded: true, getHeight: () => 117 };
setCameraAltitude(env, 20_000);
env.setBaseInView(false);
runArmedRetryChain(env, t, clock, 5);
assert.equal(env.sampleCalls.count, 0, 'an off-screen base must not be sampled');
assert.equal(governorScene.renders, 0, 'an off-screen base must not keep a parked camera rendering');
assert.ok(Math.abs(baseHeightM(env)) < 0.01);
// In the frustum but over the horizon: 50 m up, about 67 km away.
env.setBaseInView(true);
env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.0, 30.205, 50);
runArmedRetryChain(env, t, clock, 5);
assert.equal(env.sampleCalls.count, 0, 'a base over the horizon must not be sampled');
assert.equal(governorScene.renders, 0);
setCameraAltitude(env, 20_000);
env.preRender.raise();
assert.equal(env.sampleCalls.count, 1, 'the base is sampled on the first walk that sees it');
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
});
/**
* Give the harness a real Cesium camera `southM` metres south of the base at
* `cameraM` metres above the ellipsoid, looking at the base's ground at
* `groundM` (or directly away from it).
*/
function aimRealCamera(env, { groundM, cameraM, southM, away = false, fov = Math.PI / 3 }) {
const entity = env.dataSources[0].entities.values[0];
const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian);
const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, groundM);
const position = Cesium.Cartesian3.fromRadians(
base.longitude,
base.latitude - southM / 6_371_000,
cameraM,
);
const direction = Cesium.Cartesian3.normalize(
away
? Cesium.Cartesian3.subtract(position, ground, new Cesium.Cartesian3())
: Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
const right = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.cross(
direction,
Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3());
const frustum = new Cesium.PerspectiveFrustum({
fov,
aspectRatio: 800 / 600,
near: 1,
far: 1e8,
});
Object.assign(env.viewer.camera, {
positionWC: position,
directionWC: direction,
upWC: up,
frustum,
});
return frustum
.computeCullingVolume(position, direction, up)
.computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1));
}
const loadedTerrain = (height) => ({ show: true, tilesLoaded: true, getHeight: () => height });
const hiddenGlobe = { show: false, tilesLoaded: true, getHeight: () => undefined };
for (const { label, globe, groundM, cameraM, southM, fov, baseOffScreen } of [
// The Dead Sea (about -430 m) under a hidden globe, seen steeply down
// through a narrow view: both 0 m and 9,000 m above it are off screen.
{ label: 'hidden-globe depression', globe: hiddenGlobe, groundM: -430, cameraM: 20, southM: 180, fov: Math.PI / 12, baseOffScreen: true },
// 2,500 m terrain from 3 km away at a shallow angle: the ellipsoid-height
// base is far below the bottom of the screen.
{ label: 'high ground with loaded terrain', globe: loadedTerrain(2_500), groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true },
{ label: 'high ground under a hidden globe', globe: hiddenGlobe, groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true },
// A low-geoid coast (about -63 m near Mumbai) from a camera 50 m above the
// ellipsoid: the ellipsoidal horizon would hide the ground itself.
{ label: 'ground below the ellipsoid', globe: loadedTerrain(-63), groundM: -63, cameraM: 50, southM: 2_000, baseOffScreen: false },
]) {
test(`${label} in view is sampled`, async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => groundM,
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
env.viewer.scene.globe = globe;
const baseVisibility = aimRealCamera(env, { groundM, cameraM, southM, fov });
if (baseOffScreen)
assert.equal(baseVisibility, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen');
clock.advance(2_100);
env.preRender.raise();
assert.equal(env.sampleCalls.count, 1, 'the ground in view is sampled');
assert.ok(Math.abs(baseHeightM(env) - groundM) < 0.01);
});
}
test('under a hidden globe, ground behind the camera is not sampled', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 2_500,
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
env.viewer.scene.globe = hiddenGlobe;
aimRealCamera(env, { groundM: 2_500, cameraM: 3_500, southM: 3_000, away: true });
clock.advance(2_100);
env.preRender.raise();
assert.equal(env.sampleCalls.count, 0);
});
test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
+83 -4
View File
@@ -1000,9 +1000,9 @@ export function createLocalGeoJsonLayer(
// governor the retry's preRender never arrives on a parked camera
// and the stem stays at ellipsoid height (buried/floating) until
// the user happens to move. Schedule the frame the retry needs.
// Gated on a sampleable scene and in-range records only, so a far
// camera (or a keyless scene) stays fully idle; the distance is
// only computed for still-unsampled stems.
// Gated on a sampleable scene and in-range, in-view records only,
// so a far camera (or a keyless scene) stays fully idle; the
// distance is only computed for still-unsampled stems.
if (
canSampleGround &&
!record.groundSampled &&
@@ -1010,7 +1010,8 @@ export function createLocalGeoJsonLayer(
Cesium.Cartesian3.distance(
viewer.camera.positionWC,
record.base,
) < GROUND_SAMPLE_MAX_DISTANCE_M
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
groundSampleInView(viewer, record)
) {
groundRetryPending = true;
}
@@ -1130,9 +1131,87 @@ function refreshLocalTerrainFloor(viewer, record) {
return true;
}
const groundSampleOccluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
);
const groundSampleCarto = new Cesium.Cartographic();
const groundSampleLow = new Cesium.Cartesian3();
const groundSampleHigh = new Cesium.Cartesian3();
/** The record's vertical at `height` metres above the ellipsoid. */
function groundSamplePoint(record, height, result) {
groundSampleCarto.longitude = record.carto.longitude;
groundSampleCarto.latitude = record.carto.latitude;
groundSampleCarto.height = height;
return Cesium.Cartographic.toCartesian(
groundSampleCarto,
Cesium.Ellipsoid.WGS84,
result,
);
}
/**
* Whether the camera can see the ground under the record: inside the view
* frustum and above the horizon. scene.sampleHeight reads only surface
* rendered for the current view, so a sample of unseen ground passes through
* the Earth (about -6,344 km near Ashburn on Esri) and fails on every 2 s
* retry, each one a full offscreen render; 110 unseen data centres there held
* rest frames at 58-589 ms. Unsampled bases sit on the ellipsoid, so the test
* uses loaded globe terrain when there is some. Otherwise (a hidden globe) it
* tests the vertical across +/-GROUND_SAMPLE_MAX_ABS_HEIGHT_M, every height a
* sample can accept, so high or below-ellipsoid ground in view is never
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
* sees it.
*/
function groundSampleInView(viewer, record) {
const camera = viewer.camera;
const globe = viewer.scene.globe;
const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined;
const known =
Number.isFinite(terrain) &&
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
const low = groundSamplePoint(
record,
known ? terrain : -GROUND_SAMPLE_MAX_ABS_HEIGHT_M,
groundSampleLow,
);
const high = known
? low
: groundSamplePoint(
record,
GROUND_SAMPLE_MAX_ABS_HEIGHT_M,
groundSampleHigh,
);
// The vertical is unseen only when both ends are outside the same plane.
const { planes } = camera.frustum.computeCullingVolume(
camera.positionWC,
camera.directionWC,
camera.upWC,
);
for (const plane of planes) {
if (
Cesium.Cartesian3.dot(plane, low) + plane.w < 0 &&
Cesium.Cartesian3.dot(plane, high) + plane.w < 0
)
return false;
}
// The ellipsoidal occluder hides every point below the ellipsoid from a
// camera near it, so ground below 0 m (low-geoid coasts, the Dead Sea) is
// tested at 0 m; unknown ground at the top of the vertical.
const horizonHeight = known
? Math.max(terrain, 0)
: GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
groundSampleOccluder.cameraPosition = camera.positionWC;
return groundSampleOccluder.isPointVisible(
groundSamplePoint(record, horizonHeight, groundSampleHigh),
);
}
function sampleLocalGroundHeight(viewer, record, now) {
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
if (!groundSampleInView(viewer, record)) return false;
record.lastGroundSampleMs = now;
const globe = viewer.scene.globe;
if (globe?.show && globe.tilesLoaded === false) return false;
+10 -1
View File
@@ -5,7 +5,7 @@ layer.
- Source: OpenStreetMap contributors
- License: Open Database License (ODbL) 1.0
- Feature count: 4,351
- Feature count: 4,352
- Runtime file: `datacenters.geojsonl`
The public-release snapshot removes contact-oriented tags such as email, phone,
@@ -16,3 +16,12 @@ ODbL 1.0 with the required OpenStreetMap attribution.
The original extraction date and query were not recorded alongside this
snapshot. Future refreshes should record both before replacing the file.
## Norway refresh
Features inside Geofabrik's Norway boundary (`norway.poly`, with Svalbard) come
from a newer extract than the rest of the file. Contact tags are dropped as above.
- Extract: Geofabrik `norway-261005.osm.pbf`, OSM data
2026-10-05T20:21:35Z, MD5 `1d2cfdeb98980fce11b8891d0f9146c0`
- Filter: `osmium tags-filter nwr/telecom=data_center nwr/building=data_center`
@@ -3770,15 +3770,15 @@
{"id":939340882,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021364183,52.315373019],[21.02232433,52.315435883],[21.022363641,52.315209656],[21.021403578,52.315146875],[21.021364183,52.315373019]]]},"properties":{"id":11407,"tags":{"name":"T-Mobile Warszawa-Szlachecka","telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":939340882}}
{"id":1005973518,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021325375,52.315595978],[21.022285857,52.315658423],[21.02232433,52.315435883],[21.021364183,52.315373019],[21.021325375,52.315595978]]]},"properties":{"id":11976,"tags":{"telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":1005973518}}
{"id":1156823776,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.115130857,52.235385942],[21.115189866,52.236349777],[21.115232027,52.236667954],[21.115818174,52.237174556],[21.116175578,52.237468677],[21.117262124,52.236985628],[21.11728157,52.236976576],[21.117262795,52.236960985],[21.117340495,52.236926033],[21.117360276,52.2369433],[21.117447532,52.236904491],[21.11804097,52.236654459],[21.118030996,52.236645742],[21.118041054,52.236641719],[21.116603055,52.235624072],[21.116518984,52.235676375],[21.115986231,52.235696659],[21.115954966,52.235361551],[21.115130857,52.235385942]]]},"properties":{"id":15342,"tags":{"name":"Atman Data Center Warsaw-1","telecom":"data_center","website":"https://www.atman.pl"},"type":"data_center","osm_id":1156823776}}
{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.598350034,58.860111293]},"properties":{"id":24849,"tags":{"name":"Maan Group","telecom":"data_center","operator":"Privat"},"type":"data_center","osm_id":7405586142}}
{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.5983501,58.8601113]},"properties":{"id":24849,"tags":{"name":"Maan Group","opening_hours":"24/7","operator":"Privat","telecom":"data_center"},"type":"data_center","osm_id":7405586142}}
{"id":481615983,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.572510149,56.481777951],[9.574069938,56.484921751],[9.57465357,56.48483349],[9.574639572,56.484805243],[9.574700676,56.484795939],[9.574714925,56.484824857],[9.575298557,56.484736595],[9.57454888,56.483225422],[9.574609229,56.483216369],[9.574510155,56.483016629],[9.574449722,56.483025849],[9.573738769,56.481592795],[9.573155137,56.481680972],[9.573170643,56.481712321],[9.573109539,56.481721625],[9.573093781,56.48168969],[9.572510149,56.481777951]]]},"properties":{"id":7881,"tags":{"telecom":"data_center","building":"data_center","operator":"Apple","wikidata":"Q19721053","wikipedia":"en:Foulum Data Center","description":"Apple Datacenter"},"type":"data_center","osm_id":481615983}}
{"id":618756435,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.500360092,57.318668738],[10.500399236,57.318671672],[10.500382891,57.318734871],[10.500552038,57.318744175],[10.500545249,57.318787677],[10.500501495,57.318785666],[10.500458999,57.318957746],[10.500970295,57.31899647],[10.501009187,57.318847189],[10.501084959,57.318852888],[10.50111966,57.318719197],[10.500843896,57.318698159],[10.500865521,57.318615178],[10.500859235,57.318614759],[10.500869461,57.318575699],[10.500954537,57.318582153],[10.501022347,57.31832223],[10.500736691,57.318300437],[10.500669133,57.318559689],[10.500674162,57.318560025],[10.500664272,57.318598498],[10.500383981,57.318577124],[10.500360092,57.318668738]]]},"properties":{"id":8842,"tags":{"ref":"AAL01-01","name":"Prime Data Centers - Saeby Campus AAL01","telecom":"data_center","website":"https://primedatacenters.com/locations/saeby/","building":"industrial","operator":"Prime Data Centers"},"type":"data_center","osm_id":618756435}}
{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002243225,59.633471864],[11.003903261,59.633446551],[11.007046726,59.633399696],[11.006983443,59.632420941],[11.006982772,59.63227057],[11.006911023,59.632265289],[11.006852936,59.632253973],[11.006765848,59.632228576],[11.00671782,59.632205275],[11.006119687,59.632213573],[11.006119352,59.632202592],[11.006118933,59.63219245],[11.005930424,59.632194378],[11.005923635,59.632120869],[11.005980799,59.632120031],[11.005979542,59.632104524],[11.005977027,59.63206865],[11.00597225,59.631998242],[11.005412171,59.632035541],[11.005286945,59.632043839],[11.00527848,59.632044342],[11.003707962,59.632147272],[11.003107483,59.63219178],[11.002741864,59.632235533],[11.002729375,59.63223788],[11.002345987,59.632309294],[11.002308352,59.632316251],[11.002302066,59.632477351],[11.002277088,59.632913881],[11.002243225,59.633471864]]]},"properties":{"id":107,"tags":{"name":"STACK Infrastructure OSL04","type":"multipolygon","telecom":"data_center","website":"https://www.stackinfra.com","operator":"Stack Infrastructure","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":-14367602}}
{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.0022433,59.6334719],[11.0022771,59.6329139],[11.0023021,59.6324774],[11.0023084,59.6323163],[11.002346,59.6323093],[11.0027294,59.6322379],[11.0027419,59.6322356],[11.0031075,59.6321918],[11.003708,59.6321473],[11.0052785,59.6320444],[11.005287,59.6320439],[11.0054122,59.6320356],[11.0059723,59.6319983],[11.0059771,59.6320687],[11.0059796,59.6321046],[11.0059808,59.6321201],[11.0059237,59.6321209],[11.0059305,59.6321944],[11.006119,59.6321925],[11.0061194,59.6322026],[11.0061197,59.6322136],[11.0067179,59.6322053],[11.0067659,59.6322286],[11.006853,59.632254],[11.0069111,59.6322653],[11.0069828,59.6322706],[11.0069835,59.632421],[11.0070468,59.6333997],[11.0039033,59.6334466],[11.0022433,59.6334719]]]},"properties":{"id":107,"tags":{"landuse":"industrial","name":"STACK Infrastructure OSL04","operator":"Stack Infrastructure","operator:wikidata":"Q115477173","telecom":"data_center","website":"https://www.stackinfra.com","type":"multipolygon"},"type":"data_center","osm_id":-14367602}}
{"id":1028687251,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.004111886,59.632578437],[11.004142983,59.633037262],[11.004275166,59.633034999],[11.004274998,59.63303173],[11.00505133,59.633019157],[11.005056443,59.633099372],[11.005224416,59.63309669],[11.005190637,59.632562344],[11.005060885,59.632564439],[11.005060969,59.632567457],[11.004587978,59.632575419],[11.00458538,59.632536527],[11.004459819,59.632538623],[11.004462418,59.632576844],[11.004242476,59.6325807],[11.004242141,59.632576174],[11.004111886,59.632578437]]]},"properties":{"id":12226,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1028687251}}
{"id":1164840308,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002600126,59.632599392],[11.002631223,59.633058217],[11.00276349,59.633055954],[11.002763238,59.633052685],[11.00353957,59.633040196],[11.003544683,59.633120327],[11.00371274,59.633117645],[11.003678961,59.632583382],[11.003549042,59.632585478],[11.003549293,59.632588495],[11.003076135,59.632596458],[11.00307362,59.632557566],[11.002948143,59.632559661],[11.002950657,59.632597883],[11.002730716,59.632601571],[11.002730465,59.632597129],[11.002600126,59.632599392]]]},"properties":{"id":15459,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840308}}
{"id":1164840309,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.005613672,59.632723444],[11.005644769,59.633182269],[11.005776951,59.633179922],[11.005776784,59.633176737],[11.006553199,59.633164164],[11.006558229,59.633244463],[11.006726286,59.633241781],[11.006692423,59.632707351],[11.006562671,59.632709446],[11.006562839,59.632712464],[11.006089764,59.632720426],[11.006087166,59.632681534],[11.005961772,59.632683797],[11.005964287,59.632721851],[11.005744262,59.632725623],[11.005743927,59.632721181],[11.005613672,59.632723444]]]},"properties":{"id":15460,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840309}}
{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.991467366,59.759024056],[10.992780978,59.759406942],[10.992773937,59.759423957],[10.992948197,59.759474248],[10.993073842,59.759363859],[10.993022293,59.759348939],[10.993291688,59.759112653],[10.993128827,59.759065547],[10.993181885,59.759019027],[10.993083062,59.758990445],[10.992976947,59.759083484],[10.992845519,59.759045346],[10.992813584,59.759073342],[10.9927876,59.759065798],[10.993409789,59.758524243],[10.99263002,59.758297094],[10.991938848,59.758898663],[10.991693594,59.758827165],[10.991467366,59.759024056]]]},"properties":{"id":11377,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"warehouse","ref:bygningsnr":"300752944"},"type":"data_center","osm_id":935092287}}
{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.992826324,59.757448761],[10.994530281,59.757601396],[10.994634888,59.757303168],[10.992928332,59.757152545],[10.992826324,59.757448761]]]},"properties":{"id":15426,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"industrial"},"type":"data_center","osm_id":1161764012}}
{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9914674,59.7590241],[10.9916936,59.7588272],[10.9919389,59.7588987],[10.9926301,59.7582971],[10.9934098,59.7585243],[10.9927876,59.7590658],[10.9928136,59.7590734],[10.9928456,59.7590454],[10.992977,59.7590835],[10.9930831,59.7589905],[10.9931819,59.7590191],[10.9931289,59.7590656],[10.9932917,59.7591127],[10.9930223,59.759349],[10.9930739,59.7593639],[10.9929482,59.7594743],[10.992774,59.759424],[10.992781,59.759407],[10.9914674,59.7590241]]]},"properties":{"id":11377,"tags":{"building":"warehouse","name":"Green Mountain Ytre Enebakk","ref:bygningsnr":"300752944","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":935092287}}
{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9928264,59.7574488],[10.9929284,59.7571526],[10.9946349,59.7573032],[10.9945303,59.7576014],[10.9928264,59.7574488]]]},"properties":{"id":15426,"tags":{"building":"industrial","name":"Green Mountain Ytre Enebakk","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":1161764012}}
{"id":30724580,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.808790995,59.924984985],[10.808893925,59.925031923],[10.80893793,59.92500594],[10.808982941,59.925025972],[10.809042955,59.92501298],[10.809083943,59.925031923],[10.809012948,59.925077856],[10.80926583,59.925189755],[10.80955115,59.925025972],[10.809676543,59.925081293],[10.81032899,59.924701928],[10.809890365,59.924508138],[10.80982155,59.924515347],[10.809691631,59.924457847],[10.808820667,59.924557592],[10.809099365,59.924699581],[10.808806418,59.924865291],[10.808918149,59.924914828],[10.808875988,59.924937962],[10.808790995,59.924984985]]]},"properties":{"id":261,"tags":{"name":"Stack Infrastructure OSLO1A","telecom":"data_center","building":"office","operator":"Stack Infrastructure","ref:bygningsnr":"80877293","building:levels":"4","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":30724580}}
{"id":80871917,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.83408892,59.938588981],[10.834305927,59.938720996],[10.834575992,59.938720996],[10.834574987,59.938736],[10.834581943,59.938736922],[10.834579932,59.938771958],[10.834736925,59.938771958],[10.834734997,59.93893792],[10.836717988,59.938944961],[10.836717988,59.938983937],[10.836817984,59.938983937],[10.83681899,59.938944961],[10.836844974,59.938944961],[10.836846985,59.938774976],[10.836841956,59.938774976],[10.836842962,59.938744968],[10.836849919,59.938744968],[10.836851931,59.938653941],[10.836683957,59.938652935],[10.836684125,59.938640278],[10.836686975,59.938414973],[10.83490297,59.938406926],[10.834899953,59.938646984],[10.834812949,59.938645978],[10.83481496,59.938551933],[10.834579932,59.938550927],[10.834580938,59.938564925],[10.834567946,59.938564925],[10.834567946,59.938551933],[10.834089926,59.938549922],[10.83408892,59.938588981]]]},"properties":{"id":1464,"tags":{"name":"Bulk Data Centers Oslo Internet Exchange","telecom":"data_center","website":"https://bulkinfrastructure.com/data-centers/locations/os-ix","building":"industrial","ref:bygningsnr":"80709943"},"type":"data_center","osm_id":80871917}}
{"id":12158409495,"type":"Feature","geometry":{"type":"Point","coordinates":[9.689363435,62.59439936]},"properties":{"id":28723,"tags":{"name":"VisBook AS","telecom":"data_center","website":"https://visbook.com/"},"type":"data_center","osm_id":12158409495}}
@@ -4349,3 +4349,4 @@
{"id":540895898,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.71747909,56.303855963],[101.71782015,56.304420065],[101.721113819,56.303806929],[101.720772759,56.303242994],[101.71747909,56.303855963]]]},"properties":{"id":8271,"tags":{"name":"Битривер Рус","name:en":"Bitriver Rus","name:ru":"Битривер Рус","telecom":"data_center","website":"https://bitriver.farm/","building":"data_center"},"type":"data_center","osm_id":540895898}}
{"id":1082803064,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[123.948021193,47.340264299],[123.948354625,47.340268239],[123.948490663,47.340269831],[123.948488568,47.340349292],[123.948809762,47.340353064],[123.948811942,47.340267485],[123.948918643,47.340268658],[123.948922918,47.340137817],[123.948023959,47.340126082],[123.948021193,47.340264299]]]},"properties":{"id":13377,"tags":{"name":"联通云数据中心·齐齐哈尔","name:en":"Unicom Data Center of Qiqihar","name:zh":"联通云数据中心·齐齐哈尔","telecom":"data_center","building":"yes","name:zh-Hans":"联通云数据中心·齐齐哈尔","name:zh-Hant":"聯通雲數據中心·齊齊哈爾"},"type":"data_center","osm_id":1082803064}}
{"id":442479059,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[142.76019226,46.910096898],[142.760192427,46.910325724],[142.760447656,46.910328155],[142.760460397,46.910102263],[142.76019226,46.910096898]]]},"properties":{"id":7471,"tags":{"telecom":"data_center","building":"yes"},"type":"data_center","osm_id":442479059}}
{"id":1551029155,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[7.1069147,62.8627625],[7.1071207,62.8621617],[7.1075917,62.8616616],[7.1088647,62.8620476],[7.1106308,62.862645],[7.1098655,62.8632827],[7.1089604,62.8630042],[7.1086734,62.8632156],[7.1069147,62.8627625]]]},"properties":{"id":29471,"tags":{"layer":"-1","loc_name":"Troll Housing Klempertåsen","name":"Troll Housing","operator":"Troll Housing AS","telecom":"data_center","website":"https://www.trollhousing.no/"},"type":"data_center","osm_id":1551029155}}
+50
View File
@@ -98,6 +98,7 @@ function installAwarenessRuntime({
// status its module never emits guards nothing. See the settled-but-fetching
// tests below, which use militaryInstallations' genuine first-fetch readout.
layerStats = {},
subscribe = undefined,
} = {}) {
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
@@ -215,6 +216,7 @@ function installAwarenessRuntime({
setEnabled,
getLayerLifecycleState,
layers,
...(subscribe ? { subscribe } : {}),
});
militaryAwarenessLayer.setParams({ passive: true });
militaryAwarenessLayer.init(viewer);
@@ -383,6 +385,54 @@ test('Contacts does not claim a dependency already enabling for the user', async
}
});
test('a dependency the user adopts stays on when Contacts turns off', async () => {
// A direct selection, or the Cockpit riding one of its aircraft, promotes a
// layer Contacts turned on into the user's own (adoptLayerVisibility). The
// coordinator must give it up, or its OFF release tears the layer down —
// and the track or the Cockpit with it — before Context restores it.
const calls = [];
const subscribers = new Set();
const runtime = installAwarenessRuntime({
isEnabled: () => false,
isEffectivelyEnabled: () => false,
setEnabled: (layerId, enabled) => {
calls.push({ layerId, enabled });
return Promise.resolve(true);
},
subscribe: (listener) => {
subscribers.add(listener);
return () => subscribers.delete(listener);
},
});
try {
militaryAwarenessLayer.setParams({ passive: false });
await nextTurn();
for (const listener of subscribers)
listener({
type: 'visibility',
layerId: 'flights',
enabled: true,
origin: 'user',
adopted: true,
adoptedFromSelection: true,
});
await militaryAwarenessLayer.disable();
const released = calls
.filter(({ enabled }) => !enabled)
.map(({ layerId }) => layerId)
.sort();
assert.deepEqual(
released,
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
'the adopted layer is the user\'s now; the rest are still released',
);
} finally {
runtime.restore();
}
assert.equal(subscribers.size, 0, 'destroy drops the manager subscription');
});
test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => {
let enabled = false;
let releaseAircraft;
+60 -5
View File
@@ -189,7 +189,58 @@ const loadIndex = createRetryableLoader(async () => {
return index;
});
function toResult(entry, candidates = 1) {
/** Same-name parts this close are one feature split by the pack. */
const SAME_PLACE_GAP_KM = 100;
/** Gap between two bboxes in km, measured the short way across 180°. */
function bboxGapKm(a, b) {
const midLat = (a[1] + a[3] + b[1] + b[3]) / 4;
const kmPerLon = 111.32 * Math.cos((midLat * Math.PI) / 180);
let dLon = Infinity;
for (const shift of [-360, 0, 360]) {
dLon = Math.min(
dLon,
Math.max(0, Math.max(a[0], b[0] + shift) - Math.min(a[2], b[2] + shift)),
);
}
const dLat = Math.max(0, Math.max(a[1], b[1]) - Math.min(a[3], b[3]));
return Math.hypot(dLon * kmPerLon, dLat * 111.32);
}
/**
* Distinct places among same-name entries: parts that touch or nearly touch
* (the Great Rift Valley's two halves, the Lau Group across 180°) merge into
* one place with every part; distant namesakes (Flores in the Azores and in
* Indonesia) stay separate. Largest place first.
*/
function groupPlaces(list) {
const groups = [];
for (const entry of list) {
const near = groups.filter((group) =>
group.some(
(other) => bboxGapKm(other.bbox, entry.bbox) <= SAME_PLACE_GAP_KM,
),
);
const merged = [entry, ...near.flat()];
for (const group of near) groups.splice(groups.indexOf(group), 1);
groups.push(merged);
}
return groups
.map((group) => {
const largest = group.reduce((a, b) => (b.areaKm2 > a.areaKm2 ? b : a));
const [w, s, e, n] = largest.bbox;
return {
name: largest.name,
featurecla: largest.featurecla,
polygons: group.flatMap((entry) => entry.polygons),
areaKm2: group.reduce((sum, entry) => sum + entry.areaKm2, 0),
center: { lat: (s + n) / 2, lon: (w + e) / 2 },
};
})
.sort((a, b) => b.areaKm2 - a.areaKm2);
}
function toResult(entry, candidates = 1, list = [entry]) {
return {
name: entry.name,
featurecla: entry.featurecla,
@@ -198,6 +249,7 @@ function toResult(entry, candidates = 1) {
bboxDiagonalKm: entry.bboxDiagonalKm,
areaKm2: entry.areaKm2,
candidates,
places: groupPlaces(list),
};
}
@@ -210,9 +262,12 @@ function toResult(entry, candidates = 1) {
* @param {string} query e.g. "the Alps", "Rockies", "Gulf of Mexico"
* @returns {Promise<{name:string, featurecla:string, kind:'natural'|'marine',
* polygons:Array<Array<[number,number]>>, bboxDiagonalKm:number,
* areaKm2:number, candidates:number}|null>} largest-area match plus the
* number of same-name entries, or null when nothing matches. Uniqueness in
* this pack alone does not establish the user's intended place.
* areaKm2:number, candidates:number, places:Array<{name:string,
* featurecla:string, polygons:Array, areaKm2:number,
* center:{lat:number, lon:number}}>}|null>} largest-area match plus the
* number of same-name entries, or null when nothing matches. `places`
* groups those entries into distinct places, each with all its parts.
* Uniqueness in this pack alone does not establish the user's intended place.
*/
export async function findNaturalRegion(query) {
const norm = normalizeName(query);
@@ -226,7 +281,7 @@ export async function findNaturalRegion(query) {
for (const key of candidates) {
if (!key) continue;
const list = index.get(key);
if (list && list.length) return toResult(list[0], list.length);
if (list && list.length) return toResult(list[0], list.length, list);
}
return null;
}
+18
View File
@@ -195,3 +195,21 @@ test('pointInRing: basic square', () => {
assert.equal(pointInRing(sq, 15, 5), false);
assert.equal(pointInRing([[0, 0], [1, 1]], 0.5, 0.5), false, 'degenerate ring');
});
test('same-name parts of one feature are one place; distant namesakes are separate places', async () => {
for (const name of ['Great Rift Valley', 'Lau Group', 'Transantarctic Mountains']) {
const region = await findNaturalRegion(name);
assert.equal(region?.places?.length, 1, `${name} is one place`);
const place = region.places[0];
assert.equal(place.polygons.length >= 2, true, `${name} keeps every part`);
assert.ok(place.areaKm2 > region.areaKm2, `${name} counts more than its largest part`);
}
for (const name of ['Flores', 'Cordillera Oriental']) {
const region = await findNaturalRegion(name);
assert.equal(region?.places?.length, 2, `${name} names two places`);
for (const place of region.places) {
assert.ok(Number.isFinite(place.center.lat) && Number.isFinite(place.center.lon));
}
}
assert.equal((await findNaturalRegion('Alps'))?.places?.length, 1);
});
+426
View File
@@ -0,0 +1,426 @@
/**
* The OpenStreetMap feature kinds voice can search for ("hospitals",
* "petrol stations", "police"), each with the exact tags it stands for.
*
* The server compiles Overpass queries only from these ids: a request names a
* preset, never a tag, a regex or query text. Pure data; the browser and the
* server both import it.
*
* @module data/osmPresets
*/
/**
* The noun is the model's data, not UI copy: it stays English.
* id → { noun (plural, spoken), terms (other ways to say it), tags: [[key, value]] }.
* A feature matches a preset when it carries ANY of the preset's tags.
*/
const PRESETS = {
hospital: {
noun: 'hospitals',
terms: ['hospital', 'er', 'emergency room', 'medical center'],
tags: [['amenity', 'hospital']],
},
clinic: {
noun: 'clinics',
terms: ['clinic', 'health center', 'health post', 'medical clinic'],
tags: [['amenity', 'clinic']],
},
doctors: {
noun: 'doctors',
terms: ['doctor', "doctor's office", 'gp', 'physician'],
tags: [['amenity', 'doctors']],
},
dentist: {
noun: 'dentists',
terms: ['dentist', 'dental clinic'],
tags: [['amenity', 'dentist']],
},
pharmacy: {
noun: 'pharmacies',
terms: ['pharmacy', 'chemist', 'drugstore', 'drug store'],
tags: [['amenity', 'pharmacy']],
},
school: {
noun: 'schools',
terms: ['school', 'primary school', 'secondary school', 'high school'],
tags: [['amenity', 'school']],
},
kindergarten: {
noun: 'kindergartens',
terms: ['kindergarten', 'preschool', 'nursery school'],
tags: [['amenity', 'kindergarten']],
},
university: {
noun: 'universities',
terms: ['university', 'campus'],
tags: [['amenity', 'university']],
},
college: {
noun: 'colleges',
terms: ['college'],
tags: [['amenity', 'college']],
},
library: {
noun: 'libraries',
terms: ['library'],
tags: [['amenity', 'library']],
},
police: {
noun: 'police stations',
terms: ['police', 'police station', 'police post'],
tags: [['amenity', 'police']],
},
fire_station: {
noun: 'fire stations',
terms: ['fire station', 'fire department', 'firehouse'],
tags: [['amenity', 'fire_station']],
},
prison: {
noun: 'prisons',
terms: ['prison', 'jail', 'correctional facility'],
tags: [['amenity', 'prison']],
},
courthouse: {
noun: 'courthouses',
terms: ['courthouse', 'court'],
tags: [['amenity', 'courthouse']],
},
townhall: {
noun: 'town halls',
terms: ['town hall', 'city hall'],
tags: [['amenity', 'townhall']],
},
embassy: {
noun: 'embassies',
terms: ['embassy', 'consulate'],
tags: [
['office', 'diplomatic'],
['amenity', 'embassy'],
],
},
post_office: {
noun: 'post offices',
terms: ['post office'],
tags: [['amenity', 'post_office']],
},
bank: { noun: 'banks', terms: ['bank'], tags: [['amenity', 'bank']] },
atm: {
noun: 'ATMs',
terms: ['atm', 'cash machine', 'cashpoint'],
tags: [['amenity', 'atm']],
},
fuel: {
noun: 'fuel stations',
terms: [
'gas station',
'petrol station',
'fuel station',
'filling station',
'gas',
],
tags: [['amenity', 'fuel']],
},
charging_station: {
noun: 'EV charging stations',
terms: ['charging station', 'ev charger', 'ev charging', 'car charger'],
tags: [['amenity', 'charging_station']],
},
parking: {
noun: 'car parks',
terms: ['parking', 'parking lot', 'car park', 'parking garage'],
tags: [['amenity', 'parking']],
},
restaurant: {
noun: 'restaurants',
terms: ['restaurant'],
tags: [['amenity', 'restaurant']],
},
cafe: {
noun: 'cafés',
terms: ['cafe', 'café', 'coffee shop', 'coffee'],
tags: [['amenity', 'cafe']],
},
fast_food: {
noun: 'fast-food places',
terms: ['fast food', 'fast-food'],
tags: [['amenity', 'fast_food']],
},
bar: {
noun: 'bars',
terms: ['bar', 'pub'],
tags: [
['amenity', 'bar'],
['amenity', 'pub'],
],
},
place_of_worship: {
noun: 'places of worship',
terms: [
'place of worship',
'church',
'mosque',
'temple',
'synagogue',
'gurdwara',
],
tags: [['amenity', 'place_of_worship']],
},
marketplace: {
noun: 'markets',
terms: ['market', 'marketplace', 'bazaar'],
tags: [['amenity', 'marketplace']],
},
supermarket: {
noun: 'supermarkets',
terms: ['supermarket', 'grocery store', 'grocery'],
tags: [['shop', 'supermarket']],
},
mall: {
noun: 'shopping malls',
terms: ['mall', 'shopping mall', 'shopping center', 'shopping centre'],
tags: [['shop', 'mall']],
},
convenience: {
noun: 'convenience stores',
terms: ['convenience store', 'corner shop'],
tags: [['shop', 'convenience']],
},
hotel: { noun: 'hotels', terms: ['hotel'], tags: [['tourism', 'hotel']] },
hostel: {
noun: 'hostels',
terms: ['hostel'],
tags: [['tourism', 'hostel']],
},
museum: {
noun: 'museums',
terms: ['museum'],
tags: [['tourism', 'museum']],
},
attraction: {
noun: 'tourist attractions',
terms: ['tourist attraction', 'attraction', 'sight', 'sights'],
tags: [['tourism', 'attraction']],
},
viewpoint: {
noun: 'viewpoints',
terms: ['viewpoint', 'lookout', 'scenic view'],
tags: [['tourism', 'viewpoint']],
},
zoo: { noun: 'zoos', terms: ['zoo'], tags: [['tourism', 'zoo']] },
cinema: {
noun: 'cinemas',
terms: ['cinema', 'movie theater', 'movie theatre'],
tags: [['amenity', 'cinema']],
},
theatre: {
noun: 'theatres',
terms: ['theatre', 'theater', 'playhouse'],
tags: [['amenity', 'theatre']],
},
stadium: {
noun: 'stadiums',
terms: ['stadium', 'arena'],
tags: [['leisure', 'stadium']],
},
park: { noun: 'parks', terms: ['park'], tags: [['leisure', 'park']] },
playground: {
noun: 'playgrounds',
terms: ['playground'],
tags: [['leisure', 'playground']],
},
sports_centre: {
noun: 'sports centres',
terms: ['sports center', 'sports centre', 'gym', 'leisure center'],
tags: [
['leisure', 'sports_centre'],
['leisure', 'fitness_centre'],
],
},
cemetery: {
noun: 'cemeteries',
terms: ['cemetery', 'graveyard'],
tags: [
['landuse', 'cemetery'],
['amenity', 'grave_yard'],
],
},
toilets: {
noun: 'public toilets',
terms: ['toilet', 'toilets', 'restroom', 'public toilet', 'bathroom'],
tags: [['amenity', 'toilets']],
},
drinking_water: {
noun: 'drinking water points',
terms: ['drinking water', 'water fountain', 'water point'],
tags: [['amenity', 'drinking_water']],
},
shelter: {
noun: 'shelters',
terms: ['shelter', 'emergency shelter'],
tags: [['amenity', 'shelter']],
},
airport: {
noun: 'airports',
terms: ['airport', 'airfield', 'aerodrome', 'airstrip'],
tags: [['aeroway', 'aerodrome']],
},
helipad: {
noun: 'helipads',
terms: ['helipad', 'heliport'],
tags: [
['aeroway', 'helipad'],
['aeroway', 'heliport'],
],
},
train_station: {
noun: 'train stations',
terms: ['train station', 'railway station', 'rail station'],
tags: [['railway', 'station']],
},
bus_station: {
noun: 'bus stations',
terms: ['bus station', 'bus terminal', 'bus depot'],
tags: [['amenity', 'bus_station']],
},
ferry_terminal: {
noun: 'ferry terminals',
terms: ['ferry terminal', 'ferry'],
tags: [['amenity', 'ferry_terminal']],
},
harbour: {
noun: 'harbours',
terms: ['harbour', 'harbor', 'port', 'marina'],
tags: [
['harbour', 'yes'],
['leisure', 'marina'],
['landuse', 'port'],
],
},
bridge: {
noun: 'bridges',
terms: ['bridge'],
tags: [['man_made', 'bridge']],
},
lighthouse: {
noun: 'lighthouses',
terms: ['lighthouse'],
tags: [['man_made', 'lighthouse']],
},
water_tower: {
noun: 'water towers',
terms: ['water tower'],
tags: [['man_made', 'water_tower']],
},
power_plant: {
noun: 'power plants',
terms: ['power plant', 'power station', 'generating station'],
tags: [['power', 'plant']],
},
substation: {
noun: 'substations',
terms: ['substation', 'electrical substation'],
tags: [['power', 'substation']],
},
wind_turbine: {
noun: 'wind turbines',
terms: ['wind turbine', 'windmill'],
tags: [['generator:source', 'wind']],
},
dam: { noun: 'dams', terms: ['dam'], tags: [['waterway', 'dam']] },
military: {
noun: 'military sites',
terms: ['military base', 'military site', 'base', 'barracks'],
tags: [
['landuse', 'military'],
['military', 'base'],
['military', 'barracks'],
],
},
peak: {
noun: 'peaks',
terms: ['peak', 'mountain peak', 'summit', 'mountain'],
tags: [['natural', 'peak']],
},
waterfall: {
noun: 'waterfalls',
terms: ['waterfall', 'falls'],
tags: [['waterway', 'waterfall']],
},
};
/** Frozen preset table. */
export const OSM_PRESETS = Object.freeze(
Object.fromEntries(
Object.entries(PRESETS).map(([id, preset]) => [
id,
Object.freeze({
id,
label: preset.noun,
terms: Object.freeze([...preset.terms]),
tags: Object.freeze(
preset.tags.map((pair) => Object.freeze([...pair])),
),
}),
]),
),
);
/** Every preset id, for tool enums. */
export const OSM_PRESET_IDS = Object.freeze(Object.keys(OSM_PRESETS));
const normalize = (text) =>
String(text || '')
.toLowerCase()
.normalize('NFD')
.replace(/\p{M}/gu, '')
.replace(/[^a-z0-9 ]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
/** Naive singular: "pharmacies" → "pharmacy", "hospitals" → "hospital". */
function singular(word) {
if (word.endsWith('ies')) return `${word.slice(0, -3)}y`;
if (/(ches|shes|sses|xes)$/.test(word)) return word.slice(0, -2);
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1);
return word;
}
const singularPhrase = (text) => text.split(' ').map(singular).join(' ');
const TERM_INDEX = (() => {
const index = new Map();
for (const preset of Object.values(OSM_PRESETS)) {
for (const term of [
preset.id.replace(/_/g, ' '),
singularPhrase(normalize(preset.label)),
...preset.terms,
]) {
const key = singularPhrase(normalize(term));
if (key && !index.has(key)) index.set(key, preset.id);
}
}
return index;
})();
/**
* The preset a spoken phrase names ("all the hospitals" → hospital), or null.
* An exact preset id wins; otherwise the longest known term found in the
* phrase.
* @param {string} phrase
* @returns {object|null} A preset.
*/
export function matchOsmPreset(phrase) {
const raw = String(phrase || '').trim();
if (Object.hasOwn(OSM_PRESETS, raw)) return OSM_PRESETS[raw];
const text = singularPhrase(
normalize(raw).replace(/^(all|every|the|any)( the)? /, ''),
);
if (!text) return null;
if (TERM_INDEX.has(text)) return OSM_PRESETS[TERM_INDEX.get(text)];
let best = null;
for (const [term, id] of TERM_INDEX) {
if (term.length < 3) continue;
if (!new RegExp(`(^| )${term}( |$)`).test(text)) continue;
if (!best || term.length > best.term.length) best = { term, id };
}
return best ? OSM_PRESETS[best.id] : null;
}
+19 -9
View File
@@ -11,17 +11,16 @@ const FOLD = {
};
/**
* Comparable form of a place name, shared by the bundled packs, the
* OpenFreeMap street and grounds matchers and the Nominatim server: case
* folded, accents removed from Latin letters only, punctuation and symbols
* collapsed to single spaces, and a leading "the" dropped. Other scripts keep
* their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while "São Paulo",
* "sao paulo" and "SÃO PAULO!" meet.
* Script-aware fold of a place name: case folded, accents removed from Latin
* letters only, punctuation and symbols collapsed to single spaces. Other
* scripts keep their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while
* "São Paulo", "sao paulo" and "SÃO PAULO!" meet. Every word is kept: "The
* Hague" is not "Hague".
*
* @param {unknown} value
* @returns {string}
*/
export function foldPlaceName(value) {
export function foldPlaceText(value) {
return String(value ?? '')
.toLowerCase()
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
@@ -29,8 +28,19 @@ export function foldPlaceName(value) {
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFKC')
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim()
.replace(/^the /, '');
.trim();
}
/**
* Comparable form of a place name, shared by the bundled packs, the
* OpenFreeMap street and grounds matchers and the Nominatim server:
* `foldPlaceText` with a leading "the" dropped.
*
* @param {unknown} value
* @returns {string}
*/
export function foldPlaceName(value) {
return foldPlaceText(value).replace(/^the /, '');
}
/**
+505
View File
@@ -0,0 +1,505 @@
/**
* Rio de Janeiro route ids to the line a rider would say.
*
* SMTR's realtime feeds send internal keys (`O0731AAN0A` is SN731,
* `20000501110` is BRT line 50). They look decodable but are not:
* `O0014AAA0A` is LECD135, not 14. The table is copied from `routes.txt` in
* SMTR's static GTFS (https://dados.mobilidade.rio/gtfs/schedule, CC BY 4.0),
* valid to 2026-12-31. An id missing here renders as its raw key.
*/
export const RIO_ROUTE_NAMES = Object.freeze({
20000101130: '10',
20000111110: '11',
20000121130: '12',
20000131130: '13',
20000141130: '14',
20000151130: '15',
20000161130: '16',
20000171110: '17',
20000181130: '18',
20000191130: '19',
20000201130: '20',
20000221110: '22',
20000251130: '25',
20000281130: '28',
20000311140: '31',
20000351110: '35',
20000401130: '40',
20000411130: '41',
20000421110: '42',
20000431130: '43',
20000461130: '46',
20000501110: '50',
20000511110: '51',
20000521110: '52',
20000531130: '53',
20000601110: '60',
20000611130: '61',
20000671130: '67',
20000681130: '68',
20000701110: '70',
20000731130: '73',
20000801110: '80',
20000901110: '90',
'20000EXEC1110': 'ESP01',
E2110AAA0A: '2110',
E2111AAA0A: '2111',
E2112AAA0A: '2112',
E2114AAA0A: '2114',
E2303AAA0A: '2303',
E2305AAA0A: '2305',
E2307AAA0A: '2307',
E2308AAA0A: '2308',
E2309AAA0A: '2309',
E2310AAA0A: '2310',
E2334AAA0A: '2334',
E2335AAA0A: '2335',
E2336AAA0A: '2336',
E2337AAA0A: '2337',
E2338AAA0A: '2338',
E2339AAA0A: '2339',
E2342AAA0A: '2342',
E2343AAA0A: '2343',
E2344AAA0A: '2344',
E2345AAA0A: '2345',
E2381AAA0A: '2381',
E2383AAA0A: '2383',
E2613AAA0A: '2613',
E2614AAA0A: '2614',
E2802AAA0A: '2802',
E2803AAA0A: '2803',
E2804AAA0A: '2804',
O0006AAA0A: '006',
O0007AAA0A: '007',
O0007AAN0A: 'SN007',
O0010AAA0A: '010',
O0014AAA0A: 'LECD135',
O0100AAA0A: '100',
O0104AAA0A: '104',
O0104AAN0A: 'SN104',
O0107AAA0A: '107',
O0109AAA0A: 'LECD131',
O0111AAA0A: 'LECD134',
O0117AAA0A: '117',
O0133AAA0A: '133',
O0133AAN0A: 'SN133',
O0157AAA0A: 'LECD127',
O0160AAA0A: '160',
O0161AAA0A: '161',
O0162AAA0A: '162',
O0164AAA0A: 'LECD136',
O0165AAA0A: '165',
O0167AAA0A: '167',
O0169AAA0A: '169',
O0201AAA0A: '201',
O0202AAA0A: '202',
O0209AAA0A: '209',
O0209AAN0A: 'SN209',
O0210AAA0A: '210',
O0217AAA0A: '217',
O0220AAA0A: '220',
O0229AAA0A: '229',
O0232AAA0A: '232',
O0232AAN0A: 'SN232',
O0238AAA0A: '238',
O0238AAN0A: 'SN238',
O0239AAA0A: '239',
O0247AAA0A: '247',
O0249AAA0A: '249',
O0254AAA0A: '254',
O0265AAA0A: '265',
O0265AAN0A: 'SN265',
O0265AAP0A: 'SP265',
O0275AAA0A: '275',
O0277AAA0A: '277',
O0292AAA0A: '292',
O0292AAN0A: 'SN292',
O0296AAA0A: '296',
O0298AAA0A: '298',
O0298AAN0A: 'SN298',
O0300AAA0A: '300',
O0300AAR0A: 'SR300',
O0301AAA0A: '301',
O0302AAA0A: '302',
O0302AAN0A: 'SN302',
O0306AAA0A: '306',
O0311AAA0A: '311',
O0312AAA0A: '312',
O0312AAN0A: 'SN312',
O0313AAA0A: '313',
O0315AAA0A: '315',
O0319AAA0A: '319',
O0319AAN0A: 'SN319',
O0319AAV0A: 'SV319',
O0321AAA0A: '321',
O0323AAA0A: '323',
O0324AAA0A: '324',
O0324AAN0A: 'SN324',
O0324AAP0A: 'SP324',
O0325AAA0A: '325',
O0326AAA0A: '326',
O0326AAPBA: 'SPB326',
O0327AAA0A: '327',
O0328AAA0A: '328',
O0328AAN0A: 'SN328',
O0328AAP0A: 'SP328',
O0328AAR0A: 'SR328',
O0329AAA0A: '329',
O0329AAP0A: 'SP329',
O0335AAA0A: '335',
O0338AAA0A: '338',
O0341AAA0A: '341',
O0342AAA0A: '342',
O0342AAR0A: 'SR342',
O0343AAA0A: '343',
O0343AAP0A: 'SP343',
O0348AAA0A: '348',
O0349AAA0A: '349',
O0350AAA0A: '350',
O0352AAA0A: '352',
O0353AAA0A: '353',
O0353AAN0A: 'SN353',
O0355AAA0A: '355',
O0355AAN0A: 'SN355',
O0355AAR0A: 'SR355',
O0361AAA0A: '361',
O0362AAA0A: '362',
O0362AAR0A: 'SR362',
O0363AAA0A: '363',
O0363AAN0A: 'SN363',
O0363AAV0A: 'SV363',
O0368AAA0A: '368',
O0368AAN0A: 'SN368',
O0369AAA0A: '369',
O0371AAA0A: '371',
O0378AAA0A: '378',
O0378AAR0A: 'SR378',
O0380AAA0A: '380',
O0383AAA0A: '383',
O0383AAN0A: 'SN383',
O0384AAA0A: '384',
O0384AAR0A: 'SR384',
O0385AAA0A: '385',
O0386AAA0A: '386',
O0386AAR0A: 'SR386',
O0388AAA0A: '388',
O0388AAN0A: 'SN388',
O0388AAR0A: 'SR388',
O0389AAP0A: 'SP389',
O0390AAA0A: '390',
O0391AAV0A: 'SV391',
O0393AAA0A: '393',
O0393AAN0A: 'SN393',
O0393AAR0A: 'SR393',
O0397AAA0A: '397',
O0397AAN0A: 'SN397',
O0397AAR0A: 'SR397',
O0399AAA0A: '399',
O0399AAN0A: 'SN399',
O0399AAR0A: 'SR399',
O0404AAP0A: 'SP404',
O0409AAA0A: '409',
O0410AAA0A: '410',
O0410AAN0A: 'LECD139',
O0415AAA0A: '415',
O0415AAN0A: 'SN415',
O0416AAA0A: '416',
O0422AAA0A: '422',
O0422AAN0A: 'SN422',
O0426AAA0A: '426',
O0433AAA0A: 'LECD150',
O0435AAA0A: 'LECD151',
O0448AAA0A: '448',
O0455AAA0A: '455',
O0456AAA0A: 'LECD132',
O0457AAA0A: '457',
O0457AAN0A: 'SN457',
O0461AAA0A: '461',
O0472AAA0A: '472',
O0473AAA0A: '473',
O0474AAA0A: '474',
O0474AAN0A: 'SN474',
O0474AAV0A: 'SV474',
O0475AAA0A: 'LECD133',
O0476AAA0A: '476',
O0483AAA0A: '483',
O0483AAN0A: 'SN483',
O0483AAPAA: 'SPA483',
O0483AAPBA: 'SPB483',
O0483AAV0A: 'SV483',
O0484AAA0A: '484',
O0485AAA0A: '485',
O0485AAP0A: 'SP485',
O0486AAA0A: '486',
O0492AAA0A: '492',
O0497AAA0A: '497',
O0497AAN0A: 'SN497',
O0498AAA0A: '498',
O0507AAA0A: '507',
O0513AAA0A: '513',
O0518AAA0A: '518',
O0519AAA0A: '519',
O0536AAA0A: 'LECD138',
O0539AAA0A: '539',
O0550AAA0A: '550',
O0550AAN0A: 'SN550',
O0550AAPAA: 'SPA550',
O0550AAPBA: 'SPB550',
O0552AAA0A: '552',
O0552AAN0A: 'LECD149',
O0553AAA0A: '553',
O0554AAA0A: '554',
O0554AAN0A: 'SN554',
O0555AAA0A: '555',
O0557AAA0A: '557',
O0565AAA0A: '565',
O0565AAN0A: 'SN565',
O0583AAA0A: '583',
O0584AAA0A: '584',
O0585AAA0A: '585',
O0600AAA0A: '600',
O0600AAN0A: 'SN600',
O0601AAA0A: '601',
O0603AAA0A: '603',
O0606AAA0A: '606',
O0606AAV0A: 'SV606',
O0607AAA0A: '607',
O0608AAA0A: '608',
O0610AAA0A: '610',
O0611AAA0A: '611',
O0613AAA0A: '613',
O0613AAPAA: 'SPA613',
O0614AAA0A: '614',
O0616AAA0A: '616',
O0621AAA0A: '621',
O0622AAA0A: '622',
O0623AAA0A: '623',
O0624AAA0A: '624',
O0624AAN0A: 'SN624',
O0624AAV0A: 'SV624',
O0625AAA0A: '625',
O0626AAA0A: '626',
O0627AAA0A: '627',
O0628AAA0A: '628',
O0629AAA0A: '629',
O0629AAN0A: 'SN629',
O0630AAA0A: '630',
O0634AAA0A: '634',
O0635AAA0A: '635',
O0635AAP0A: 'SP635',
O0636AAA0A: '636',
O0636AAN0A: 'SN636',
O0638AAA0A: '638',
O0639AAA0A: '639',
O0639AAN0A: 'SN639',
O0645AAA0A: '645',
O0649AAA0A: 'LECD126',
O0650AAA0A: '650',
O0651AAA0A: '651',
O0652AAA0A: '652',
O0653AAA0A: '653',
O0663AAA0A: '663',
O0665AAA0A: '665',
O0665AAVAA: 'LECD147',
O0665AAVBA: 'SVB665',
O0669AAA0A: '669',
O0669AAV0A: 'SV669',
O0678AAA0A: '678',
O0679AAA0A: '679',
O0685AAA0A: 'LECD140',
O0685AAVAA: 'LECD148',
O0685AAVBA: 'LECD141',
O0687AAA0A: '687',
O0688AAA0A: '688',
O0691AAA0A: '691',
O0692AAA0A: '692',
O0692AAV0A: 'SV692',
O0693AAA0A: '693',
O0696AAA0A: '696',
O0696AAN0A: 'SN696',
O0711AAA0A: '711',
O0712AAA0A: '712',
O0721AAA0A: '721',
O0731AAA0A: '731',
O0731AAN0A: 'SN731',
O0737AAA0A: '737',
O0741AAA0A: '741',
O0743AAA0A: '743',
O0745AAA0A: '745',
O0746AAA0A: '746',
O0753AAA0A: '753',
O0754AAA0A: '754',
O0756AAA0A: '756',
O0757AAA0A: '757',
O0759AAA0A: '759',
O0759AAP0A: 'SP759',
O0764AAA0A: '764',
O0765AAA0A: '765',
O0766AAA0A: '766',
O0766AAP0A: 'SP766',
O0770AAA0A: '770',
O0771AAA0A: '771',
O0774AAA0A: '774',
O0774AAV0A: 'LECD142',
O0775AAA0A: '775',
O0777AAV0A: 'SV777',
O0778AAA0A: '778',
O0779AAA0A: '779',
O0779AAN0A: 'SN779',
O0780AAA0A: '780',
O0783AAA0A: '783',
O0785AAA0A: '785',
O0786AAA0A: '786',
O0788AAA0A: '788',
O0790AAA0A: '790',
O0790AAN0A: 'SN790',
O0790AAV0A: 'SV790',
O0791AAA0A: '791',
O0793AAA0A: '793',
O0794AAA0A: '794',
O0794AAN0A: 'SN794',
O0795AAP0A: 'SP795',
O0796AAA0A: '796',
O0798AAA0A: '798',
O0799AAA0A: '799',
O0801AAA0A: '801',
O0801AAP0A: 'SP801',
O0802AAA0A: '802',
O0803AAA0A: '803',
O0803AAN0A: 'SN803',
O0803AAP0A: 'SP803',
O0804AAA0A: '804',
O0805AAA0A: '805',
O0805AAN0A: 'SN805',
O0805AAP0A: 'SP805',
O0807AAA0A: '807',
O0808AAA0A: '808',
O0809AAA0A: '809',
O0810AAA0A: '810',
O0810AAN0A: 'SN810',
O0810AAP0A: 'SP810',
O0812AAA0A: '812',
O0817AAA0A: '817',
O0819AAA0A: '819',
O0820AAA0A: '820',
O0821AAA0A: '821',
O0821AAN0A: 'SN821',
O0822AAA0A: '822',
O0825AAA0A: '825',
O0825AAN0A: 'SN825',
O0826AAA0A: '826',
O0829AAA0A: '829',
O0830AAA0A: '830',
O0831AAA0A: '831',
O0831AAV0A: 'SV831',
O0833AAA0A: '833',
O0834AAA0A: '834',
O0835AAA0A: '835',
O0836AAA0A: '836',
O0837AAA0A: '837',
O0838AAA0A: '838',
O0840AAA0A: '840',
O0840AAN0A: 'SN840',
O0841AAA0A: '841',
O0842AAA0A: '842',
O0843AAA0A: '843',
O0844AAA0A: '844',
O0845AAA0A: '845',
O0846AAA0A: '846',
O0846AAN0A: 'SN846',
O0847AAA0A: '847',
O0848AAA0A: '848',
O0848AAN0A: 'SN848',
O0849AAA0A: '849',
O0850AAA0A: '850',
O0851AAA0A: '851',
O0852AAA0A: '852',
O0853AAA0A: '853',
O0853AAN0A: 'SN853',
O0853AAV0A: 'SV853',
O0854AAAAA: '854',
O0855AAA0A: '855',
O0856AAA0A: '856',
O0857AAA0A: '857',
O0857AAN0A: 'SN857',
O0859AAA0A: '859',
O0861AAA0A: '861',
O0861AAN0A: 'SN861',
O0862AAA0A: '862',
O0863AAA0A: '863',
O0863AAV0A: 'SV863',
O0864AAA0A: '864',
O0864AAP0A: 'SP864',
O0865AAA0A: '865',
O0866AAA0A: '866',
O0866AAV0A: 'SV866',
O0867AAA0A: '867',
O0867AAN0A: 'SN867',
O0868AAA0A: '868',
O0869AAA0A: '869',
O0870AAA0A: '870',
O0871AAA0A: '871',
O0872AAA0A: '872',
O0878AAA0A: '878',
O0878AAN0A: 'SN878',
O0878AAV0A: 'SV878',
O0880AAA0A: '880',
O0880AAN0A: 'SN880',
O0881AAA0A: '881',
O0883AAA0A: '883',
O0884AAA0A: '884',
O0884AAN0A: 'SN884',
O0885AAA0A: '885',
O0885AAN0A: 'SN885',
O0892AAA0A: '892',
O0893AAA0A: '893',
O0893AAN0A: 'SN893',
O0894AAA0A: '894',
O0895AAA0A: '895',
O0895AAN0A: 'SN895',
O0897AAA0A: '897',
O0897AAN0A: 'SN897',
O0898AAA0A: '898',
O0898AAN0A: 'SN898',
O0899AAA0A: '899',
O0899AAV0A: 'SV899',
O0900AAA0A: '900',
O0901AAA0A: '901',
O0901AAVBA: 'SVB901',
O0905AAA0A: '905',
O0906AAA0A: '906',
O0908AAA0A: '908',
O0908AAN0A: 'SN908',
O0908AAV0A: 'SV908',
O0910AAA0A: '910',
O0913AAA0A: '913',
O0915AAA0A: '915',
O0917AAA0A: '917',
O0917AAV0A: 'SV917',
O0918AAA0A: '918',
O0918AAP0A: 'SP918',
O0919AAA0A: '919',
O0919AAN0A: 'SN919',
O0920AAA0A: '920',
O0921AAA0A: '921',
O0921AAV0A: 'SV921',
O0922AAA0A: '922',
O0922AAV0A: 'SV922',
O0925AAA0A: '925',
O0926AAA0A: '926',
O0928AAA0A: '928',
O0932AAA0A: '932',
O0940AAA0A: '940',
O0944AAA0A: 'LECD143',
O0946AAA0A: '946',
O0950AAA0A: 'LECD144',
O0951AAA0A: 'LECD145',
O0954AAA0A: '954',
O0954AAN0A: 'SN954',
O0959AAA0A: 'LECD146',
O0961AAA0A: '961',
O0975AAA0A: '975',
O0979AAA0A: '979',
O0987AAA0A: '987',
O0990AAA0A: '990',
});
+1 -1
View File
@@ -85,7 +85,7 @@ test('Warendorf webcam display introduces the shared OSM credit', (t) => {
const v = viewer(t), empty = new Proxy({}, { get: () => noop });
const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty, navigation: empty }, source: {} });
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
});
+1
View File
@@ -2,6 +2,7 @@
export const SPRITE_LAYER_ORDER = Object.freeze([
'cctv',
'firms',
'wildlife-sightings',
'bikeshare',
'transit',
'transit-motion',
+22
View File
@@ -30,6 +30,28 @@ test('a superseded flow fetch is not an outage', () => {
assert.equal(deriveTrafficFlowError(undefined), null);
});
test('hosted account limits and service failures name their own reason', () => {
const failure = (status, code) =>
deriveTrafficFlowError(
Object.assign(new Error(`TomTom flow unavailable (HTTP ${status})`), {
status,
code,
}),
);
assert.equal(
failure(429, 'USAGE_LIMIT_REACHED'),
'Monthly traffic allowance used',
);
assert.equal(failure(429, 'RATE_LIMITED'), 'Traffic rate limit reached');
assert.equal(failure(429, 'budget'), 'TomTom daily budget reached');
assert.equal(failure(503, 'no_key'), 'TomTom key unavailable');
assert.equal(failure(503, null), 'TomTom key unavailable');
assert.equal(
failure(503, 'USAGE_UNAVAILABLE'),
'Traffic service unavailable',
);
});
test('flow failures map onto short, specific reasons', () => {
const reason = (message) => deriveTrafficFlowError(new Error(message));
assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable');
+59
View File
@@ -1,4 +1,5 @@
import { greatCircleKm } from '../geoDistance.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
/**
* @module transitFeeds
@@ -376,6 +377,18 @@ function burlingtonRouteLabel(routeId) {
return BURLINGTON_ROUTE_NAMES[routeId] || routeId;
}
/**
* Rio route key to the line a rider would say. One SMTR `routes.txt` covers
* both feeds (see rioRouteNames.js).
* @param {string|null} routeId
* @returns {string|null}
*/
function rioRouteLabel(routeId) {
return Object.hasOwn(RIO_ROUTE_NAMES, routeId)
? RIO_ROUTE_NAMES[routeId]
: routeId;
}
/**
* Registry of feeds. Order is presentation order in the stats/credit text.
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
@@ -729,6 +742,52 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus',
routeLabel: burlingtonRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-onibus',
name: 'Rio buses (SPPO)',
operator: 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro',
region: 'Rio de Janeiro, RJ',
// Every vehicle in a 2026-10-29 snapshot, from Santa Cruz in the west to
// the Zona Sul, sat within 31 km of this point.
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/onibus/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f',
attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro — buses (SPPO)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds are m/s, as the spec requires: checked against vehicle movement on 2026-11-05, though a 2026-10-29 snapshot read as km/h. Line names come from SMTR’s GTFS `routes.txt`, under the same CC BY 4.0 terms.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-brt',
name: 'Mobi-Rio BRT',
operator:
'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro, for the Mobi-Rio BRT fleet',
region: 'Rio de Janeiro, RJ',
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/brt/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4',
attribution:
'SMTR / Prefeitura da Cidade do Rio de Janeiro — BRT (Mobi-Rio)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are m/s too. Line names come from the same `routes.txt`.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
]);
/**
+70
View File
@@ -15,6 +15,7 @@ import {
transitModeResolved,
transitRouteLabel,
} from './transitFeeds.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
const ids = new Set();
@@ -482,3 +483,72 @@ test('Burlington is live and carries its terms link, which its licence requires'
assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/);
assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all');
});
test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São Paulo', () => {
const ids = (lat, lon) =>
transitFeedsInRange(lat, lon).map((feed) => feed.id);
for (const [place, lat, lon] of [
['Centro', -22.9068, -43.1729],
['Barra da Tijuca', -23.0004, -43.3659],
['Santa Cruz', -22.9194, -43.6847],
]) {
assert.ok(
ids(lat, lon).includes('smtr-rio-onibus'),
`buses reach ${place}`,
);
assert.ok(ids(lat, lon).includes('smtr-rio-brt'), `BRT reaches ${place}`);
}
assert.deepEqual(
ids(-23.5505, -46.6333).filter((id) => id.startsWith('smtr-rio')),
[],
'São Paulo, 360 km away, never polls Rio',
);
});
test('Rio route keys read as the line on the front of the bus', () => {
// Some keys look parseable and are not (`O0014AAA0A` is LECD135).
const buses = getTransitFeed('smtr-rio-onibus');
assert.equal(transitRouteLabel(buses, 'O0868AAA0A'), '868');
assert.equal(transitRouteLabel(buses, 'O0731AAN0A'), 'SN731');
assert.equal(transitRouteLabel(buses, 'O0759AAP0A'), 'SP759');
assert.equal(transitRouteLabel(buses, 'O0014AAA0A'), 'LECD135');
assert.equal(transitRouteLabel(buses, 'E2303AAA0A'), '2303');
const brt = getTransitFeed('smtr-rio-brt');
assert.equal(transitRouteLabel(brt, '20000501110'), '50');
assert.equal(transitRouteLabel(brt, '20000EXEC1110'), 'ESP01');
// Unknown ids, prototype names included, keep their raw key.
assert.equal(transitRouteLabel(buses, 'O9999AAA0A'), 'O9999AAA0A');
assert.equal(transitRouteLabel(buses, 'constructor'), 'constructor');
assert.equal(transitRouteLabel(brt, null), null);
});
test('the Rio route table is a clean join of published keys to names', () => {
const entries = Object.entries(RIO_ROUTE_NAMES);
assert.ok(entries.length > 400, 'the whole published table is carried');
for (const [id, name] of entries) {
assert.match(id, /^(?:[OE]\d{4}[A-Z0-9]{5}|2\d{4}[A-Z0-9]{4,8})$/, id);
assert.ok(name && name === name.trim(), `${id} has a name`);
assert.notEqual(name, id, `${id} is translated`);
}
});
test('Rio feeds are CC BY 4.0 and live', () => {
for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) {
const feed = getTransitFeed(id);
assert.equal(feed.defaultEnabled, true, `${id} is live`);
assert.equal(feed.license, 'CC BY 4.0');
assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//);
assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/);
assert.equal(transitModeFor(feed, null), 'bus');
}
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources
.split('\n')
.some((line) => line.startsWith('| **SMTR Rio de Janeiro')),
'Rio has a DATA_SOURCES row',
);
});
+10
View File
@@ -137,6 +137,7 @@ function encodeFeed({
const position = new PbfWriter();
position.writeFloatField(1, entity.lat);
position.writeFloatField(2, entity.lon);
if (entity.speed != null) position.writeFloatField(5, entity.speed);
vehicle.writeBytesField(2, position.finish());
if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp);
const descriptor = new PbfWriter();
@@ -284,6 +285,15 @@ test('the snapshot builder fills timestamps from the header it decoded', () => {
assert.equal(snapshot.feedTimestamp, 1_700_000_000);
});
test('Rio speeds pass through as the m/s they are published in', () => {
// Checked against vehicle movement on 2026-11-05.
const bytes = encodeFeed({
entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }],
});
const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1);
assert.equal(rio.vehicles[0].speedMps, 10);
});
test('conditional-request validators and gzip ride along with feed identification', () => {
const entur = getTransitFeed('entur-norway');
const plain = transitUpstreamHeaders(entur);
+20 -4
View File
@@ -983,7 +983,13 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// ordering while allowing source formatting. Derived from the unchanged
// release schema before formatting (the previous source-byte pin passed).
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
(tool) => tool.name !== 'set_cyber_sonar',
(tool) =>
![
'set_cyber_sonar',
'resolve_area',
'find_imagery',
'osm_query',
].includes(tool.name),
);
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
.parameters.properties.layout;
@@ -1014,6 +1020,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
'noir',
]);
visualStyle.enum = [...visualStyle.enum, 'snow'];
// Tilt to an angle adds pitchDeg and rewords direction; restore
// the legacy camera fields to keep the pin.
const camera = legacyTools.find((tool) => tool.name === 'move_camera')
.parameters.properties;
assert.equal(camera.pitchDeg.type, 'number');
delete camera.pitchDeg;
camera.direction.description =
'Required for pan/tilt/rotate; orbit/rotate_globe default right.';
const block = JSON.stringify(legacyTools);
// Re-derived for the voice layer manifest (generated layer enums, alias and
// field hints), point-and-ask's pointer/referent arguments and the prompt
@@ -1031,11 +1045,13 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// S2 GhostMaps extends the generated layer enums; both feature contracts remain.
// The layer alias hint leaves out aliases the layer id already spells.
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
// Pistes extend the generated layer enums and alias hint.
assert.equal(block.length, 31026, 'serialized tool schema length drifted');
// Pistes extend the generated layer enums and alias hint; voice geometry
// adds the analyst area scopes and the osm-places layer.
// control_cockpit states the own-layer entry rule (Contacts not required).
assert.equal(block.length, 31372, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'5f5275cd6e320afef4fd224b235f3e0f191ec06599b257ffe1f60b703a255199',
'7f593e78c232db972b04441f830b6a60f93916a6e5bc5a68eca7422cd1035508',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+162
View File
@@ -0,0 +1,162 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PassThrough } from 'node:stream';
import { EventEmitter } from 'node:events';
import { createGeminiChatHandler } from '../server/providers/gemini/chat.js';
const contents = [{ role: 'user', parts: [{ text: 'Hello' }] }];
async function request(input, overrides = {}) {
const calls = [];
const handler = createGeminiChatHandler({
resolveApiKey: () => 'fixture-key',
resolveRateLimit: () => '0',
resolveHost: () => '',
resolveAllowedHosts: () => '',
tools: [
{ name: 'fly_to', description: 'Fly', parameters: { type: 'object' } },
],
fetchImpl: async (url, options) => {
calls.push({ url, body: JSON.parse(options.body) });
return Response.json({
candidates: [
{
content: { role: 'model', parts: [{ text: 'Hello' }] },
finishReason: 'STOP',
},
],
});
},
...overrides,
});
const req = new PassThrough();
req.method = 'POST';
req.headers = {
host: 'localhost:4190',
origin: 'http://localhost:4190',
'content-type': 'application/json',
};
req.socket = { remoteAddress: '127.0.0.1' };
const res = new EventEmitter();
res.setHeader = () => {};
res.end = (body) => {
res.body = JSON.parse(body);
res.writableEnded = true;
};
const done = handler(req, res);
req.end(JSON.stringify(input));
await done;
return { calls, res };
}
test('Gemini chat pins text output, server tools and model without Live settings', async () => {
const { calls, res } = await request({ contents });
assert.equal(res.statusCode, 200);
assert.match(calls[0].url, /gemini-3.5-flash-lite:generateContent$/);
assert.deepEqual(calls[0].body.generationConfig.responseModalities, ['TEXT']);
assert.equal(
calls[0].body.tools[0].functionDeclarations[0].behavior,
undefined,
);
assert.equal(calls[0].body.outputAudioTranscription, undefined);
});
test('chat rejects caller configuration, media and undeclared tool history before upstream', async () => {
for (const input of [
{ contents, model: 'other' },
{ contents: [{ role: 'user', parts: [{ inlineData: { data: 'AA==' } }] }] },
{
contents: [
{
role: 'model',
parts: [{ functionCall: { name: 'not_allowed', args: {} } }],
},
],
},
]) {
const { calls, res } = await request(input);
assert.equal(res.statusCode, 400);
assert.equal(calls.length, 0);
}
});
test('chat refuses audio models and hides upstream errors', async () => {
const bad = await request(
{ contents },
{ resolveChatModel: () => 'gemini-3.8-live' },
);
assert.equal(bad.res.statusCode, 503);
assert.equal(bad.calls.length, 0);
const failure = await request(
{ contents },
{
fetchImpl: async () =>
new Response('sensitive upstream detail', { status: 500 }),
},
);
assert.equal(failure.res.statusCode, 502);
assert.ok(!JSON.stringify(failure.res.body).includes('sensitive'));
});
test('chat history round-trips signatures and function results while rejecting user signatures', async () => {
const exchange = [
...contents,
{
role: 'model',
parts: [
{
thoughtSignature: 'opaque',
functionCall: { name: 'fly_to', args: {}, id: 'call' },
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'fly_to',
id: 'call',
response: { ok: true },
},
},
],
},
];
const good = await request({ contents: exchange });
assert.equal(good.res.statusCode, 200);
assert.deepEqual(good.calls[0].body.contents, exchange);
const bad = await request({
contents: [
{ role: 'user', parts: [{ text: 'Hello', thoughtSignature: 'opaque' }] },
],
});
assert.equal(bad.res.statusCode, 400);
});
test('foreign and missing origins cannot spend the server key', async () => {
for (const origin of [undefined, 'https://elsewhere.example']) {
let calls = 0;
const handler = createGeminiChatHandler({
resolveHost: () => '',
resolveAllowedHosts: () => '',
fetchImpl: () => {
calls++;
throw Error('unexpected');
},
});
const req = new PassThrough();
req.method = 'POST';
req.headers = {
host: 'localhost:4190',
...(origin ? { origin } : {}),
'content-type': 'application/json',
};
req.socket = { remoteAddress: '127.0.0.1' };
const res = new EventEmitter();
res.setHeader = () => {};
res.end = () => {
res.writableEnded = true;
};
const done = handler(req, res);
req.end('{}');
await done;
assert.equal(res.statusCode, 403);
assert.equal(calls, 0);
}
});
+6 -4
View File
@@ -5,6 +5,7 @@ import { PassThrough } from 'node:stream';
import { rm } from 'node:fs/promises';
import { localProviderPlugins } from '../server/providers/local.js';
import { standaloneVoiceTools } from '../server/standalone/voiceTools.js';
import { offeredTools } from '../server/providers/openai/tools.js';
import {
KEY_SETUP_KEYS,
keySetupStatus,
@@ -60,8 +61,7 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
plugin[hook]({
middlewares: {
use(route, value) {
assert.equal(route, '/api/gemini/token');
handler = value;
if (route === '/api/gemini/token') handler = value;
},
},
});
@@ -85,13 +85,15 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
assert.equal(calls.length, 1);
const locked = JSON.parse(calls[0].options.body).bidiGenerateContentSetup
.tools[0].functionDeclarations;
// Without an operator Overpass, osm_query is left out of the session.
const offered = offeredTools(tools);
assert.deepEqual(
locked.map(({ name }) => name),
tools.map(({ name }) => name),
offered.map(({ name }) => name),
);
assert.deepEqual(
locked.map(({ parametersJsonSchema }) => parametersJsonSchema),
tools.map(({ parameters }) => parameters),
offered.map(({ parameters }) => parameters),
);
assert.ok(locked.every(({ behavior }) => behavior === 'BLOCKING'));
assert.deepEqual(res.body.config.tools[0].functionDeclarations, locked);
+19 -15
View File
@@ -16,7 +16,11 @@ import {
GEMINI_LIVE_WEBSOCKET_URL,
createGeminiLiveConfig,
} from '../server/providers/gemini/config.js';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import {
GEV_REALTIME_TOOLS,
offeredTools,
realtimeTools,
} from '../server/providers/openai/tools.js';
import { PROXY_SIGNALS } from './localRequestGate.mjs';
const API_KEY = 'private-gemini-test-key-never-return';
@@ -103,9 +107,8 @@ test('dev and preview install the same bounded Gemini token endpoint', () => {
geminiLiveProxy()[hook]({
middlewares: { use: (...args) => installed.push(args) },
});
assert.equal(installed.length, 1);
assert.equal(installed[0][0], '/api/gemini/token');
assert.equal(typeof installed[0][1], 'function');
assert.deepEqual(installed.map(([route]) => route), ['/api/gemini/chat', '/api/gemini/token']);
assert.ok(installed.every(([, handler]) => typeof handler === 'function'));
}
});
@@ -219,16 +222,15 @@ test('mint locks canonical action schemas and server instructions with no perman
undefined,
);
const functions = data.config.tools[0].functionDeclarations;
// Without an operator Overpass, osm_query is left out of the session.
const offered = realtimeTools();
assert.deepEqual(
functions.map((tool) => tool.name),
GEV_REALTIME_TOOLS.map((tool) => tool.name),
offered.map((tool) => tool.name),
);
for (let i = 0; i < functions.length; i++) {
assert.equal(functions[i].behavior, 'BLOCKING');
assert.deepEqual(
functions[i].parametersJsonSchema,
GEV_REALTIME_TOOLS[i].parameters,
);
assert.deepEqual(functions[i].parametersJsonSchema, offered[i].parameters);
assert.equal(functions[i].parameters, undefined);
}
const [url, init] = f.calls[0];
@@ -876,12 +878,14 @@ test(
test('standalone mints lock the full voice query catalog in both credential and browser setup', async (t) => {
const tools = standaloneVoiceTools();
const declarations = tools.map(({ name, description, parameters }) => ({
name,
description,
parametersJsonSchema: parameters,
behavior: 'BLOCKING',
}));
const declarations = offeredTools(tools).map(
({ name, description, parameters }) => ({
name,
description,
parametersJsonSchema: parameters,
behavior: 'BLOCKING',
}),
);
for (const name of ['search_places', 'get_weather', 'get_wind', 'plan_route'])
assert.ok(
tools.some((tool) => tool.name === name),
+179
View File
@@ -0,0 +1,179 @@
import * as Cesium from 'cesium';
// Cesium's free look (Shift+drag) turns the camera in place. Near the ground
// that surveys a city; from orbit a few drags turn the whole planet out of
// frame and leave only stars.
export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000;
// A voice turn from orbit stops when about this share of the frame still shows
// the planet's surface.
export const MIN_GLOBE_FRAME_SHARE = 0.1;
/**
* A free globe camera: the viewer is in 3D, follows no tracked entity, and its
* camera is not locked to a reference frame.
*/
export function isFreeGlobeCamera(viewer) {
return (
viewer.scene.mode === Cesium.SceneMode.SCENE3D &&
!viewer.trackedEntity &&
Cesium.Matrix4.equals(viewer.camera.transform, Cesium.Matrix4.IDENTITY)
);
}
const GRID_COLUMNS = 32;
const GRID_ROWS = 18;
// A turn across a sliver of planet moves its edge through sample points, so
// the share wobbles by a cell or two without the planet leaving.
const GRID_NOISE_CELLS = 2;
// Whatever the baseline, a voice turn never takes the planet below this share
// of the frame; a view that already shows less may only keep what it has.
const LOOK_ABSOLUTE_FLOOR_SHARE = 0.01;
const sampleRay = new Cesium.Ray();
const sampleOffset = new Cesium.Cartesian3();
/**
* Cast a 32×18 grid of view rays, one through the centre of each cell, at the
* WGS84 ellipsoid. With `keyholeRadius` (a radius around the frame centre, in
* units of half the frame height) only the cells inside that circle count: the
* scope mask hides the rest. Returns the surface hits and the cells counted, or
* null for a frustum other than a perspective one.
*/
function sampleFrame(camera, keyholeRadius) {
const frustum = camera.frustum;
if (!(frustum instanceof Cesium.PerspectiveFrustum)) return null;
const aspect = frustum.aspectRatio;
const tanY = Math.tan(frustum.fovy / 2);
const tanX = tanY * aspect;
const keyholeSquared = keyholeRadius > 0 ? keyholeRadius * keyholeRadius : 0;
Cesium.Cartesian3.clone(camera.positionWC, sampleRay.origin);
let hits = 0;
let cells = 0;
for (let row = 0; row < GRID_ROWS; row += 1) {
const rowY = (2 * (row + 0.5)) / GRID_ROWS - 1;
for (let column = 0; column < GRID_COLUMNS; column += 1) {
const columnX = (2 * (column + 0.5)) / GRID_COLUMNS - 1;
if (
keyholeSquared &&
(columnX * aspect) ** 2 + rowY * rowY > keyholeSquared
)
continue;
cells += 1;
Cesium.Cartesian3.multiplyByScalar(
camera.rightWC,
columnX * tanX,
sampleOffset,
);
Cesium.Cartesian3.add(
camera.directionWC,
sampleOffset,
sampleRay.direction,
);
Cesium.Cartesian3.multiplyByScalar(
camera.upWC,
rowY * tanY,
sampleOffset,
);
Cesium.Cartesian3.add(
sampleRay.direction,
sampleOffset,
sampleRay.direction,
);
Cesium.Cartesian3.normalize(sampleRay.direction, sampleRay.direction);
if (
Cesium.IntersectionTests.rayEllipsoid(sampleRay, Cesium.Ellipsoid.WGS84)
)
hits += 1;
}
}
return { hits, cells };
}
/**
* Share of the frame, from 0 to 1, that shows the planet's surface: the
* fraction of a 32×18 grid of view rays, one through the centre of each cell,
* that hit the WGS84 ellipsoid. With `keyholeRadius` (see `sampleFrame`) it is
* the share of the visible circle instead. A frustum other than a perspective
* one counts as fully covered.
*/
export function globeFrameShare(camera, { keyholeRadius = 0 } = {}) {
const sample = sampleFrame(camera, keyholeRadius);
if (!sample || sample.cells === 0) return 1;
return sample.hits / sample.cells;
}
/** True when none of the planet's surface is in `camera`'s visible frame. */
export function globeOutOfFrame(camera, options) {
return globeFrameShare(camera, options) === 0;
}
const lookProbes = new WeakMap();
const LOOK_EDGE_TOLERANCE = Cesium.Math.toRadians(0.05);
/**
* How much of one in-place look step (`lookUp`, `lookDown`, `lookLeft` or
* `lookRight` by a non-negative `angle` in radians) keeps the planet in frame
* from orbit. Above the free-look height the step may not leave less than
* `MIN_GLOBE_FRAME_SHARE` of the visible frame on the planet, or less than
* `startShare` when that is lower, give or take two grid cells of sampling
* noise. However the baseline was taken, it never leaves less than 1% of the
* frame, and a view that already shows less may only keep or gain what it has.
* `startShare` is the baseline the turn is measured against (the current share
* by default): callers pass one value to every step of a motion, and to every
* turn that continues from where the last one left off, so the limit cannot
* slide down a step or a command at a time. `keyholeRadius` limits the measure
* to the scope's visible circle, as in `globeFrameShare`.
* With nothing on screen the limit is nothing either, so the step is left to
* the move-end recovery. Returns `angle` when the whole step fits, otherwise a
* smaller angle that fits, found by bisection to within 0.05°, so a stopped
* motion rests at the limit. Steps are tried on a copy; `camera` is not moved.
*/
export function lookAngleWithinFrame(
camera,
look,
angle,
{ startShare, keyholeRadius = 0 } = {},
) {
if (
!(angle > 0) ||
!(camera.positionCartographic?.height > FREE_LOOK_MAX_HEIGHT_METERS)
)
return angle;
const sample = sampleFrame(camera, keyholeRadius);
if (!sample || sample.cells === 0) return angle;
const share = sample.hits / sample.cells;
const baseline = startShare ?? share;
// Capped at what is on screen now, so a step that keeps or gains surface is
// never refused, even when the frame has shrunk since the baseline was taken
// (a resized window or a turned phone).
const floor = Math.min(
share,
Math.max(
Math.min(MIN_GLOBE_FRAME_SHARE, baseline) -
GRID_NOISE_CELLS / sample.cells,
Math.min(LOOK_ABSOLUTE_FLOOR_SHARE, baseline),
),
);
const fits = (step) => {
const probe = Cesium.Camera.clone(camera, lookProbes.get(camera));
lookProbes.set(camera, probe);
probe[look](step);
// Shares are whole grid cells; the epsilon keeps rounding in `floor` from
// costing a cell.
return globeFrameShare(probe, { keyholeRadius }) >= floor - 1e-9;
};
if (fits(angle)) return angle;
let inside = 0;
let outside = angle;
while (outside - inside > LOOK_EDGE_TOLERANCE) {
const middle = (inside + outside) / 2;
if (fits(middle)) inside = middle;
else outside = middle;
}
return inside;
}
/** True when an in-place look step of `angle` would lose part of its turn. */
export function lookStepLosesGlobe(camera, look, angle, options) {
return lookAngleWithinFrame(camera, look, angle, options) < angle;
}
+260 -4
View File
@@ -1,7 +1,4 @@
{
"Close camera preview": "إغلاق معاينة الكاميرا",
"Refresh map still": "تحديث صورة الخريطة الثابتة",
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
"NO PLACE LEFT BEHIND": "لا مكان يُترك خلفنا",
"Initializing photorealistic world...": "جارٍ تهيئة العالم الواقعي…",
"Configuring viewer...": "جارٍ إعداد العارض…",
@@ -304,6 +301,8 @@
"AVAILABLE MISSIONS": "المهام المتاحة",
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا",
"Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.",
"30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح",
"RADIO": "الراديو",
"ENABLE": "تفعيل",
"Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة",
@@ -1750,8 +1749,16 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى (يحتاج إلى مفتاح TomTom)",
"Hybrid": "هجين",
"Lines": "خطوط",
"Dots": "نقاط",
"Road lines colored by live flow: green to red": "خطوط الطرق ملوّنة حسب التدفق المباشر: من الأخضر إلى الأحمر",
"Moving dots colored by live flow": "نقاط متحركة ملوّنة حسب التدفق المباشر",
"Moving dots": "نقاط متحركة",
"TomTom key unavailable": "مفتاح TomTom غير متاح",
"TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom",
"Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية",
"Traffic rate limit reached": "تم بلوغ حد طلبات حركة المرور",
"Traffic service unavailable": "خدمة حركة المرور غير متاحة",
"TomTom upstream unreachable": "تعذّر الوصول إلى خادم TomTom",
"TomTom flow unavailable": "تدفق TomTom غير متاح",
"SIMULATED — {reason}": "محاكى — {reason}",
@@ -2190,6 +2197,9 @@
"{value} KM/H": "{value} كم/س",
"{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "مصدر البيانات العسكرية متوقف · صورة جزئية",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "مصدر الرحلات متوقف · صورة جزئية",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "قائمة الطائرات القريبة غير مكتملة · لا يعني أن الوضع آمن",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
@@ -3217,6 +3227,19 @@
"Download the timing log of the last play": "تنزيل سجل توقيت آخر تشغيل",
"Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة",
"Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد",
"fire confidence filter|All": "الكل",
"fire confidence filter|Nominal+": "متوسطة فأعلى",
"fire confidence filter|High": "عالية",
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
"Fire intensity (FRP)": "شدة الحريق (FRP)",
"≥ {value} MW": "{value} MW على الأقل",
"Confidence": "الثقة",
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
"fire intensity filter|All": "الكل",
"Detection confidence": "ثقة الرصد",
"{shown} of {total} shown": "المعروضة: {shown} من {total}",
"Name of {name}": "اسم {name}",
"Change the colour of {name}": "تغيير لون {name}",
"Colour of {name}": "لون {name}",
@@ -3226,7 +3249,240 @@
"UTILITIES": "الأدوات",
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
"Sea Marks": "علامات بحرية",
"Voice place search (needs an Overpass server)": "البحث الصوتي عن الأماكن (يتطلب خادم Overpass)",
"OSM Places": "أماكن OSM",
"state or province": "ولاية أو إقليم",
"county or district": "مقاطعة أو قضاء",
"neighbourhood": "حي",
"natural region": "منطقة طبيعية",
"Which {place}?": "أي {place} تقصد؟",
"Which place?": "أي مكان تقصد؟",
"Not drawn: {parts}": "لم يُرسم المخطط: {parts}",
"approximate": "تقريبي",
"{pct}% cloud": "الغيوم {pct}%",
"Over {area}": "فوق {area}",
"{count} of {total} acquisitions match": {
"few": "الصور المطابقة: {count} من {total}",
"many": "الصور المطابقة: {count} من {total}",
"one": "الصور المطابقة: {count} من {total}",
"two": "الصور المطابقة: {count} من {total}",
"zero": "الصور المطابقة: {count} من {total}",
"other": "الصور المطابقة: {count} من {total}"
},
"{n} acquisitions searched": {
"zero": "الصور المفحوصة: {n}",
"one": "الصور المفحوصة: {n}",
"two": "الصور المفحوصة: {n}",
"few": "الصور المفحوصة: {n}",
"many": "الصور المفحوصة: {n}",
"other": "الصور المفحوصة: {n}"
},
"No matching imagery": "لا توجد صور مطابقة",
"Unknown number of {kind}": "{kind}: العدد غير معروف",
"About {count} {kind}": "{kind}: نحو {count}",
"At least {count} {kind}": "{kind}: {count} على الأقل",
"{count} {kind}": "{kind}: {count}",
"In {area}": "في {area}",
"Area around {place}": "المنطقة حول {place}",
"Area around landmark": "المنطقة حول المعلم",
"Outline {place}": "رسم مخطط {place}",
"Outline area": "رسم مخطط منطقة",
"Find {place}": "البحث عن {place}",
"Find area": "البحث عن منطقة",
"Find recent imagery": "البحث عن صور حديثة",
"Find {what}": "البحث عن {what}",
"Find places": "البحث عن أماكن",
"Drawing outline": "جارٍ رسم المخطط",
"Searching imagery catalog": "جارٍ البحث في فهرس الصور",
"Loading imagery": "جارٍ تحميل الصور",
"Searching OpenStreetMap": "جارٍ البحث في OpenStreetMap",
"voice area|Area": "منطقة",
"buffer sized to the place": "منطقة تقريبية بحجم المكان",
"enclosing mapped area": "المنطقة المرسومة المحيطة",
"the map was cleared while looking it up": "مُسحت الخريطة أثناء البحث",
"OpenStreetMap coverage varies by place": "تختلف تغطية OpenStreetMap من مكان لآخر",
"the catalog covers the last 30 days": "يغطي الفهرس آخر 30 يومًا",
"there are more": "هناك المزيد",
"{radius} m around the landmark": "{radius} م حول المعلم",
"{count} parts": {
"few": "الأجزاء: {count}",
"many": "الأجزاء: {count}",
"one": "الأجزاء: {count}",
"other": "الأجزاء: {count}",
"two": "الأجزاء: {count}",
"zero": "الأجزاء: {count}"
},
"{drawn} of {total} parts drawn": {
"few": "الأجزاء المرسومة: {drawn} من {total}",
"many": "الأجزاء المرسومة: {drawn} من {total}",
"one": "الأجزاء المرسومة: {drawn} من {total}",
"other": "الأجزاء المرسومة: {drawn} من {total}",
"two": "الأجزاء المرسومة: {drawn} من {total}",
"zero": "الأجزاء المرسومة: {drawn} من {total}"
},
"{count} duplicate mappings merged": {
"few": "التكرارات المدمجة: {count}",
"many": "التكرارات المدمجة: {count}",
"one": "التكرارات المدمجة: {count}",
"other": "التكرارات المدمجة: {count}",
"two": "التكرارات المدمجة: {count}",
"zero": "التكرارات المدمجة: {count}"
},
"listed the first {limit}": "عُرض أول {limit}",
"counted over the bounding box of {area}, not its outline": "جرى العدّ ضمن المستطيل المحيط بمنطقة {area}، لا ضمن حدودها الفعلية",
"Search cancelled": "أُلغي البحث",
"Sea Marks": "العلامات البحرية",
"Ski Pistes": "مسارات التزلج",
"Fly to Tokyo": "انتقل إلى طوكيو",
"Show live flights": "اعرض تتبّع الرحلات المباشر",
"Reset to globe view": "ارجع إلى عرض الكرة الأرضية",
"Show earthquakes": "اعرض الزلازل",
"AI chat": "دردشة الذكاء الاصطناعي",
"GEV CHAT": "دردشة GEV",
"Ask about the map. Tell it where to go.": "اسأل عن الخريطة، ووجّهها إلى حيث تريد.",
"Close chat": "إغلاق الدردشة",
"Conversation": "المحادثة",
"Explore with a question": "استكشف بسؤال",
"Find a place, explore live data, or change your view.": "ابحث عن مكان، أو استكشف البيانات المباشرة، أو غيّر العرض الحالي.",
"Attachments": "المرفقات",
"Remove attachment": "إزالة المرفق",
"Attach a map layer": "إرفاق طبقة خريطة",
"Choose a layer…": "اختر طبقة…",
"Attach a file": "إرفاق ملف",
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT أو Markdown أو CSV أو JSON أو GeoJSON · حتى 10 كيلوبايت إجمالًا. تُرسل المرفقات إلى مزوّد الذكاء الاصطناعي مع رسالتك.",
"Latest AI response": "أحدث رد من الذكاء الاصطناعي",
"Expand conversation": "توسيع المحادثة",
"Command suggestions": "اقتراحات الأوامر",
"Expand chat": "توسيع الدردشة",
"Add attachment": "إضافة مرفق",
"Message GEV": "رسالة إلى GEV",
"Ask GEV anything about the map…": "اسأل GEV أي شيء عن الخريطة…",
"Stop reply": "إيقاف الرد",
"Send message": "إرسال الرسالة",
"Panel position": "موضع اللوحة",
"Move above the dock": "النقل فوق الشريط السفلي",
"Dock to the right": "تثبيت على اليسار",
"Expand panel": "توسيع اللوحة",
"Minimize panel": "تصغير اللوحة",
"You can attach up to four items.": "يمكنك إرفاق أربعة عناصر كحد أقصى.",
"Attachments are too large together. Use smaller files.": "الحجم الإجمالي للمرفقات كبير جدًا. استخدم ملفات أصغر.",
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "اختر ملف TXT أو Markdown أو CSV أو JSON أو GeoJSON.",
"Each file must be 10 KB or smaller.": "يجب ألا يتجاوز حجم كل ملف 10 كيلوبايت.",
"Choose a non-empty UTF-8 text file.": "اختر ملفًا نصيًا غير فارغ بترميز UTF-8.",
"Connecting…": "جارٍ الاتصال…",
"Thinking…": "جارٍ التفكير…",
"Working on the map…": "جارٍ العمل على الخريطة…",
"Silent chat · microphone off": "دردشة صامتة · الميكروفون متوقف",
"Reply interrupted. You can try again.": "انقطع الرد. يمكنك إعادة المحاولة.",
"Could not connect. Try again.": "تعذّر الاتصال. أعد المحاولة.",
"Session ended. Send a message to start a new conversation.": "انتهت الجلسة. أرسل رسالة لبدء محادثة جديدة.",
"Connection timed out. Try again.": "انتهت مهلة الاتصال. أعد المحاولة.",
"Chat closed.": "أُغلقت الدردشة.",
"Connection stopped.": "توقف الاتصال.",
"Message and attachments are too large. Remove an attachment or shorten your message.": "الرسالة والمرفقات كبيرة جدًا. أزل مرفقًا أو اختصر رسالتك.",
"Message was not sent. Try again.": "لم تُرسل الرسالة. أعد المحاولة.",
"Reply stopped.": "أُوقف الرد.",
"Suggestions": "الاقتراحات",
"Recent": "الأخيرة",
"Quick commands": "الأوامر السريعة",
"Open AI chat": "فتح دردشة الذكاء الاصطناعي",
"CHAT": "دردشة",
"Back to chat": "العودة إلى الدردشة",
"Back to voice": "العودة إلى الصوت",
"Latest voice exchange": "آخر حوار صوتي",
"Try saying": "جرّب أن تقول",
"What am I looking at?": "ما الذي أنظر إليه؟",
"AI Provider": "مزوّد الذكاء الاصطناعي",
"The selected AI provider is shared by voice and chat.": "مزوّد الذكاء الاصطناعي المختار مشترك بين الصوت والدردشة.",
"Show quick commands": "إظهار الأوامر السريعة",
"Chat replies are silent. No microphone access is needed.": "ردود الدردشة صامتة. لا حاجة إلى الوصول إلى الميكروفون.",
"Chat": "الدردشة",
"Attach layer": "إرفاق الطبقة",
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "تستخدم الدردشة النصية مفتاح OpenAI API الخاص بك. تسجيل الدخول إلى ChatGPT يشمل الصوت فقط.",
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "الدردشة النصية غير متاحة ما دام GEV_PREFER_CODEX_OAUTH يفرض استخدام ChatGPT OAuth.",
"That request needed too many steps. Changes already made to the map stay in place.": "تطلّب هذا الطلب خطوات كثيرة جدًا. تبقى التغييرات التي أُجريت على الخريطة كما هي.",
"That request needed too many steps. Try a simpler request.": "تطلّب هذا الطلب خطوات كثيرة جدًا. جرّب طلبًا أبسط.",
"The provider rejected the API key. Check it in Provider Settings.": "رفض المزوّد مفتاح API. تحقّق منه في «إعدادات المزوّدين».",
"Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. أعد المحاولة بعد {seconds} ث.",
"The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول باستخدام هذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات.",
"{lower} to {upper}": "من {lower} إلى {upper}",
"Up to {upper}": "حتى {upper}",
"From {lower}": "من {lower}",
"Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات",
"Unnamed zone": "منطقة بلا اسم",
"Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح",
"Prohibited (P)": "محظور (P)",
"Restricted (R)": "مقيّد (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي المحظور والمقيّد ذو الاستخدام الخاص لدى FAA فقط، ولا يشمل UAS Facility Map ولا قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
"Prohibited": "محظور",
"Restricted": "مقيّد",
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
"SIGHTING": "مشاهدة",
"Show {name} sighting": "إظهار مشاهدة {name}",
"seen {age} ago": "شوهد قبل {age}",
"observed {age} ago": "رُصد قبل {age}",
"Be Whale Wise": "راقب الحيتان بمسؤولية",
"Reported by {name} ({entity})": "أبلغ عنه {name} ({entity})",
"Reported by {name}": "أبلغ عنه {name}",
"Salish Sea": "بحر ساليش",
"Birds": "الطيور",
"Mammals": "الثدييات",
"Reptiles": "الزواحف",
"Amphibians": "البرمائيات",
"Ray-finned fishes": "الأسماك شعاعية الزعانف",
"Insects": "الحشرات",
"Arachnids": "العنكبوتيات",
"Plants": "النباتات",
"Fungi": "الفطريات",
"Other animals": "حيوانات أخرى",
"Other or unknown": "أخرى أو غير معروفة",
"{label} {detail} — click to retry": "{label} {detail} — انقر لإعادة المحاولة",
"Wildlife sightings source unavailable": "مصدر مشاهدات الحياة البرية غير متاح",
"Show only community-confirmed (research-grade) sightings": "إظهار المشاهدات المؤكدة مجتمعياً (بدرجة بحثية) فقط",
"No recent sightings in view": "لا توجد مشاهدات حديثة في العرض",
"Acartia feed unavailable": "تعذر الوصول إلى مصدر Acartia",
"Show all taxa": "إظهار جميع المجموعات التصنيفية",
"BIRDS": "الطيور",
"Show only birds": "إظهار الطيور فقط",
"MAMMALS": "الثدييات",
"Show only mammals": "إظهار الثدييات فقط",
"WHALES": "الحيتان",
"Show only whales and dolphins (7 days)": "إظهار الحيتان والدلافين فقط (7 أيام)",
"INSECTS": "الحشرات",
"Show only insects": "إظهار الحشرات فقط",
"PLANTS": "النباتات",
"Show only plants": "إظهار النباتات فقط",
"All rights reserved": "جميع الحقوق محفوظة",
"Cetacean": "حوتي",
"Wildlife Sightings": "مشاهدات الحياة البرية",
"Exit cockpit to fly to a sighting": "اخرج من قمرة القيادة للانتقال إلى مشاهدة",
"Exit cockpit to fly to a sighting area": "اخرج من قمرة القيادة للانتقال إلى منطقة مشاهدة",
"Wildlife": "الحياة البرية",
"Zoom in for local sightings": "قرّب لرؤية المشاهدات المحلية",
"Loading photo...": "جارٍ تحميل الصورة…",
"Photo unavailable": "الصورة غير متاحة",
"Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°",
"Photo of {name}": "صورة {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} مشاهدة ضمن النطاق",
"Close sighting": "إغلاق المشاهدة",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "حياة برية شوهدت خلال 48 ساعة (الحيتان 7 أيام). يوضح اللون ورمز الشارة المجموعة التصنيفية. تُعرض الأنواع الحساسة في خلايا بحجم 0.2° فقط؛ وتُقرّب مشاهدات الثدييات البحرية في بحر ساليش عبر Acartia إلى 0.01°. ليست إحصاءً شاملاً.",
"Open {name} on iNaturalist": "فتح {name} على iNaturalist",
"Open {name} on Acartia": "فتح {name} على Acartia",
"iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل",
"{days}d": "{days}ي",
"All markers": "كل العلامات",
"Show markers of every age": "عرض العلامات من جميع الأعمار",
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة",
"event age filter|All": "الكل",
"Event age": "عمر الحدث",
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ",
"Close camera preview": "إغلاق معاينة الكاميرا",
"Refresh map still": "تحديث صورة الخريطة الثابتة",
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك."
}
+239 -3
View File
@@ -1,7 +1,4 @@
{
"Close camera preview": "Kameravorschau schließen",
"Refresh map still": "Kartenstandbild aktualisieren",
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
"Flying to Austin, TX...": "Flug nach Austin, TX...",
"Restoring shared view...": "Geteilte Ansicht wird wiederhergestellt...",
"Configuring viewer...": "Viewer wird konfiguriert...",
@@ -763,6 +760,8 @@
"AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN",
"SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN",
"LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN",
"Space Missions unavailable: the mission index could not load. Try again later.": "Raummissionen nicht verfügbar: Der Missionsindex konnte nicht geladen werden. Später erneut versuchen.",
"30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL",
"Internet radio and local SDR": "Internetradio und lokales SDR",
"STATION TAG": "KATEGORIE",
@@ -1668,8 +1667,16 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert (braucht einen TomTom-Schlüssel)",
"Hybrid": "Hybrid",
"Lines": "Linien",
"Dots": "Punkte",
"Road lines colored by live flow: green to red": "Straßenlinien nach Live-Verkehrsfluss gefärbt: grün bis rot",
"Moving dots colored by live flow": "Bewegte Punkte nach Live-Verkehrsfluss gefärbt",
"Moving dots": "Bewegte Punkte",
"TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar",
"TomTom daily budget reached": "TomTom-Tagesbudget erreicht",
"Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht",
"Traffic rate limit reached": "Verkehrs-Anfragelimit erreicht",
"Traffic service unavailable": "Verkehrsdienst nicht verfügbar",
"TomTom upstream unreachable": "TomTom-Upstream nicht erreichbar",
"TomTom flow unavailable": "TomTom-Verkehrsfluss nicht verfügbar",
"SIMULATED — {reason}": "SIMULIERT – {reason}",
@@ -2090,6 +2097,9 @@
"{value} KM/H": "{value} KM/H",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "MILITÄR-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUG-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLUGZEUGE IN DER NÄHE UNVOLLSTÄNDIG · KEINE ENTWARNUNG",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
@@ -2910,6 +2920,18 @@
"STALE · Roads: {source} · Flow: cached TomTom tiles": "VERALTET · Straßen: {source} · Verkehrsfluss: TomTom-Kacheln aus dem Cache",
"routing busy — too many routes at once": "Routing-Dienst ausgelastet – zu viele Routen gleichzeitig",
"route proxy error": "Fehler des Routen-Proxys",
"{lower} to {upper}": "{lower} bis {upper}",
"Up to {upper}": "Bis {upper}",
"From {lower}": "Ab {lower}",
"Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung",
"Unnamed zone": "Unbenannte Zone",
"Restricted airspace source unavailable": "Quelle für Sperr- und Flugbeschränkungsgebiete nicht verfügbar",
"Prohibited (P)": "Sperrgebiet (P)",
"Restricted (R)": "Flugbeschränkungsgebiet (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Nur Sperr- und Flugbeschränkungsgebiete der FAA (Luftraum mit besonderer Nutzung), nicht die UAS Facility Map oder UAS-Flugbeschränkungen. Nur zur Information – kein Ersatz für offizielle UAS-Flugplanungstools (B4UFLY / LAANC).",
"Prohibited": "Sperrgebiet",
"Restricted": "Flugbeschränkungsgebiet",
"Prohibited & Restricted Airspace": "Sperr- und Flugbeschränkungsgebiete",
"scene|PLAY": "START",
"AUTHORING": "ERSTELLUNG",
"Create and edit scenes": "Szenen erstellen und bearbeiten",
@@ -2937,6 +2959,19 @@
"Right panels": "Rechte Panels",
"CONFIRMED": "BESTÄTIGT",
"PAD / RTLS": "STARTPLATZ / RTLS",
"fire confidence filter|All": "Alle",
"fire confidence filter|Nominal+": "Mittel+",
"fire confidence filter|High": "Hoch",
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen – viele der stärksten Brände haben eine niedrige Konfidenz",
"Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz",
"High confidence detections only": "Nur Erkennungen mit hoher Konfidenz",
"Fire intensity (FRP)": "Intensität (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Konfidenz",
"Minimum fire radiative power, megawatts": "Mindestwert der Strahlungsleistung des Brandes in Megawatt",
"fire intensity filter|All": "Alle",
"Detection confidence": "Konfidenz der Erkennung",
"{shown} of {total} shown": "{shown} von {total} angezeigt",
"Name of {name}": "Name von {name}",
"Change the colour of {name}": "Farbe von {name} ändern",
"Colour of {name}": "Farbe von {name}",
@@ -2946,7 +2981,208 @@
"UTILITIES": "WERKZEUGE",
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
"Voice place search (needs an Overpass server)": "Sprachgesteuerte Ortssuche (benötigt einen Overpass-Server)",
"OSM Places": "OSM-Orte",
"state or province": "Bundesstaat oder Provinz",
"county or district": "Landkreis oder Bezirk",
"neighbourhood": "Stadtviertel",
"natural region": "Naturraum",
"Which {place}?": "Welcher Ort ist mit {place} gemeint?",
"Which place?": "Welcher Ort?",
"Not drawn: {parts}": "Nicht gezeichnet: {parts}",
"approximate": "ungefähr",
"{pct}% cloud": "{pct} % Bewölkung",
"Over {area}": "Gebiet: {area}",
"{count} of {total} acquisitions match": {
"one": "Passende Aufnahmen: {count} von {total}",
"other": "Passende Aufnahmen: {count} von {total}"
},
"{n} acquisitions searched": {
"one": "Durchsuchte Aufnahmen: {n}",
"other": "Durchsuchte Aufnahmen: {n}"
},
"No matching imagery": "Keine passenden Bilddaten",
"Unknown number of {kind}": "{kind}: Anzahl unbekannt",
"About {count} {kind}": "{kind}: etwa {count}",
"At least {count} {kind}": "{kind}: mindestens {count}",
"{count} {kind}": "{kind}: {count}",
"In {area}": "Gebiet: {area}",
"Area around {place}": "Gebiet um {place}",
"Area around landmark": "Gebiet um einen Ort",
"Outline {place}": "Umriss von {place} zeichnen",
"Outline area": "Umriss zeichnen",
"Find {place}": "{place} finden",
"Find area": "Gebiet finden",
"Find recent imagery": "Aktuelle Bilddaten finden",
"Find {what}": "{what} finden",
"Find places": "Orte finden",
"Drawing outline": "Umriss wird gezeichnet",
"Searching imagery catalog": "Bildkatalog wird durchsucht",
"Loading imagery": "Bilddaten werden geladen",
"Searching OpenStreetMap": "OpenStreetMap wird durchsucht",
"voice area|Area": "Gebiet",
"buffer sized to the place": "Puffer entsprechend der Ortsgröße",
"enclosing mapped area": "umschließendes kartiertes Gebiet",
"the map was cleared while looking it up": "die Zeichenfläche wurde während der Suche geleert",
"OpenStreetMap coverage varies by place": "die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
"the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage",
"there are more": "es gibt weitere",
"{radius} m around the landmark": "{radius} m um den Ort",
"{count} parts": {
"one": "{count} Teil",
"other": "{count} Teile"
},
"{drawn} of {total} parts drawn": {
"one": "{drawn} von {total} Teil gezeichnet",
"other": "{drawn} von {total} Teilen gezeichnet"
},
"{count} duplicate mappings merged": {
"one": "{count} Duplikat zusammengeführt",
"other": "{count} Duplikate zusammengeführt"
},
"listed the first {limit}": "die ersten {limit} aufgelistet",
"counted over the bounding box of {area}, not its outline": "im umgebenden Rechteck von {area} gezählt, nicht innerhalb des Umrisses",
"Search cancelled": "Suche abgebrochen",
"Sea Marks": "Seezeichen",
"Ski Pistes": "Skipisten",
"Fly to Tokyo": "Nach Tokio fliegen",
"Show live flights": "Live-Flüge anzeigen",
"Reset to globe view": "Zur Globusansicht zurückkehren",
"Show earthquakes": "Erdbeben anzeigen",
"AI chat": "KI-Chat",
"GEV CHAT": "GEV-CHAT",
"Ask about the map. Tell it where to go.": "Fragen zur Karte stellen und Ziele nennen.",
"Close chat": "Chat schließen",
"Conversation": "Gespräch",
"Explore with a question": "Mit einer Frage erkunden",
"Find a place, explore live data, or change your view.": "Einen Ort finden, Live-Daten erkunden oder die Ansicht ändern.",
"Attachments": "Anhänge",
"Remove attachment": "Anhang entfernen",
"Attach a map layer": "Kartenebene anhängen",
"Choose a layer…": "Ebene wählen…",
"Attach a file": "Datei anhängen",
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON oder GeoJSON · insgesamt bis zu 10 KB. Anhänge werden mit der Nachricht an den gewählten KI-Anbieter gesendet.",
"Latest AI response": "Neueste KI-Antwort",
"Expand conversation": "Gespräch ausklappen",
"Command suggestions": "Befehlsvorschläge",
"Expand chat": "Chat ausklappen",
"Add attachment": "Anhang hinzufügen",
"Message GEV": "Nachricht an GEV",
"Ask GEV anything about the map…": "GEV etwas zur Karte fragen…",
"Stop reply": "Antwort stoppen",
"Send message": "Nachricht senden",
"Panel position": "Bereichsposition",
"Move above the dock": "Über das Dock verschieben",
"Dock to the right": "Rechts andocken",
"Expand panel": "Bereich ausklappen",
"Minimize panel": "Bereich minimieren",
"You can attach up to four items.": "Es können höchstens vier Elemente angehängt werden.",
"Attachments are too large together. Use smaller files.": "Die Anhänge sind zusammen zu groß. Kleinere Dateien verwenden.",
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Eine TXT-, Markdown-, CSV-, JSON- oder GeoJSON-Datei wählen.",
"Each file must be 10 KB or smaller.": "Jede Datei darf höchstens 10 KB groß sein.",
"Choose a non-empty UTF-8 text file.": "Eine nicht leere UTF-8-Textdatei wählen.",
"Connecting…": "Verbindet…",
"Thinking…": "Denkt nach…",
"Working on the map…": "Arbeitet an der Karte…",
"Silent chat · microphone off": "Chat ohne Ton · Mikrofon aus",
"Reply interrupted. You can try again.": "Antwort unterbrochen. Erneut versuchen.",
"Could not connect. Try again.": "Verbindung fehlgeschlagen. Erneut versuchen.",
"Session ended. Send a message to start a new conversation.": "Sitzung beendet. Für ein neues Gespräch eine Nachricht senden.",
"Connection timed out. Try again.": "Zeitüberschreitung bei der Verbindung. Erneut versuchen.",
"Chat closed.": "Chat geschlossen.",
"Connection stopped.": "Verbindung beendet.",
"Message and attachments are too large. Remove an attachment or shorten your message.": "Nachricht und Anhänge sind zu groß. Einen Anhang entfernen oder die Nachricht kürzen.",
"Message was not sent. Try again.": "Nachricht wurde nicht gesendet. Erneut versuchen.",
"Reply stopped.": "Antwort gestoppt.",
"Suggestions": "Vorschläge",
"Recent": "Zuletzt verwendet",
"Quick commands": "Schnellbefehle",
"Open AI chat": "KI-Chat öffnen",
"CHAT": "CHAT",
"Back to chat": "Zurück zum Chat",
"Back to voice": "Zurück zur Sprachsteuerung",
"Latest voice exchange": "Letzter Sprachdialog",
"Try saying": "Zum Beispiel",
"What am I looking at?": "Was sehe ich gerade?",
"AI Provider": "KI-Anbieter",
"The selected AI provider is shared by voice and chat.": "Der gewählte KI-Anbieter gilt für Sprachsteuerung und Chat.",
"Show quick commands": "Schnellbefehle anzeigen",
"Chat replies are silent. No microphone access is needed.": "Chat-Antworten sind stumm. Kein Mikrofonzugriff nötig.",
"Chat": "Chat",
"Attach layer": "Ebene anhängen",
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Der Text-Chat nutzt den OpenAI-API-Schlüssel. Die ChatGPT-Anmeldung gilt nur für die Sprachsteuerung.",
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Der Text-Chat ist nicht verfügbar, solange GEV_PREFER_CODEX_OAUTH ChatGPT OAuth vorschreibt.",
"That request needed too many steps. Changes already made to the map stay in place.": "Diese Anfrage brauchte zu viele Schritte. Bereits vorgenommene Kartenänderungen bleiben erhalten.",
"That request needed too many steps. Try a simpler request.": "Diese Anfrage brauchte zu viele Schritte. Eine einfachere Anfrage versuchen.",
"The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Den Schlüssel in den Anbieter-Einstellungen prüfen.",
"Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen.",
"SIGHTING": "BEOBACHTUNG",
"Show {name} sighting": "Beobachtung von {name} anzeigen",
"seen {age} ago": "vor {age} gesichtet",
"observed {age} ago": "vor {age} beobachtet",
"Be Whale Wise": "Wale respektvoll beobachten",
"Reported by {name} ({entity})": "Gemeldet von {name} ({entity})",
"Reported by {name}": "Gemeldet von {name}",
"Salish Sea": "Salish-See",
"Birds": "Vögel",
"Mammals": "Säugetiere",
"Reptiles": "Reptilien",
"Amphibians": "Amphibien",
"Ray-finned fishes": "Strahlenflosser",
"Insects": "Insekten",
"Arachnids": "Spinnentiere",
"Plants": "Pflanzen",
"Fungi": "Pilze",
"Other animals": "Andere Tiere",
"Other or unknown": "Andere oder unbekannt",
"{label} {detail} — click to retry": "{label} {detail} — zum Wiederholen klicken",
"Wildlife sightings source unavailable": "Quelle für Naturbeobachtungen nicht verfügbar",
"Show only community-confirmed (research-grade) sightings": "Nur gemeinschaftlich bestätigte Beobachtungen (Forschungsqualität) anzeigen",
"No recent sightings in view": "Keine aktuellen Beobachtungen im Ausschnitt",
"Acartia feed unavailable": "Acartia-Datenstrom nicht verfügbar",
"Show all taxa": "Alle Artengruppen anzeigen",
"BIRDS": "VÖGEL",
"Show only birds": "Nur Vögel anzeigen",
"MAMMALS": "SÄUGETIERE",
"Show only mammals": "Nur Säugetiere anzeigen",
"WHALES": "WALE",
"Show only whales and dolphins (7 days)": "Nur Wale und Delfine anzeigen (7 Tage)",
"INSECTS": "INSEKTEN",
"Show only insects": "Nur Insekten anzeigen",
"PLANTS": "PFLANZEN",
"Show only plants": "Nur Pflanzen anzeigen",
"All rights reserved": "Alle Rechte vorbehalten",
"Cetacean": "Wal oder Delfin",
"Wildlife Sightings": "Naturbeobachtungen",
"Exit cockpit to fly to a sighting": "Cockpit verlassen, um zu einer Beobachtung zu fliegen",
"Exit cockpit to fly to a sighting area": "Cockpit verlassen, um zu einem Beobachtungsgebiet zu fliegen",
"Wildlife": "Tierwelt",
"Zoom in for local sightings": "Für lokale Beobachtungen vergrößern",
"Loading photo...": "Foto wird geladen…",
"Photo unavailable": "Foto nicht verfügbar",
"Location obscured to a 0.2° cell": "Standort auf eine 0,2°-Rasterzelle verschleiert",
"Photo of {name}": "Foto von {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} Sichtungen im Ausschnitt",
"Close sighting": "Sichtung schließen",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "In den letzten 48 h beobachtete Wildtiere (Wale 7 Tage). Farbe und Abzeichensymbol zeigen die Artengruppe. Empfindliche Arten erscheinen nur als 0,2°-Zellen; Acartia-Meldungen zu Meeressäugern aus der Salish-See werden auf 0,01° gerundet. Keine Bestandszählung.",
"Open {name} on iNaturalist": "{name} auf iNaturalist öffnen",
"Open {name} on Acartia": "{name} auf Acartia öffnen",
"iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag",
"{days}d": "{days}T",
"All markers": "Alle Markierungen",
"Show markers of every age": "Markierungen jeden Alters anzeigen",
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben",
"event age filter|All": "Alle",
"Event age": "Ereignisalter",
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben",
"Close camera preview": "Kameravorschau schließen",
"Refresh map still": "Kartenstandbild aktualisieren",
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht."
}
+244 -3
View File
@@ -1,7 +1,4 @@
{
"Close camera preview": "Cerrar vista previa de la cámara",
"Refresh map still": "Actualizar imagen fija del mapa",
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
"NO PLACE LEFT BEHIND": "NINGÚN RINCÓN SIN VIGILAR",
"Initializing photorealistic world...": "Inicializando el mundo fotorrealista...",
"Configuring viewer...": "Configurando el visor...",
@@ -195,6 +192,8 @@
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Misiones espaciales no disponibles: no se pudo cargar el índice de misiones. Inténtalo más tarde.",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE",
"RADIO": "RADIO",
"ENABLE": "ACTIVAR",
"Previous filtered radio station": "Emisora de radio filtrada anterior",
@@ -1923,6 +1922,9 @@
"N/A": "N/D",
"{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO",
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
"ft": "ft",
"kts": "kn",
@@ -2274,6 +2276,9 @@
"SIMULATED — {reason}": "SIMULADO — {reason}",
"TomTom key unavailable": "Clave de TomTom no disponible",
"TomTom daily budget reached": "Se alcanzó el límite diario de TomTom",
"Monthly traffic allowance used": "Cupo mensual de tráfico agotado",
"Traffic rate limit reached": "Límite de solicitudes de tráfico alcanzado",
"Traffic service unavailable": "Servicio de tráfico no disponible",
"TomTom upstream unreachable": "Servidor de TomTom inaccesible",
"TomTom flow unavailable": "Flujo de TomTom no disponible",
"API key rejected — check AISSTREAM_API_KEY": "Clave de API rechazada — revisa AISSTREAM_API_KEY",
@@ -2694,6 +2699,11 @@
"Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales",
"Camera coverage unavailable": "Cobertura de cámaras no disponible",
"Hybrid": "Híbrido",
"Lines": "Líneas",
"Dots": "Puntos",
"Road lines colored by live flow: green to red": "Líneas de vías coloreadas por el flujo en vivo: de verde a rojo",
"Moving dots colored by live flow": "Puntos en movimiento coloreados por el flujo en vivo",
"Moving dots": "Puntos en movimiento",
"TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo",
"TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)",
"OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible",
@@ -2986,6 +2996,31 @@
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
"Preview is off.": "La vista previa está desactivada.",
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
"fire confidence filter|All": "Todas",
"fire confidence filter|Nominal+": "Nominal+",
"fire confidence filter|High": "Alta",
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos se clasifican con confianza baja",
"Nominal and high confidence detections": "Detecciones de confianza nominal y alta",
"High confidence detections only": "Solo detecciones de confianza alta",
"Fire intensity (FRP)": "Intensidad (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Confianza",
"Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios",
"fire intensity filter|All": "Todo",
"Detection confidence": "Confianza de la detección",
"{shown} of {total} shown": "Mostrando {shown} de {total}",
"{lower} to {upper}": "De {lower} a {upper}",
"Up to {upper}": "Hasta {upper}",
"From {lower}": "Desde {lower}",
"Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos",
"Unnamed zone": "Zona sin nombre",
"Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible",
"Prohibited (P)": "Prohibido (P)",
"Restricted (R)": "Restringido (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA; no incluye el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo — no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
"Prohibited": "Zona prohibida",
"Restricted": "Zona restringida",
"Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido",
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
"route proxy error": "error del proxy de rutas",
"AUTHORING": "CREACIÓN",
@@ -3016,7 +3051,213 @@
"UTILITIES": "UTILIDADES",
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
"Voice place search (needs an Overpass server)": "Búsqueda de lugares por voz (requiere un servidor Overpass)",
"OSM Places": "Lugares OSM",
"state or province": "estado o provincia",
"county or district": "condado o distrito",
"neighbourhood": "barrio",
"natural region": "región natural",
"Which {place}?": "¿Qué {place}?",
"Which place?": "¿Qué lugar?",
"Not drawn: {parts}": "Sin dibujar: {parts}",
"approximate": "aproximada",
"{pct}% cloud": "{pct}% de nubes",
"Over {area}": "Zona: {area}",
"{count} of {total} acquisitions match": {
"many": "Adquisiciones coincidentes: {count} de {total}",
"one": "Adquisiciones coincidentes: {count} de {total}",
"other": "Adquisiciones coincidentes: {count} de {total}"
},
"{n} acquisitions searched": {
"many": "Adquisiciones revisadas: {n}",
"one": "Adquisiciones revisadas: {n}",
"other": "Adquisiciones revisadas: {n}"
},
"No matching imagery": "No hay imágenes que coincidan",
"Unknown number of {kind}": "{kind}: número desconocido",
"About {count} {kind}": "{kind}: aproximadamente {count}",
"At least {count} {kind}": "{kind}: al menos {count}",
"{count} {kind}": "{kind}: {count}",
"In {area}": "En {area}",
"Area around {place}": "Área alrededor de {place}",
"Area around landmark": "Área alrededor de un punto de interés",
"Outline {place}": "Trazar contorno de {place}",
"Outline area": "Trazar contorno de área",
"Find {place}": "Buscar {place}",
"Find area": "Buscar área",
"Find recent imagery": "Buscar imágenes recientes",
"Find {what}": "Buscar {what}",
"Find places": "Buscar lugares",
"Drawing outline": "Dibujando contorno",
"Searching imagery catalog": "Buscando en el catálogo de imágenes",
"Loading imagery": "Cargando imágenes",
"Searching OpenStreetMap": "Buscando en OpenStreetMap",
"voice area|Area": "Área",
"buffer sized to the place": "zona aproximada del tamaño del lugar",
"enclosing mapped area": "zona mapeada que contiene el punto de interés",
"the map was cleared while looking it up": "el mapa se borró durante la búsqueda",
"OpenStreetMap coverage varies by place": "la cobertura de OpenStreetMap varía según el lugar",
"the catalog covers the last 30 days": "el catálogo cubre los últimos 30 días",
"there are more": "hay más",
"{radius} m around the landmark": "{radius} m alrededor del punto de interés",
"{count} parts": {
"many": "Partes: {count}",
"one": "Partes: {count}",
"other": "Partes: {count}"
},
"{drawn} of {total} parts drawn": {
"many": "Partes dibujadas: {drawn} de {total}",
"one": "Partes dibujadas: {drawn} de {total}",
"other": "Partes dibujadas: {drawn} de {total}"
},
"{count} duplicate mappings merged": {
"many": "Duplicados fusionados: {count}",
"one": "Duplicados fusionados: {count}",
"other": "Duplicados fusionados: {count}"
},
"listed the first {limit}": "se listaron los primeros {limit}",
"counted over the bounding box of {area}, not its outline": "se contó en el rectángulo que abarca {area}, no dentro de su contorno",
"Search cancelled": "Búsqueda cancelada",
"Sea Marks": "Señales marítimas",
"Ski Pistes": "Pistas de esquí",
"Fly to Tokyo": "Vuela a Tokio",
"Show live flights": "Muestra los vuelos en vivo",
"Reset to globe view": "Vuelve a la vista del globo",
"Show earthquakes": "Muestra los terremotos",
"AI chat": "Chat de IA",
"GEV CHAT": "CHAT DE GEV",
"Ask about the map. Tell it where to go.": "Pregunta sobre el mapa. Indica adónde ir.",
"Close chat": "Cerrar chat",
"Conversation": "Conversación",
"Explore with a question": "Explora con una pregunta",
"Find a place, explore live data, or change your view.": "Busca un lugar, explora datos en vivo o cambia la vista.",
"Attachments": "Adjuntos",
"Remove attachment": "Quitar adjunto",
"Attach a map layer": "Adjuntar una capa del mapa",
"Choose a layer…": "Elige una capa…",
"Attach a file": "Adjuntar un archivo",
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON o GeoJSON · hasta 10 KB en total. Los adjuntos se envían a tu proveedor de IA con el mensaje.",
"Latest AI response": "Última respuesta de la IA",
"Expand conversation": "Expandir conversación",
"Command suggestions": "Sugerencias de comandos",
"Expand chat": "Expandir chat",
"Add attachment": "Añadir adjunto",
"Message GEV": "Mensaje para GEV",
"Ask GEV anything about the map…": "Pregunta a GEV lo que quieras sobre el mapa…",
"Stop reply": "Detener respuesta",
"Send message": "Enviar mensaje",
"Panel position": "Posición del panel",
"Move above the dock": "Mover encima del dock",
"Dock to the right": "Acoplar a la derecha",
"Expand panel": "Expandir panel",
"Minimize panel": "Minimizar panel",
"You can attach up to four items.": "Puedes adjuntar hasta cuatro elementos.",
"Attachments are too large together. Use smaller files.": "El tamaño total de los adjuntos es demasiado grande. Usa archivos más pequeños.",
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Elige un archivo TXT, Markdown, CSV, JSON o GeoJSON.",
"Each file must be 10 KB or smaller.": "Cada archivo debe tener como máximo 10 KB.",
"Choose a non-empty UTF-8 text file.": "Elige un archivo de texto UTF-8 que no esté vacío.",
"Connecting…": "Conectando…",
"Thinking…": "Pensando…",
"Working on the map…": "Trabajando en el mapa…",
"Silent chat · microphone off": "Chat silencioso · micrófono apagado",
"Reply interrupted. You can try again.": "Respuesta interrumpida. Puedes intentarlo de nuevo.",
"Could not connect. Try again.": "No se pudo conectar. Inténtalo de nuevo.",
"Session ended. Send a message to start a new conversation.": "Sesión finalizada. Envía un mensaje para iniciar una nueva conversación.",
"Connection timed out. Try again.": "Se agotó el tiempo de conexión. Inténtalo de nuevo.",
"Chat closed.": "Chat cerrado.",
"Connection stopped.": "Conexión detenida.",
"Message and attachments are too large. Remove an attachment or shorten your message.": "El mensaje y los adjuntos son demasiado grandes. Quita un adjunto o acorta el mensaje.",
"Message was not sent. Try again.": "No se envió el mensaje. Inténtalo de nuevo.",
"Reply stopped.": "Respuesta detenida.",
"Suggestions": "Sugerencias",
"Recent": "Recientes",
"Quick commands": "Comandos rápidos",
"Open AI chat": "Abrir chat de IA",
"CHAT": "CHAT",
"Back to chat": "Volver al chat",
"Back to voice": "Volver a la voz",
"Latest voice exchange": "Último intercambio de voz",
"Try saying": "Intenta decir",
"What am I looking at?": "¿Qué estoy viendo?",
"AI Provider": "Proveedor de IA",
"The selected AI provider is shared by voice and chat.": "El proveedor de IA seleccionado se usa tanto para voz como para chat.",
"Show quick commands": "Mostrar comandos rápidos",
"Chat replies are silent. No microphone access is needed.": "Las respuestas del chat son silenciosas. No se necesita acceso al micrófono.",
"Chat": "Chat",
"Attach layer": "Adjuntar capa",
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "El chat escrito usa tu clave de API de OpenAI. El inicio de sesión de ChatGPT solo sirve para la voz.",
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "El chat escrito no está disponible mientras GEV_PREFER_CODEX_OAUTH exija OAuth de ChatGPT.",
"That request needed too many steps. Changes already made to the map stay in place.": "Esa solicitud requirió demasiados pasos. Los cambios ya hechos en el mapa se mantienen.",
"That request needed too many steps. Try a simpler request.": "Esa solicitud requirió demasiados pasos. Prueba una solicitud más sencilla.",
"The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes de proveedores.",
"Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes.",
"SIGHTING": "AVISTAMIENTO",
"Show {name} sighting": "Mostrar avistamiento de {name}",
"seen {age} ago": "visto hace {age}",
"observed {age} ago": "observado hace {age}",
"Be Whale Wise": "Observa cetáceos con respeto",
"Reported by {name} ({entity})": "Reportado por {name} ({entity})",
"Reported by {name}": "Reportado por {name}",
"Salish Sea": "Mar de Salish",
"Birds": "Aves",
"Mammals": "Mamíferos",
"Reptiles": "Reptiles",
"Amphibians": "Anfibios",
"Ray-finned fishes": "Peces de aletas radiadas",
"Insects": "Insectos",
"Arachnids": "Arácnidos",
"Plants": "Plantas",
"Fungi": "Hongos",
"Other animals": "Otros animales",
"Other or unknown": "Otros o desconocidos",
"{label} {detail} — click to retry": "{label} {detail} — haz clic para reintentar",
"Wildlife sightings source unavailable": "Fuente de avistamientos no disponible",
"Show only community-confirmed (research-grade) sightings": "Mostrar solo observaciones confirmadas por la comunidad (grado de investigación)",
"No recent sightings in view": "No hay avistamientos recientes en la vista",
"Acartia feed unavailable": "Fuente Acartia no disponible",
"Show all taxa": "Mostrar todos los grupos taxonómicos",
"BIRDS": "AVES",
"Show only birds": "Mostrar solo aves",
"MAMMALS": "MAMÍFEROS",
"Show only mammals": "Mostrar solo mamíferos",
"WHALES": "CETÁCEOS",
"Show only whales and dolphins (7 days)": "Mostrar solo ballenas y delfines (7 días)",
"INSECTS": "INSECTOS",
"Show only insects": "Mostrar solo insectos",
"PLANTS": "PLANTAS",
"Show only plants": "Mostrar solo plantas",
"All rights reserved": "Todos los derechos reservados",
"Cetacean": "Cetáceo",
"Wildlife Sightings": "Avistamientos de fauna y flora",
"Exit cockpit to fly to a sighting": "Sal de la cabina para volar a un avistamiento",
"Exit cockpit to fly to a sighting area": "Sal de la cabina para volar a un área de avistamiento",
"Wildlife": "Vida silvestre",
"Zoom in for local sightings": "Acerca para ver avistamientos locales",
"Loading photo...": "Cargando foto…",
"Photo unavailable": "Foto no disponible",
"Location obscured to a 0.2° cell": "Ubicación oculta en una celda de 0,2°",
"Photo of {name}": "Foto de {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} avistamientos en la vista",
"Close sighting": "Cerrar avistamiento",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna observada en las últimas 48 h (cetáceos 7 días). El color y el símbolo de la insignia indican el grupo taxonómico. Las especies sensibles solo se muestran en celdas de 0,2°; los informes de mamíferos marinos de Acartia en el mar de Salish se redondean a 0,01°. No es un censo.",
"Open {name} on iNaturalist": "Abrir {name} en iNaturalist",
"Open {name} on Acartia": "Abrir {name} en Acartia",
"iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro",
"{days}d": "{days}d",
"All markers": "Todos los marcadores",
"Show markers of every age": "Mostrar marcadores de cualquier antigüedad",
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha",
"event age filter|All": "Todos",
"Event age": "Antigüedad",
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha",
"Close camera preview": "Cerrar vista previa de la cámara",
"Refresh map still": "Actualizar imagen fija del mapa",
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP."
}
+244 -3
View File
@@ -1,7 +1,4 @@
{
"Close camera preview": "Fermer l’aperçu de la caméra",
"Refresh map still": "Actualiser l’image fixe sur la carte",
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
"Pick a shape, then click the map.": "Choisissez une forme, puis cliquez sur la carte.",
"Enter to place the pin, Esc to cancel.": "Enter pour poser l'épingle, Esc pour annuler.",
"Click where the pin goes.": "Cliquez à l'endroit où poser l'épingle.",
@@ -1106,8 +1103,16 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation (nécessite une clé TomTom)",
"Hybrid": "Hybride",
"Lines": "Lignes",
"Dots": "Points",
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
"Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct",
"Moving dots": "Points mobiles",
"TomTom key unavailable": "Clé TomTom indisponible",
"TomTom daily budget reached": "Quota quotidien TomTom atteint",
"Monthly traffic allowance used": "Quota mensuel de trafic épuisé",
"Traffic rate limit reached": "Limite de requêtes de trafic atteinte",
"Traffic service unavailable": "Service de trafic indisponible",
"TomTom upstream unreachable": "Serveur TomTom injoignable",
"TomTom flow unavailable": "Flux TomTom indisponible",
"SIMULATED — {reason}": "SIMULÉ — {reason}",
@@ -1663,6 +1668,9 @@
"REGIONAL NEWS": "ACTUS RÉGIONALES",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT",
"CONTEXT STANDBY": "CONTEXTE EN VEILLE",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT",
@@ -2802,6 +2810,8 @@
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
"Internet radio and local SDR": "Radio Internet et SDR local",
"STATION TAG": "TAG DE STATION",
@@ -2980,6 +2990,18 @@
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
"Malformed cyclone snapshot": "Instantané cyclonique mal formé",
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
"{lower} to {upper}": "De {lower} à {upper}",
"Up to {upper}": "Jusqu'à {upper}",
"From {lower}": "À partir de {lower}",
"Informational only · not for flight planning": "À titre indicatif · ne pas utiliser pour la préparation des vols",
"Unnamed zone": "Sans nom",
"Restricted airspace source unavailable": "Source de l'espace aérien réglementé indisponible",
"Prohibited (P)": "Zone interdite (P)",
"Restricted (R)": "Zone réglementée (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre indicatif uniquement — ne remplace pas les outils officiels de préparation des vols de drones (B4UFLY / LAANC).",
"Prohibited": "Zone interdite",
"Restricted": "Zone réglementée",
"Prohibited & Restricted Airspace": "Espaces aériens interdits et réglementés",
"scene|PLAY": "LIRE",
"AUTHORING": "CRÉATION",
"Create and edit scenes": "Créer et modifier des scènes",
@@ -3007,6 +3029,19 @@
"Right panels": "Panneaux de droite",
"CONFIRMED": "CONFIRMÉE",
"PAD / RTLS": "PAS DE TIR / RTLS",
"fire confidence filter|All": "Toutes",
"fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Élevée",
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — de nombreux feux parmi les plus intenses sont classés en confiance faible",
"Nominal and high confidence detections": "Détections de confiance nominale et élevée",
"High confidence detections only": "Détections de confiance élevée uniquement",
"Fire intensity (FRP)": "Intensité (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Confiance",
"Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts",
"fire intensity filter|All": "Toutes",
"Detection confidence": "Confiance de la détection",
"{shown} of {total} shown": "Affichés : {shown} sur {total}",
"Name of {name}": "Nom de {name}",
"Change the colour of {name}": "Changer la couleur de {name}",
"Colour of {name}": "Couleur de {name}",
@@ -3016,7 +3051,213 @@
"UTILITIES": "OUTILS",
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
"Voice place search (needs an Overpass server)": "Recherche vocale de lieux (nécessite un serveur Overpass)",
"OSM Places": "Lieux OSM",
"state or province": "État ou province",
"county or district": "comté ou district",
"neighbourhood": "quartier",
"natural region": "région naturelle",
"Which {place}?": "Quel lieu nommé {place} ?",
"Which place?": "Quel lieu ?",
"Not drawn: {parts}": "Contour non tracé : {parts}",
"approximate": "approximatif",
"{pct}% cloud": "{pct} % de nuages",
"Over {area}": "Zone : {area}",
"{count} of {total} acquisitions match": {
"many": "Acquisitions correspondantes : {count} sur {total}",
"one": "Acquisitions correspondantes : {count} sur {total}",
"other": "Acquisitions correspondantes : {count} sur {total}"
},
"{n} acquisitions searched": {
"many": "Acquisitions examinées : {n}",
"one": "Acquisitions examinées : {n}",
"other": "Acquisitions examinées : {n}"
},
"No matching imagery": "Aucune image correspondante",
"Unknown number of {kind}": "{kind} : nombre inconnu",
"About {count} {kind}": "{kind} : environ {count}",
"At least {count} {kind}": "{kind} : au moins {count}",
"{count} {kind}": "{kind} : {count}",
"In {area}": "Zone : {area}",
"Area around {place}": "Environs : {place}",
"Area around landmark": "Environs d'un lieu",
"Outline {place}": "Délimiter {place}",
"Outline area": "Délimiter une zone",
"Find {place}": "Trouver {place}",
"Find area": "Trouver une zone",
"Find recent imagery": "Trouver des images récentes",
"Find {what}": "Trouver : {what}",
"Find places": "Trouver des lieux",
"Drawing outline": "Dessin du contour",
"Searching imagery catalog": "Recherche dans le catalogue d'images",
"Loading imagery": "Chargement des images",
"Searching OpenStreetMap": "Recherche dans OpenStreetMap",
"voice area|Area": "Zone",
"buffer sized to the place": "zone tampon adaptée à la taille du lieu",
"enclosing mapped area": "zone cartographiée englobante",
"the map was cleared while looking it up": "la carte a été effacée pendant la recherche",
"OpenStreetMap coverage varies by place": "la couverture OpenStreetMap varie selon les lieux",
"the catalog covers the last 30 days": "le catalogue couvre les 30 derniers jours",
"there are more": "il y en a d'autres",
"{radius} m around the landmark": "{radius} m autour du lieu",
"{count} parts": {
"many": "Parties : {count}",
"one": "Parties : {count}",
"other": "Parties : {count}"
},
"{drawn} of {total} parts drawn": {
"many": "Parties tracées : {drawn} sur {total}",
"one": "Parties tracées : {drawn} sur {total}",
"other": "Parties tracées : {drawn} sur {total}"
},
"{count} duplicate mappings merged": {
"many": "Doublons fusionnés : {count}",
"one": "Doublons fusionnés : {count}",
"other": "Doublons fusionnés : {count}"
},
"listed the first {limit}": "liste limitée à {limit}",
"counted over the bounding box of {area}, not its outline": "compté dans le rectangle englobant ({area}), pas dans son contour exact",
"Search cancelled": "Recherche annulée",
"Sea Marks": "Balisage maritime",
"Ski Pistes": "Pistes de ski",
"Fly to Tokyo": "Aller à Tokyo",
"Show live flights": "Afficher les vols en direct",
"Reset to globe view": "Revenir à la vue du globe",
"Show earthquakes": "Afficher les séismes",
"AI chat": "Discussion IA",
"GEV CHAT": "DISCUSSION GEV",
"Ask about the map. Tell it where to go.": "Posez une question sur la carte. Dites-lui où aller.",
"Close chat": "Fermer la discussion",
"Conversation": "Conversation",
"Explore with a question": "Explorez en posant une question",
"Find a place, explore live data, or change your view.": "Trouvez un lieu, explorez des données en direct ou changez de vue.",
"Attachments": "Pièces jointes",
"Remove attachment": "Retirer la pièce jointe",
"Attach a map layer": "Joindre une couche de la carte",
"Choose a layer…": "Choisir une couche…",
"Attach a file": "Joindre un fichier",
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON ou GeoJSON · 10 Ko max. au total. Les pièces jointes sont envoyées à votre fournisseur d'IA avec votre message.",
"Latest AI response": "Dernière réponse de l'IA",
"Expand conversation": "Développer la conversation",
"Command suggestions": "Suggestions de commandes",
"Expand chat": "Développer la discussion",
"Add attachment": "Ajouter une pièce jointe",
"Message GEV": "Message à GEV",
"Ask GEV anything about the map…": "Posez à GEV toutes vos questions sur la carte…",
"Stop reply": "Arrêter la réponse",
"Send message": "Envoyer le message",
"Panel position": "Position du panneau",
"Move above the dock": "Placer au-dessus du dock",
"Dock to the right": "Ancrer à droite",
"Expand panel": "Développer le panneau",
"Minimize panel": "Réduire le panneau",
"You can attach up to four items.": "Vous pouvez joindre jusqu'à quatre éléments.",
"Attachments are too large together. Use smaller files.": "La taille totale des pièces jointes est trop importante. Utilisez des fichiers plus petits.",
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Choisissez un fichier TXT, Markdown, CSV, JSON ou GeoJSON.",
"Each file must be 10 KB or smaller.": "Chaque fichier doit faire 10 Ko ou moins.",
"Choose a non-empty UTF-8 text file.": "Choisissez un fichier texte UTF-8 non vide.",
"Connecting…": "Connexion…",
"Thinking…": "Réflexion…",
"Working on the map…": "Mise à jour de la carte…",
"Silent chat · microphone off": "Discussion silencieuse · micro coupé",
"Reply interrupted. You can try again.": "Réponse interrompue. Vous pouvez réessayer.",
"Could not connect. Try again.": "Connexion impossible. Réessayez.",
"Session ended. Send a message to start a new conversation.": "Session terminée. Envoyez un message pour démarrer une nouvelle conversation.",
"Connection timed out. Try again.": "Délai de connexion dépassé. Réessayez.",
"Chat closed.": "Discussion fermée.",
"Connection stopped.": "Connexion arrêtée.",
"Message and attachments are too large. Remove an attachment or shorten your message.": "Le message et les pièces jointes sont trop volumineux. Retirez une pièce jointe ou raccourcissez votre message.",
"Message was not sent. Try again.": "Le message n'a pas été envoyé. Réessayez.",
"Reply stopped.": "Réponse arrêtée.",
"Suggestions": "Suggestions",
"Recent": "Récents",
"Quick commands": "Commandes rapides",
"Open AI chat": "Ouvrir la discussion IA",
"CHAT": "CHAT",
"Back to chat": "Retour à la discussion",
"Back to voice": "Retour au mode vocal",
"Latest voice exchange": "Dernier échange vocal",
"Try saying": "Essayez de dire",
"What am I looking at?": "Qu'est-ce que je regarde ?",
"AI Provider": "Fournisseur d'IA",
"The selected AI provider is shared by voice and chat.": "Le fournisseur d'IA choisi est commun à la voix et à la discussion.",
"Show quick commands": "Afficher les commandes rapides",
"Chat replies are silent. No microphone access is needed.": "Les réponses de la discussion sont silencieuses. Aucun accès au microphone n'est nécessaire.",
"Chat": "Discussion",
"Attach layer": "Joindre la couche",
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "La discussion écrite utilise votre clé API OpenAI. La connexion ChatGPT ne couvre que la voix.",
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "La discussion écrite est indisponible tant que GEV_PREFER_CODEX_OAUTH impose l'OAuth ChatGPT.",
"That request needed too many steps. Changes already made to the map stay in place.": "Cette demande a nécessité trop d'étapes. Les modifications déjà apportées à la carte sont conservées.",
"That request needed too many steps. Try a simpler request.": "Cette demande a nécessité trop d'étapes. Essayez une demande plus simple.",
"The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé API. Vérifiez-la dans les paramètres des fournisseurs.",
"Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
"SIGHTING": "OBSERVATION",
"Show {name} sighting": "Afficher l’observation de {name}",
"seen {age} ago": "vu il y a {age}",
"observed {age} ago": "observé il y a {age}",
"Be Whale Wise": "Respectez les cétacés",
"Reported by {name} ({entity})": "Signalé par {name} ({entity})",
"Reported by {name}": "Signalé par {name}",
"Salish Sea": "Mer des Salish",
"Birds": "Oiseaux",
"Mammals": "Mammifères",
"Reptiles": "Reptiles",
"Amphibians": "Amphibiens",
"Ray-finned fishes": "Poissons à nageoires rayonnées",
"Insects": "Insectes",
"Arachnids": "Arachnides",
"Plants": "Plantes",
"Fungi": "Champignons",
"Other animals": "Autres animaux",
"Other or unknown": "Autres ou inconnus",
"{label} {detail} — click to retry": "{label} {detail} — cliquer pour réessayer",
"Wildlife sightings source unavailable": "Source des observations naturalistes indisponible",
"Show only community-confirmed (research-grade) sightings": "Afficher uniquement les observations confirmées par la communauté (qualité recherche)",
"No recent sightings in view": "Aucune observation récente dans la vue",
"Acartia feed unavailable": "Flux Acartia indisponible",
"Show all taxa": "Afficher tous les groupes taxonomiques",
"BIRDS": "OISEAUX",
"Show only birds": "Afficher uniquement les oiseaux",
"MAMMALS": "MAMMIFÈRES",
"Show only mammals": "Afficher uniquement les mammifères",
"WHALES": "CÉTACÉS",
"Show only whales and dolphins (7 days)": "Afficher uniquement les baleines et dauphins (7 jours)",
"INSECTS": "INSECTES",
"Show only insects": "Afficher uniquement les insectes",
"PLANTS": "PLANTES",
"Show only plants": "Afficher uniquement les plantes",
"All rights reserved": "Tous droits réservés",
"Cetacean": "Cétacé",
"Wildlife Sightings": "Observations naturalistes",
"Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation",
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation",
"Wildlife": "Faune et flore",
"Zoom in for local sightings": "Zoomez pour les observations locales",
"Loading photo...": "Chargement de la photo…",
"Photo unavailable": "Photo indisponible",
"Location obscured to a 0.2° cell": "Localisation masquée dans une cellule de 0,2°",
"Photo of {name}": "Photo de {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} observations dans la vue",
"Close sighting": "Fermer l’observation",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.",
"Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist",
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
"iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation",
"{days}d": "{days}j",
"All markers": "Tous les marqueurs",
"Show markers of every age": "Afficher les marqueurs de tout âge",
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
"event age filter|All": "Tous",
"Event age": "Ancienneté",
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
"Close camera preview": "Fermer l’aperçu de la caméra",
"Refresh map still": "Actualiser l’image fixe sur la carte",
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP."
}
+244 -3
View File
@@ -1,7 +1,4 @@
{
"Close camera preview": "Chiudi anteprima della telecamera",
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
"Pick a shape, then click the map.": "Scegli una forma, poi fai clic sulla mappa.",
"Enter to place the pin, Esc to cancel.": "Premi Enter per posizionare il segnaposto, Esc per annullare.",
"Click where the pin goes.": "Fai clic dove va il segnaposto.",
@@ -1106,8 +1103,16 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato (richiede una chiave TomTom)",
"Hybrid": "Ibrido",
"Lines": "Linee",
"Dots": "Punti",
"Road lines colored by live flow: green to red": "Strade colorate in base al traffico in tempo reale: dal verde al rosso",
"Moving dots colored by live flow": "Punti in movimento colorati in base al traffico in tempo reale",
"Moving dots": "Punti in movimento",
"TomTom key unavailable": "Chiave TomTom non disponibile",
"TomTom daily budget reached": "Budget giornaliero TomTom esaurito",
"Monthly traffic allowance used": "Quota mensile del traffico esaurita",
"Traffic rate limit reached": "Limite di richieste del traffico raggiunto",
"Traffic service unavailable": "Servizio traffico non disponibile",
"TomTom upstream unreachable": "Servizio TomTom irraggiungibile",
"TomTom flow unavailable": "Flusso TomTom non disponibile",
"SIMULATED — {reason}": "SIMULATO: {reason}",
@@ -1663,6 +1668,9 @@
"REGIONAL NEWS": "NOTIZIE REGIONALI",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA",
"CONTEXT STANDBY": "CONTESTO IN ATTESA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA",
@@ -2802,6 +2810,8 @@
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
"Internet radio and local SDR": "Radio internet e SDR locale",
"STATION TAG": "TAG STAZIONE",
@@ -2980,6 +2990,18 @@
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout",
"{lower} to {upper}": "Da {lower} a {upper}",
"Up to {upper}": "Fino a {upper}",
"From {lower}": "Da {lower}",
"Informational only · not for flight planning": "Solo a scopo informativo · non per la pianificazione del volo",
"Unnamed zone": "Senza nome",
"Restricted airspace source unavailable": "Fonte dello spazio aereo regolamentato non disponibile",
"Prohibited (P)": "Zona vietata (P)",
"Restricted (R)": "Zona regolamentata (R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone vietate e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
"Prohibited": "Zona vietata",
"Restricted": "Zona regolamentata",
"Prohibited & Restricted Airspace": "Spazio aereo vietato e regolamentato",
"scene|PLAY": "AVVIA",
"AUTHORING": "CREAZIONE",
"Create and edit scenes": "Crea e modifica le scene",
@@ -3007,6 +3029,19 @@
"Right panels": "Pannelli a destra",
"CONFIRMED": "CONFERMATA",
"PAD / RTLS": "SITO DI LANCIO / RTLS",
"fire confidence filter|All": "Tutti",
"fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Alta",
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti: molti degli incendi più intensi hanno confidenza bassa",
"Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta",
"High confidence detections only": "Solo rilevamenti con confidenza alta",
"Fire intensity (FRP)": "Intensità (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Confidenza",
"Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt",
"fire intensity filter|All": "Tutti",
"Detection confidence": "Confidenza del rilevamento",
"{shown} of {total} shown": "Mostrati: {shown} su {total}",
"Name of {name}": "Nome di {name}",
"Change the colour of {name}": "Cambia il colore di {name}",
"Colour of {name}": "Colore di {name}",
@@ -3016,7 +3051,213 @@
"UTILITIES": "STRUMENTI",
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
"Voice place search (needs an Overpass server)": "Ricerca vocale di luoghi (richiede un server Overpass)",
"OSM Places": "Luoghi OSM",
"state or province": "stato o provincia",
"county or district": "contea o distretto",
"neighbourhood": "quartiere",
"natural region": "regione naturale",
"Which {place}?": "Quale {place}?",
"Which place?": "Quale luogo?",
"Not drawn: {parts}": "Contorno non disegnato: {parts}",
"approximate": "approssimata",
"{pct}% cloud": "{pct}% di nuvole",
"Over {area}": "Area: {area}",
"{count} of {total} acquisitions match": {
"many": "Acquisizioni corrispondenti: {count} su {total}",
"one": "Acquisizioni corrispondenti: {count} su {total}",
"other": "Acquisizioni corrispondenti: {count} su {total}"
},
"{n} acquisitions searched": {
"many": "Acquisizioni esaminate: {n}",
"one": "Acquisizioni esaminate: {n}",
"other": "Acquisizioni esaminate: {n}"
},
"No matching imagery": "Nessuna immagine corrispondente",
"Unknown number of {kind}": "{kind}: numero sconosciuto",
"About {count} {kind}": "{kind}: circa {count}",
"At least {count} {kind}": "{kind}: almeno {count}",
"{count} {kind}": "{kind}: {count}",
"In {area}": "Area: {area}",
"Area around {place}": "Area intorno a {place}",
"Area around landmark": "Area intorno a un punto di interesse",
"Outline {place}": "Traccia il contorno di {place}",
"Outline area": "Traccia il contorno dell'area",
"Find {place}": "Trova {place}",
"Find area": "Trova area",
"Find recent imagery": "Trova immagini recenti",
"Find {what}": "Trova {what}",
"Find places": "Trova luoghi",
"Drawing outline": "Disegno del contorno",
"Searching imagery catalog": "Ricerca nel catalogo immagini",
"Loading imagery": "Caricamento delle immagini",
"Searching OpenStreetMap": "Ricerca in OpenStreetMap",
"voice area|Area": "Area",
"buffer sized to the place": "zona tampone delle dimensioni del luogo",
"enclosing mapped area": "area mappata che contiene il punto di interesse",
"the map was cleared while looking it up": "la mappa è stata ripulita durante la ricerca",
"OpenStreetMap coverage varies by place": "la copertura di OpenStreetMap varia da luogo a luogo",
"the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni",
"there are more": "ce ne sono altri",
"{radius} m around the landmark": "{radius} m intorno al punto di interesse",
"{count} parts": {
"many": "Parti: {count}",
"one": "Parti: {count}",
"other": "Parti: {count}"
},
"{drawn} of {total} parts drawn": {
"many": "Parti disegnate: {drawn} su {total}",
"one": "Parti disegnate: {drawn} su {total}",
"other": "Parti disegnate: {drawn} su {total}"
},
"{count} duplicate mappings merged": {
"many": "Duplicati uniti: {count}",
"one": "Duplicati uniti: {count}",
"other": "Duplicati uniti: {count}"
},
"listed the first {limit}": "elenco limitato ai primi risultati: {limit}",
"counted over the bounding box of {area}, not its outline": "conteggio nel riquadro di delimitazione di {area}, non entro il suo contorno",
"Search cancelled": "Ricerca annullata",
"Sea Marks": "Segnalamenti marittimi",
"Ski Pistes": "Piste da sci",
"Fly to Tokyo": "Vola a Tokyo",
"Show live flights": "Mostra i voli live",
"Reset to globe view": "Ripristina la vista del globo",
"Show earthquakes": "Mostra i terremoti",
"AI chat": "Chat IA",
"GEV CHAT": "CHAT GEV",
"Ask about the map. Tell it where to go.": "Fai domande sulla mappa. Digli dove andare.",
"Close chat": "Chiudi la chat",
"Conversation": "Conversazione",
"Explore with a question": "Esplora con una domanda",
"Find a place, explore live data, or change your view.": "Trova un luogo, esplora i dati live o cambia vista.",
"Attachments": "Allegati",
"Remove attachment": "Rimuovi l'allegato",
"Attach a map layer": "Allega un livello della mappa",
"Choose a layer…": "Scegli un livello…",
"Attach a file": "Allega un file",
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON o GeoJSON · fino a 10 KB in totale. Gli allegati vengono inviati al tuo provider IA insieme al messaggio.",
"Latest AI response": "Ultima risposta dell'IA",
"Expand conversation": "Espandi la conversazione",
"Command suggestions": "Suggerimenti di comandi",
"Expand chat": "Espandi la chat",
"Add attachment": "Aggiungi allegato",
"Message GEV": "Messaggio a GEV",
"Ask GEV anything about the map…": "Chiedi a GEV qualsiasi cosa sulla mappa…",
"Stop reply": "Interrompi la risposta",
"Send message": "Invia il messaggio",
"Panel position": "Posizione del pannello",
"Move above the dock": "Sposta sopra il dock",
"Dock to the right": "Aggancia a destra",
"Expand panel": "Espandi il pannello",
"Minimize panel": "Riduci il pannello",
"You can attach up to four items.": "Puoi allegare fino a quattro elementi.",
"Attachments are too large together. Use smaller files.": "Nel complesso gli allegati sono troppo grandi. Usa file più piccoli.",
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Scegli un file TXT, Markdown, CSV, JSON o GeoJSON.",
"Each file must be 10 KB or smaller.": "Ogni file non deve superare i 10 KB.",
"Choose a non-empty UTF-8 text file.": "Scegli un file di testo UTF-8 non vuoto.",
"Connecting…": "Connessione…",
"Thinking…": "Sto pensando…",
"Working on the map…": "Sto lavorando sulla mappa…",
"Silent chat · microphone off": "Chat silenziosa · microfono spento",
"Reply interrupted. You can try again.": "Risposta interrotta. Puoi riprovare.",
"Could not connect. Try again.": "Impossibile connettersi. Riprova.",
"Session ended. Send a message to start a new conversation.": "Sessione terminata. Invia un messaggio per iniziare una nuova conversazione.",
"Connection timed out. Try again.": "La connessione è andata in timeout. Riprova.",
"Chat closed.": "Chat chiusa.",
"Connection stopped.": "Connessione interrotta.",
"Message and attachments are too large. Remove an attachment or shorten your message.": "Messaggio e allegati sono troppo grandi. Rimuovi un allegato o accorcia il messaggio.",
"Message was not sent. Try again.": "Il messaggio non è stato inviato. Riprova.",
"Reply stopped.": "Risposta interrotta.",
"Suggestions": "Suggerimenti",
"Recent": "Recenti",
"Quick commands": "Comandi rapidi",
"Open AI chat": "Apri la chat IA",
"CHAT": "CHAT",
"Back to chat": "Torna alla chat",
"Back to voice": "Torna alla voce",
"Latest voice exchange": "Ultimo scambio vocale",
"Try saying": "Prova a dire",
"What am I looking at?": "Cosa sto guardando?",
"AI Provider": "Provider IA",
"The selected AI provider is shared by voice and chat.": "Il provider IA selezionato è condiviso tra voce e chat.",
"Show quick commands": "Mostra i comandi rapidi",
"Chat replies are silent. No microphone access is needed.": "Le risposte della chat sono silenziose. Non serve l'accesso al microfono.",
"Chat": "Chat",
"Attach layer": "Allega livello",
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "La chat scritta usa la tua chiave API OpenAI. L'accesso a ChatGPT vale solo per la voce.",
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "La chat scritta non è disponibile finché GEV_PREFER_CODEX_OAUTH richiede ChatGPT OAuth.",
"That request needed too many steps. Changes already made to the map stay in place.": "Per questa richiesta sono serviti troppi passaggi. Le modifiche già apportate alla mappa restano.",
"That request needed too many steps. Try a simpler request.": "Per questa richiesta sono serviti troppi passaggi. Prova con una richiesta più semplice.",
"The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.",
"Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.",
"SIGHTING": "AVVISTAMENTO",
"Show {name} sighting": "Mostra avvistamento di {name}",
"seen {age} ago": "avvistato {age} fa",
"observed {age} ago": "osservato {age} fa",
"Be Whale Wise": "Rispetta i cetacei",
"Reported by {name} ({entity})": "Segnalato da {name} ({entity})",
"Reported by {name}": "Segnalato da {name}",
"Salish Sea": "Mare dei Salish",
"Birds": "Uccelli",
"Mammals": "Mammiferi",
"Reptiles": "Rettili",
"Amphibians": "Anfibi",
"Ray-finned fishes": "Pesci con pinne raggiate",
"Insects": "Insetti",
"Arachnids": "Aracnidi",
"Plants": "Piante",
"Fungi": "Funghi",
"Other animals": "Altri animali",
"Other or unknown": "Altri o sconosciuti",
"{label} {detail} — click to retry": "{label} {detail} — fai clic per riprovare",
"Wildlife sightings source unavailable": "Fonte degli avvistamenti non disponibile",
"Show only community-confirmed (research-grade) sightings": "Mostra solo osservazioni confermate dalla comunità (grado di ricerca)",
"No recent sightings in view": "Nessun avvistamento recente nella vista",
"Acartia feed unavailable": "Feed Acartia non disponibile",
"Show all taxa": "Mostra tutti i gruppi tassonomici",
"BIRDS": "UCCELLI",
"Show only birds": "Mostra solo uccelli",
"MAMMALS": "MAMMIFERI",
"Show only mammals": "Mostra solo mammiferi",
"WHALES": "CETACEI",
"Show only whales and dolphins (7 days)": "Mostra solo balene e delfini (7 giorni)",
"INSECTS": "INSETTI",
"Show only insects": "Mostra solo insetti",
"PLANTS": "PIANTE",
"Show only plants": "Mostra solo piante",
"All rights reserved": "Tutti i diritti riservati",
"Cetacean": "Cetaceo",
"Wildlife Sightings": "Avvistamenti naturalistici",
"Exit cockpit to fly to a sighting": "Esci dalla cabina per volare a un avvistamento",
"Exit cockpit to fly to a sighting area": "Esci dalla cabina per volare a un’area di avvistamento",
"Wildlife": "Natura",
"Zoom in for local sightings": "Ingrandisci per gli avvistamenti locali",
"Loading photo...": "Caricamento della foto…",
"Photo unavailable": "Foto non disponibile",
"Location obscured to a 0.2° cell": "Posizione oscurata in una cella di 0,2°",
"Photo of {name}": "Foto di {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} avvistamenti nella vista",
"Close sighting": "Chiudi avvistamento",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna osservata nelle ultime 48 h (cetacei 7 giorni). Colore e simbolo del contrassegno indicano il gruppo tassonomico. Le specie sensibili sono mostrate solo in celle di 0,2°; le segnalazioni di mammiferi marini di Acartia nel mar dei Salish sono arrotondate a 0,01°. Non è un censimento.",
"Open {name} on iNaturalist": "Apri {name} su iNaturalist",
"Open {name} on Acartia": "Apri {name} su Acartia",
"iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione",
"{days}d": "{days}g",
"All markers": "Tutti gli indicatori",
"Show markers of every age": "Mostra gli indicatori di qualsiasi età",
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti",
"event age filter|All": "Tutti",
"Event age": "Età degli eventi",
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti",
"Close camera preview": "Chiudi anteprima della telecamera",
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP."
}
+236 -5
View File
@@ -1,7 +1,4 @@
{
"Close camera preview": "カメラのプレビューを閉じる",
"Refresh map still": "地図上の静止画を更新",
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
"Pick a shape, then click the map.": "図形を選んでから、地図をクリックしてください。",
"Enter to place the pin, Esc to cancel.": "Enterでピンを配置、Escでキャンセル。",
"Click where the pin goes.": "ピンを置く場所をクリックしてください。",
@@ -1074,8 +1071,16 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション)",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション、TomTomキーが必要)",
"Hybrid": "ハイブリッド",
"Lines": "ライン",
"Dots": "ドット",
"Road lines colored by live flow: green to red": "ライブの交通流で色分けした道路ライン(緑から赤)",
"Moving dots colored by live flow": "ライブの交通流で色分けした移動ドット",
"Moving dots": "移動ドット",
"TomTom key unavailable": "TomTomキーを利用できません",
"TomTom daily budget reached": "TomTomの1日の上限に達しました",
"Monthly traffic allowance used": "今月の交通情報の上限に達しました",
"Traffic rate limit reached": "交通情報のリクエスト上限に達しました",
"Traffic service unavailable": "交通情報サービスを利用できません",
"TomTom upstream unreachable": "TomTomのサービスに接続できません",
"TomTom flow unavailable": "TomTomの交通流を利用できません",
"SIMULATED — {reason}": "シミュレーション:{reason}",
@@ -1615,6 +1620,9 @@
"REGIONAL NEWS": "地域ニュース",
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません",
"CONTEXT STANDBY": "コンテキスト待機",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません",
@@ -1687,7 +1695,7 @@
"Restore real aircraft": "実際の航空機に戻す",
"Reclassify as TR-3B": "TR-3Bに再分類",
"TRACK ACQUIRED": "追跡開始",
"{label} · COURSE {heading}°": "{label} · 針路 {heading}°",
"{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°",
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
"No selected data layers": "選択中のデータレイヤーはありません",
"{count} data layers could not be cleared": {
@@ -2660,6 +2668,8 @@
"AVAILABLE MISSIONS": "利用可能なミッション",
"SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示",
"LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中",
"Space Missions unavailable: the mission index could not load. Try again later.": "宇宙ミッションを利用できません:ミッション一覧を読み込めませんでした。後でもう一度お試しください。",
"30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択",
"Internet radio and local SDR": "インターネットラジオとローカルSDR",
"STATION TAG": "局タグ",
@@ -2840,6 +2850,18 @@
"route proxy error": "ルート検索プロキシでエラーが発生しました",
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
"{lower} to {upper}": "{lower}–{upper}",
"Up to {upper}": "{upper}まで",
"From {lower}": "{lower}から",
"Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください",
"Unnamed zone": "名称不明の区域",
"Restricted airspace source unavailable": "制限空域のデータソースを利用できません",
"Prohibited (P)": "飛行禁止区域(P)",
"Restricted (R)": "飛行制限区域(R)",
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの特別使用空域のうち、飛行禁止区域と飛行制限区域のみです。UAS Facility MapやUASの飛行制限は含みません。参考情報であり、公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
"Prohibited": "飛行禁止区域",
"Restricted": "飛行制限区域",
"Prohibited & Restricted Airspace": "飛行禁止・飛行制限区域",
"scene|PLAY": "再生",
"AUTHORING": "作成",
"Create and edit scenes": "シーンを作成・編集",
@@ -2867,6 +2889,19 @@
"Right panels": "右パネル",
"CONFIRMED": "確認済み",
"PAD / RTLS": "射場 / RTLS",
"fire confidence filter|All": "すべて",
"fire confidence filter|Nominal+": "中以上",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "すべての検知。最も強い火災の多くは信頼度「低」と判定されています",
"Nominal and high confidence detections": "信頼度「中」以上の検知",
"High confidence detections only": "信頼度「高」の検知のみ",
"Fire intensity (FRP)": "火災強度(FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "信頼度",
"Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)",
"fire intensity filter|All": "すべて",
"Detection confidence": "検知の信頼度",
"{shown} of {total} shown": "{total}件中{shown}件を表示",
"Name of {name}": "{name}の名前",
"Change the colour of {name}": "{name}の色を変更",
"Colour of {name}": "{name}の色",
@@ -2876,7 +2911,203 @@
"UTILITIES": "ユーティリティ",
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
"Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)",
"OSM Places": "OSMの場所",
"state or province": "州・県",
"county or district": "郡・地区",
"neighbourhood": "近隣地区",
"natural region": "自然地域",
"Which {place}?": "どの「{place}」ですか?",
"Which place?": "どの場所ですか?",
"Not drawn: {parts}": "未描画:{parts}",
"approximate": "近似",
"{pct}% cloud": "雲量 {pct}%",
"Over {area}": "範囲:{area}",
"{count} of {total} acquisitions match": {
"other": "一致した画像:{count}/{total}件"
},
"{n} acquisitions searched": {
"other": "検索した画像:{n}件"
},
"No matching imagery": "一致する画像はありません",
"Unknown number of {kind}": "{kind}:件数不明",
"About {count} {kind}": "{kind}:約{count}か所",
"At least {count} {kind}": "{kind}:{count}か所以上",
"{count} {kind}": "{kind}:{count}か所",
"In {area}": "{area}内",
"Area around {place}": "{place}周辺のエリア",
"Area around landmark": "ランドマーク周辺のエリア",
"Outline {place}": "{place}の輪郭を描画",
"Outline area": "エリアの輪郭を描画",
"Find {place}": "{place}を検索",
"Find area": "エリアを検索",
"Find recent imagery": "最近の衛星画像を検索",
"Find {what}": "{what}を検索",
"Find places": "場所を検索",
"Drawing outline": "輪郭を地図に描画中",
"Searching imagery catalog": "画像カタログを検索中",
"Loading imagery": "画像を読み込み中",
"Searching OpenStreetMap": "OpenStreetMapを検索中",
"voice area|Area": "エリア",
"buffer sized to the place": "場所の大きさに合わせたバッファー",
"enclosing mapped area": "ランドマークを含むマップ済みのエリア",
"the map was cleared while looking it up": "検索中に地図が消去されました",
"OpenStreetMap coverage varies by place": "OpenStreetMapの網羅度は場所によって異なります",
"the catalog covers the last 30 days": "カタログの対象は過去30日間です",
"there are more": "ほかにもあります",
"{radius} m around the landmark": "ランドマークから半径{radius} m",
"{count} parts": {
"other": "パーツ数:{count}"
},
"{drawn} of {total} parts drawn": {
"other": "描画したパーツ:{drawn}/{total}"
},
"{count} duplicate mappings merged": {
"other": "重複登録{count}件を統合"
},
"listed the first {limit}": "最初の{limit}件を表示",
"counted over the bounding box of {area}, not its outline": "{area}の輪郭ではなく外接矩形の範囲で数えています",
"Search cancelled": "検索をキャンセルしました",
"Sea Marks": "航路標識",
"Ski Pistes": "スキーゲレンデ",
"Ski Pistes": "スキーコース",
"Fly to Tokyo": "東京へ移動して",
"Show live flights": "ライブフライトを表示して",
"Reset to globe view": "地球全体ビューに戻して",
"Show earthquakes": "地震を表示して",
"AI chat": "AIチャット",
"GEV CHAT": "GEVチャット",
"Ask about the map. Tell it where to go.": "地図について質問できます。行き先を伝えてください。",
"Close chat": "チャットを閉じる",
"Conversation": "会話",
"Explore with a question": "質問から探索",
"Find a place, explore live data, or change your view.": "場所を探したり、ライブデータを見たり、表示を変えたりできます。",
"Attachments": "添付",
"Remove attachment": "添付を削除",
"Attach a map layer": "地図レイヤーを添付",
"Choose a layer…": "レイヤーを選択…",
"Attach a file": "ファイルを添付",
"TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT、Markdown、CSV、JSON、GeoJSON · 合計10 KBまで。添付はメッセージとともにAIプロバイダーへ送信されます。",
"Latest AI response": "AIの最新の応答",
"Expand conversation": "会話を展開",
"Command suggestions": "コマンドの候補",
"Expand chat": "チャットを展開",
"Add attachment": "添付を追加",
"Message GEV": "GEVへのメッセージ",
"Ask GEV anything about the map…": "地図についてGEVに何でも質問…",
"Stop reply": "応答を停止",
"Send message": "メッセージを送信",
"Panel position": "パネルの位置",
"Move above the dock": "ドックの上へ移動",
"Dock to the right": "右側にドッキング",
"Expand panel": "パネルを展開",
"Minimize panel": "パネルを最小化",
"You can attach up to four items.": "添付できるのは4件までです。",
"Attachments are too large together. Use smaller files.": "添付の合計サイズが大きすぎます。小さいファイルを使ってください。",
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "TXT、Markdown、CSV、JSON、GeoJSONのいずれかのファイルを選んでください。",
"Each file must be 10 KB or smaller.": "各ファイルは10 KB以下にしてください。",
"Choose a non-empty UTF-8 text file.": "空でないUTF-8のテキストファイルを選んでください。",
"Connecting…": "接続中…",
"Thinking…": "考え中…",
"Working on the map…": "地図を操作中…",
"Silent chat · microphone off": "テキストチャット · マイク オフ",
"Reply interrupted. You can try again.": "応答が中断されました。もう一度お試しください。",
"Could not connect. Try again.": "接続できませんでした。もう一度お試しください。",
"Session ended. Send a message to start a new conversation.": "セッションが終了しました。メッセージを送ると新しい会話を始められます。",
"Connection timed out. Try again.": "接続がタイムアウトしました。もう一度お試しください。",
"Chat closed.": "チャットを閉じました。",
"Connection stopped.": "接続が停止しました。",
"Message and attachments are too large. Remove an attachment or shorten your message.": "メッセージと添付が大きすぎます。添付を外すか、メッセージを短くしてください。",
"Message was not sent. Try again.": "メッセージを送信できませんでした。もう一度お試しください。",
"Reply stopped.": "応答を停止しました。",
"Suggestions": "候補",
"Recent": "履歴",
"Quick commands": "クイックコマンド",
"Open AI chat": "AIチャットを開く",
"CHAT": "チャット",
"Back to chat": "チャットに戻る",
"Back to voice": "音声に戻る",
"Latest voice exchange": "直近の音声のやり取り",
"Try saying": "話しかけてみましょう",
"What am I looking at?": "いま見えているのは何?",
"AI Provider": "AIプロバイダー",
"The selected AI provider is shared by voice and chat.": "選んだAIプロバイダーは音声とチャットで共通です。",
"Show quick commands": "クイックコマンドを表示",
"Chat replies are silent. No microphone access is needed.": "チャットの応答は読み上げられません。マイクへのアクセスは不要です。",
"Chat": "チャット",
"Attach layer": "レイヤーを添付",
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "テキストチャットはOpenAI APIキーを使用します。ChatGPTのサインインは音声のみが対象です。",
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "GEV_PREFER_CODEX_OAUTHがChatGPT OAuthを必須にしている間、テキストチャットは使えません。",
"That request needed too many steps. Changes already made to the map stay in place.": "このリクエストは手順が多すぎました。地図に加えた変更はそのまま残ります。",
"That request needed too many steps. Try a simpler request.": "このリクエストは手順が多すぎました。より簡単なリクエストを試してください。",
"The provider rejected the API key. Check it in Provider Settings.": "プロバイダーがAPIキーを拒否しました。プロバイダー設定で確認してください。",
"Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。",
"The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。",
"SIGHTING": "目撃情報",
"Show {name} sighting": "{name}の目撃情報を表示",
"seen {age} ago": "{age}前に目撃",
"observed {age} ago": "{age}前に観察",
"Be Whale Wise": "クジラに配慮しましょう",
"Reported by {name} ({entity})": "報告元 {name}({entity})",
"Reported by {name}": "報告元 {name}",
"Salish Sea": "セイリッシュ海",
"Birds": "鳥類",
"Mammals": "哺乳類",
"Reptiles": "爬虫類",
"Amphibians": "両生類",
"Ray-finned fishes": "条鰭類",
"Insects": "昆虫",
"Arachnids": "クモ類",
"Plants": "植物",
"Fungi": "菌類",
"Other animals": "その他の動物",
"Other or unknown": "その他・不明",
"{label} {detail} — click to retry": "{label} {detail} — クリックして再試行",
"Wildlife sightings source unavailable": "野生生物の目撃情報源を利用できません",
"Show only community-confirmed (research-grade) sightings": "コミュニティが確認した研究グレードの目撃情報のみ表示",
"No recent sightings in view": "表示範囲に最近の目撃情報はありません",
"Acartia feed unavailable": "Acartiaのフィードを利用できません",
"Show all taxa": "すべての分類群を表示",
"BIRDS": "鳥類",
"Show only birds": "鳥類のみ表示",
"MAMMALS": "哺乳類",
"Show only mammals": "哺乳類のみ表示",
"WHALES": "クジラ類",
"Show only whales and dolphins (7 days)": "クジラとイルカのみ表示(7日間)",
"INSECTS": "昆虫",
"Show only insects": "昆虫のみ表示",
"PLANTS": "植物",
"Show only plants": "植物のみ表示",
"All rights reserved": "無断転載禁止",
"Cetacean": "クジラ類",
"Wildlife Sightings": "野生生物の目撃情報",
"Exit cockpit to fly to a sighting": "目撃地点に移動するにはコックピットを終了",
"Exit cockpit to fly to a sighting area": "目撃範囲に移動するにはコックピットを終了",
"Wildlife": "野生生物",
"Zoom in for local sightings": "拡大して地域の目撃情報を表示",
"Loading photo...": "写真を読み込み中…",
"Photo unavailable": "写真を表示できません",
"Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています",
"Photo of {name}": "{name}の写真",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "表示範囲に{count}件の目撃情報",
"Close sighting": "目撃情報を閉じる",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "過去48時間以内に観察された野生生物(クジラ類は7日間)。色とバッジのアイコンは分類群を表します。保護が必要な種は0.2°の区画のみ表示し、セイリッシュ海のAcartia海生哺乳類目撃情報は0.01°に丸めます。個体数調査ではありません。",
"Open {name} on iNaturalist": "iNaturalistで{name}を開く",
"Open {name} on Acartia": "Acartiaで{name}を開く",
"iNaturalist": "iNaturalist",
"iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと",
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと",
"{days}d": "{days}日",
"All markers": "すべてのマーカー",
"Show markers of every age": "日付にかかわらずすべてのマーカーを表示",
"Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持",
"event age filter|All": "すべて",
"Event age": "経過日数",
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持",
"Close camera preview": "カメラのプレビューを閉じる",
"Refresh map still": "地図上の静止画を更新",
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。"
}
Loaded 100 of 474 files, more files were not shown because too many files have changed in this diff. Show more