mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Merge remote-tracking branch 'origin/main' into claude/hal-676-gev-fire-intensity-742bd1
# Conflicts: # src/i18n/locales/es.json # src/i18n/locales/ru.json # src/i18n/locales/uk.json # src/i18n/locales/zh-CN.json
This commit is contained in:
commit
908cce915a
222 files changed
+31778
-1189
No files matched your search
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,7 @@ 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 { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createOsmOverlayLayers } from '../data/osmOverlays.js';
|
||||
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||
@@ -42,6 +44,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 +134,9 @@ export function createApplicationCatalog({
|
||||
windowMs: sources['gnss-integrity'].windowMs,
|
||||
}),
|
||||
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
|
||||
createApplicationDroneAirspace({
|
||||
source: sources['drone-airspace'],
|
||||
}),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
createApplicationStreetLevel({
|
||||
surface,
|
||||
@@ -145,6 +152,7 @@ export function createApplicationCatalog({
|
||||
createApplicationBikeshare({ source: sources.bikeshare }),
|
||||
createApplicationDirections({ source: sources.directions }),
|
||||
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
|
||||
createApplicationOsmPlaces(),
|
||||
vessels,
|
||||
installations,
|
||||
createApplicationAwareness({
|
||||
|
||||
@@ -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, 37);
|
||||
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, 37);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
Vendored
+7
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -49,6 +49,7 @@ 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']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 35);
|
||||
assert.equal(actual.layers.length, 37);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -181,6 +181,13 @@ 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)',
|
||||
},
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
{
|
||||
@@ -311,6 +318,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 & 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 +587,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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -612,6 +612,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',
|
||||
|
||||
@@ -203,8 +203,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 34);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 34);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 35);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -243,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(), '9');
|
||||
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(
|
||||
@@ -306,8 +306,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '8',
|
||||
next: '9',
|
||||
future: '9',
|
||||
next: '00',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -317,7 +317,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 8/,
|
||||
/next free token 9/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
@@ -340,10 +340,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' }),
|
||||
@@ -2695,6 +2695,22 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
|
||||
);
|
||||
});
|
||||
|
||||
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, [
|
||||
|
||||
@@ -128,7 +128,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, 35);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -139,6 +139,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
's2-ghostmaps': '5',
|
||||
'sea-marks': '6',
|
||||
'ski-pistes': '7',
|
||||
'drone-airspace': '8',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -164,9 +165,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', '9']])
|
||||
.future,
|
||||
'8',
|
||||
'9',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -184,14 +185,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: [...reservationRows, ['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 +200,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 +216,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: [...reservationRows, ['pr-a', '9']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -237,8 +238,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' }],
|
||||
[...reservationRows, ['pr-b', '9']],
|
||||
[{ id: 'pr-b', token: '9' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
@@ -246,10 +247,10 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '8'], ['pr-b', '8']],
|
||||
[...reservationRows, ['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 +258,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 = [...reservationRows, ['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,20 +275,20 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '8'], ['pr-b', '9']],
|
||||
[...reservationRows, ['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,
|
||||
...digitRows,
|
||||
@@ -339,7 +340,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,
|
||||
]);
|
||||
@@ -376,7 +377,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 +395,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, 36);
|
||||
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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1031,11 +1037,12 @@ 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.
|
||||
assert.equal(block.length, 31318, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'5f5275cd6e320afef4fd224b235f3e0f191ec06599b257ffe1f60b703a255199',
|
||||
'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
|
||||
+168
-1
@@ -3236,6 +3236,173 @@
|
||||
"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": {
|
||||
"few": "الصور المطابقة: {count} من {total}",
|
||||
"many": "الصور المطابقة: {count} من {total}",
|
||||
"one": "الصور المطابقة: {count} من {total}",
|
||||
"two": "الصور المطابقة: {count} من {total}",
|
||||
"zero": "الصور المطابقة: {count} من {total}",
|
||||
"other": "الصور المطابقة: {count} من {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"few": "الصور التي جرى البحث فيها: {n}",
|
||||
"many": "الصور التي جرى البحث فيها: {n}",
|
||||
"one": "الصور التي جرى البحث فيها: {n}",
|
||||
"two": "الصور التي جرى البحث فيها: {n}",
|
||||
"zero": "الصور التي جرى البحث فيها: {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": "مسارات التزلج"
|
||||
"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": "المجال الجوي المحظور والمقيّد"
|
||||
}
|
||||
+148
-1
@@ -2907,6 +2907,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 Sperrgebiete 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",
|
||||
@@ -2956,6 +2968,141 @@
|
||||
"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 ein Wahrzeichen",
|
||||
"Outline {place}": "{place} umreißen",
|
||||
"Outline area": "Gebiet umreißen",
|
||||
"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 in Größe des Ortes",
|
||||
"enclosing mapped area": "umschließendes kartiertes Gebiet",
|
||||
"the map was cleared while looking it up": "die Karte wurde während der Suche geleert",
|
||||
"OpenStreetMap coverage varies by place": "Die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
|
||||
"the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage",
|
||||
"there are more": "es gibt weitere",
|
||||
"{radius} m around the landmark": "{radius} m um das Wahrzeichen",
|
||||
"{count} parts": {
|
||||
"one": "Teile: {count}",
|
||||
"other": "Teile: {count}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"one": "Gezeichnete Teile: {drawn} von {total}",
|
||||
"other": "Gezeichnete Teile: {drawn} von {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"one": "Zusammengeführte Doppelkartierungen: {count}",
|
||||
"other": "Zusammengeführte Doppelkartierungen: {count}"
|
||||
},
|
||||
"listed the first {limit}": "die ersten {limit} aufgelistet",
|
||||
"counted over the bounding box of {area}, not its outline": "über das umgebende Rechteck gezählt ({area}), nicht über den Umriss",
|
||||
"Search cancelled": "Suche abgebrochen",
|
||||
"Sea Marks": "Seezeichen",
|
||||
"Ski Pistes": "Skipisten"
|
||||
"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."
|
||||
}
|
||||
+153
-1
@@ -2996,6 +2996,18 @@
|
||||
"fire intensity filter|All": "Todo",
|
||||
"Detection confidence": "Confianza de la detección",
|
||||
"{shown} of {total} shown": "{shown} de {total} mostrados",
|
||||
"{lower} to {upper}": "{lower} a {upper}",
|
||||
"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, sin el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo: no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Prohibido",
|
||||
"Restricted": "Restringido",
|
||||
"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",
|
||||
@@ -3026,6 +3038,146 @@
|
||||
"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}?": "¿Cuál {place}?",
|
||||
"Which place?": "¿Qué lugar?",
|
||||
"Not drawn: {parts}": "Sin dibujar: {parts}",
|
||||
"approximate": "aproximado",
|
||||
"{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}: unos {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 cartografiada que lo rodea",
|
||||
"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 lugar",
|
||||
"{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": "contado en el rectángulo que lo rodea ({area}), no en su contorno",
|
||||
"Search cancelled": "Búsqueda cancelada",
|
||||
"Sea Marks": "Señales marítimas",
|
||||
"Ski Pistes": "Pistas de esquí"
|
||||
"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."
|
||||
}
|
||||
+153
-1
@@ -2977,6 +2977,18 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
|
||||
"Malformed cyclone snapshot": "Instantané cyclonique mal formé",
|
||||
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
|
||||
"{lower} to {upper}": "{lower} à {upper}",
|
||||
"Up to {upper}": "Jusqu’à {upper}",
|
||||
"From {lower}": "À partir de {lower}",
|
||||
"Informational only · not for flight planning": "À titre informatif uniquement · ne pas utiliser pour la planification de vol",
|
||||
"Unnamed zone": "Zone sans nom",
|
||||
"Restricted airspace source unavailable": "Source de l’espace aérien réglementé indisponible",
|
||||
"Prohibited (P)": "Zone interdite (P)",
|
||||
"Restricted (R)": "Zone réglementée (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre informatif uniquement — ne remplace pas les outils officiels de planification de vol des drones (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zone interdite",
|
||||
"Restricted": "Zone réglementée",
|
||||
"Prohibited & Restricted Airspace": "Zones interdites et réglementées",
|
||||
"scene|PLAY": "LIRE",
|
||||
"AUTHORING": "CRÉATION",
|
||||
"Create and edit scenes": "Créer et modifier des scènes",
|
||||
@@ -3026,6 +3038,146 @@
|
||||
"UTILITIES": "OUTILS",
|
||||
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
|
||||
"Voice place search (needs an Overpass server)": "Recherche de lieux par la voix (nécessite un serveur Overpass)",
|
||||
"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}": "Non dessiné : {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 repère",
|
||||
"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 à 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}": "les {limit} premiers sont listés",
|
||||
"counted over the bounding box of {area}, not its outline": "compté sur le rectangle englobant ({area}), pas sur le contour",
|
||||
"Search cancelled": "Recherche annulée",
|
||||
"Sea Marks": "Balisage maritime",
|
||||
"Ski Pistes": "Pistes de ski"
|
||||
"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."
|
||||
}
|
||||
+153
-1
@@ -2977,6 +2977,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": "Zona senza nome",
|
||||
"Restricted airspace source unavailable": "Fonte dello spazio aereo regolamentato non disponibile",
|
||||
"Prohibited (P)": "Zona proibita (P)",
|
||||
"Restricted (R)": "Zona regolamentata (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone proibite e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona proibita",
|
||||
"Restricted": "Zona regolamentata",
|
||||
"Prohibited & Restricted Airspace": "Zone proibite e regolamentate",
|
||||
"scene|PLAY": "AVVIA",
|
||||
"AUTHORING": "CREAZIONE",
|
||||
"Create and edit scenes": "Crea e modifica le scene",
|
||||
@@ -3026,6 +3038,146 @@
|
||||
"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}": "Non disegnati: {parts}",
|
||||
"approximate": "approssimativo",
|
||||
"{pct}% cloud": "{pct}% di nuvole",
|
||||
"Over {area}": "Zona: {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 contorno di {place}",
|
||||
"Outline area": "Traccia contorno 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 immagini",
|
||||
"Searching OpenStreetMap": "Ricerca in OpenStreetMap",
|
||||
"voice area|Area": "Area",
|
||||
"buffer sized to the place": "zona tampone delle dimensioni del luogo",
|
||||
"enclosing mapped area": "area mappata circostante",
|
||||
"the map was cleared while looking it up": "la mappa è stata cancellata durante la ricerca",
|
||||
"OpenStreetMap coverage varies by place": "La copertura di OpenStreetMap varia da luogo a luogo",
|
||||
"the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni",
|
||||
"there are more": "ce ne sono altri",
|
||||
"{radius} m around the landmark": "{radius} m attorno al luogo",
|
||||
"{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}": "elencati i primi {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contato sul riquadro di delimitazione ({area}), non sul contorno",
|
||||
"Search cancelled": "Ricerca annullata",
|
||||
"Sea Marks": "Segnalamenti marittimi",
|
||||
"Ski Pistes": "Piste da sci"
|
||||
"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."
|
||||
}
|
||||
+143
-1
@@ -2837,6 +2837,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": "シーンを作成・編集",
|
||||
@@ -2886,6 +2898,136 @@
|
||||
"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プロバイダーを選んでください。"
|
||||
}
|
||||
+153
-1
@@ -2977,6 +2977,18 @@
|
||||
"Don't show this again": "Não mostrar novamente",
|
||||
"ESC to dismiss": "ESC para fechar",
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Dica: o botão GEV MIC no dock permite falar com o mapa.",
|
||||
"{lower} to {upper}": "{lower} a {upper}",
|
||||
"Up to {upper}": "Até {upper}",
|
||||
"From {lower}": "A partir de {lower}",
|
||||
"Informational only · not for flight planning": "Apenas informativo · não usar para planejamento de voo",
|
||||
"Unnamed zone": "Zona sem nome",
|
||||
"Restricted airspace source unavailable": "Fonte de espaço aéreo restrito indisponível",
|
||||
"Prohibited (P)": "Área proibida (P)",
|
||||
"Restricted (R)": "Área restrita (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Apenas áreas proibidas e restritas da FAA (espaço aéreo de uso especial), sem o UAS Facility Map ou as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Área proibida",
|
||||
"Restricted": "Área restrita",
|
||||
"Prohibited & Restricted Airspace": "Áreas proibidas e restritas",
|
||||
"scene|PLAY": "INICIAR",
|
||||
"AUTHORING": "CRIAÇÃO",
|
||||
"Create and edit scenes": "Criar e editar cenas",
|
||||
@@ -3026,6 +3038,146 @@
|
||||
"UTILITIES": "UTILITÁRIOS",
|
||||
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
|
||||
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
|
||||
"Voice place search (needs an Overpass server)": "Busca de lugares por voz (requer um servidor Overpass)",
|
||||
"OSM Places": "Lugares OSM",
|
||||
"state or province": "estado ou província",
|
||||
"county or district": "condado ou distrito",
|
||||
"neighbourhood": "bairro",
|
||||
"natural region": "região natural",
|
||||
"Which {place}?": "Qual {place}?",
|
||||
"Which place?": "Qual lugar?",
|
||||
"Not drawn: {parts}": "Não desenhado: {parts}",
|
||||
"approximate": "aproximado",
|
||||
"{pct}% cloud": "{pct}% de nuvens",
|
||||
"Over {area}": "Área: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Aquisições correspondentes: {count} de {total}",
|
||||
"one": "Aquisições correspondentes: {count} de {total}",
|
||||
"other": "Aquisições correspondentes: {count} de {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"many": "Aquisições pesquisadas: {n}",
|
||||
"one": "Aquisições pesquisadas: {n}",
|
||||
"other": "Aquisições pesquisadas: {n}"
|
||||
},
|
||||
"No matching imagery": "Nenhuma imagem correspondente",
|
||||
"Unknown number of {kind}": "{kind}: número desconhecido",
|
||||
"About {count} {kind}": "{kind}: cerca de {count}",
|
||||
"At least {count} {kind}": "{kind}: pelo menos {count}",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "Área: {area}",
|
||||
"Area around {place}": "Área ao redor de {place}",
|
||||
"Area around landmark": "Área ao redor de um ponto de referência",
|
||||
"Outline {place}": "Traçar contorno de {place}",
|
||||
"Outline area": "Traçar contorno da área",
|
||||
"Find {place}": "Encontrar {place}",
|
||||
"Find area": "Encontrar área",
|
||||
"Find recent imagery": "Encontrar imagens recentes",
|
||||
"Find {what}": "Encontrar {what}",
|
||||
"Find places": "Encontrar lugares",
|
||||
"Drawing outline": "Desenhando contorno",
|
||||
"Searching imagery catalog": "Buscando no catálogo de imagens",
|
||||
"Loading imagery": "Carregando imagens",
|
||||
"Searching OpenStreetMap": "Buscando no OpenStreetMap",
|
||||
"voice area|Area": "Área",
|
||||
"buffer sized to the place": "zona de margem do tamanho do local",
|
||||
"enclosing mapped area": "área mapeada envolvente",
|
||||
"the map was cleared while looking it up": "o mapa foi limpo durante a busca",
|
||||
"OpenStreetMap coverage varies by place": "A cobertura do OpenStreetMap varia de lugar para lugar",
|
||||
"the catalog covers the last 30 days": "o catálogo cobre os últimos 30 dias",
|
||||
"there are more": "há mais",
|
||||
"{radius} m around the landmark": "{radius} m ao redor do local",
|
||||
"{count} parts": {
|
||||
"many": "Partes: {count}",
|
||||
"one": "Partes: {count}",
|
||||
"other": "Partes: {count}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"many": "Partes desenhadas: {drawn} de {total}",
|
||||
"one": "Partes desenhadas: {drawn} de {total}",
|
||||
"other": "Partes desenhadas: {drawn} de {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"many": "Duplicatas mescladas: {count}",
|
||||
"one": "Duplicatas mescladas: {count}",
|
||||
"other": "Duplicatas mescladas: {count}"
|
||||
},
|
||||
"listed the first {limit}": "listados os primeiros {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contado na caixa delimitadora ({area}), não no contorno",
|
||||
"Search cancelled": "Busca cancelada",
|
||||
"Sea Marks": "Sinais marítimos",
|
||||
"Ski Pistes": "Pistas de esqui"
|
||||
"Ski Pistes": "Pistas de esqui",
|
||||
"Fly to Tokyo": "Voe para Tóquio",
|
||||
"Show live flights": "Mostre os voos ao vivo",
|
||||
"Reset to globe view": "Volte à vista do globo",
|
||||
"Show earthquakes": "Mostre os terremotos",
|
||||
"AI chat": "Chat de IA",
|
||||
"GEV CHAT": "CHAT GEV",
|
||||
"Ask about the map. Tell it where to go.": "Pergunte sobre o mapa. Diga para onde ir.",
|
||||
"Close chat": "Fechar chat",
|
||||
"Conversation": "Conversa",
|
||||
"Explore with a question": "Explore com uma pergunta",
|
||||
"Find a place, explore live data, or change your view.": "Encontre um lugar, explore dados ao vivo ou mude a vista do mapa.",
|
||||
"Attachments": "Anexos",
|
||||
"Remove attachment": "Remover anexo",
|
||||
"Attach a map layer": "Anexar uma camada do mapa",
|
||||
"Choose a layer…": "Escolha uma camada…",
|
||||
"Attach a file": "Anexar um arquivo",
|
||||
"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 · até 10 KB no total. Os anexos são enviados ao seu provedor de IA com a sua mensagem.",
|
||||
"Latest AI response": "Última resposta da IA",
|
||||
"Expand conversation": "Expandir conversa",
|
||||
"Command suggestions": "Sugestões de comandos",
|
||||
"Expand chat": "Expandir chat",
|
||||
"Add attachment": "Adicionar anexo",
|
||||
"Message GEV": "Mensagem para o GEV",
|
||||
"Ask GEV anything about the map…": "Pergunte ao GEV qualquer coisa sobre o mapa…",
|
||||
"Stop reply": "Parar resposta",
|
||||
"Send message": "Enviar mensagem",
|
||||
"Panel position": "Posição do painel",
|
||||
"Move above the dock": "Posicionar acima do dock",
|
||||
"Dock to the right": "Acoplar à direita",
|
||||
"Expand panel": "Expandir painel",
|
||||
"Minimize panel": "Minimizar painel",
|
||||
"You can attach up to four items.": "Você pode anexar até quatro itens.",
|
||||
"Attachments are too large together. Use smaller files.": "Os anexos juntos são grandes demais. Use arquivos menores.",
|
||||
"Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Escolha um arquivo TXT, Markdown, CSV, JSON ou GeoJSON.",
|
||||
"Each file must be 10 KB or smaller.": "Cada arquivo deve ter 10 KB ou menos.",
|
||||
"Choose a non-empty UTF-8 text file.": "Escolha um arquivo de texto UTF-8 não vazio.",
|
||||
"Connecting…": "Conectando…",
|
||||
"Thinking…": "Pensando…",
|
||||
"Working on the map…": "Trabalhando no mapa…",
|
||||
"Silent chat · microphone off": "Chat silencioso · microfone desligado",
|
||||
"Reply interrupted. You can try again.": "Resposta interrompida. Você pode tentar novamente.",
|
||||
"Could not connect. Try again.": "Não foi possível conectar. Tente novamente.",
|
||||
"Session ended. Send a message to start a new conversation.": "Sessão encerrada. Envie uma mensagem para iniciar uma nova conversa.",
|
||||
"Connection timed out. Try again.": "A conexão expirou. Tente novamente.",
|
||||
"Chat closed.": "Chat fechado.",
|
||||
"Connection stopped.": "Conexão interrompida.",
|
||||
"Message and attachments are too large. Remove an attachment or shorten your message.": "A mensagem e os anexos são grandes demais. Remova um anexo ou encurte sua mensagem.",
|
||||
"Message was not sent. Try again.": "A mensagem não foi enviada. Tente novamente.",
|
||||
"Reply stopped.": "Resposta interrompida.",
|
||||
"Suggestions": "Sugestões",
|
||||
"Recent": "Recentes",
|
||||
"Quick commands": "Comandos rápidos",
|
||||
"Open AI chat": "Abrir chat de IA",
|
||||
"CHAT": "CHAT",
|
||||
"Back to chat": "Voltar ao chat",
|
||||
"Back to voice": "Voltar à voz",
|
||||
"Latest voice exchange": "Última interação por voz",
|
||||
"Try saying": "Experimente dizer",
|
||||
"What am I looking at?": "O que estou vendo?",
|
||||
"AI Provider": "Provedor de IA",
|
||||
"The selected AI provider is shared by voice and chat.": "O provedor de IA selecionado é usado tanto pela voz quanto pelo chat.",
|
||||
"Show quick commands": "Mostrar comandos rápidos",
|
||||
"Chat replies are silent. No microphone access is needed.": "As respostas do chat são silenciosas. Não é preciso acesso ao microfone.",
|
||||
"Chat": "Chat",
|
||||
"Attach layer": "Anexar camada",
|
||||
"Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "O chat por texto usa sua chave de API da OpenAI. O login do ChatGPT vale apenas para a voz.",
|
||||
"Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "O chat por texto fica indisponível enquanto GEV_PREFER_CODEX_OAUTH exigir o OAuth do ChatGPT.",
|
||||
"That request needed too many steps. Changes already made to the map stay in place.": "Esse pedido precisou de etapas demais. As alterações já feitas no mapa são mantidas.",
|
||||
"That request needed too many steps. Try a simpler request.": "Esse pedido precisou de etapas demais. Tente um pedido mais simples.",
|
||||
"The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configurações de provedores\".",
|
||||
"Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\"."
|
||||
}
|
||||
+158
-1
@@ -3066,6 +3066,18 @@
|
||||
"fire intensity filter|All": "Все",
|
||||
"Detection confidence": "Достоверность детекции",
|
||||
"{shown} of {total} shown": "показано {shown} из {total}",
|
||||
"{lower} to {upper}": "от {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "От {lower}",
|
||||
"Informational only · not for flight planning": "Только для информации · не для планирования полётов",
|
||||
"Unnamed zone": "Зона без названия",
|
||||
"Restricted airspace source unavailable": "Источник данных об ограниченном воздушном пространстве недоступен",
|
||||
"Prohibited (P)": "Запретная зона (P)",
|
||||
"Restricted (R)": "Зона ограничения (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Только запретные зоны и зоны ограничения полётов FAA (воздушное пространство особого использования), без UAS Facility Map и ограничений полётов БАС. Только для информации — не заменяет официальные инструменты планирования полётов БАС (B4UFLY / LAANC).",
|
||||
"Prohibited": "Запретная зона",
|
||||
"Restricted": "Зона ограничения",
|
||||
"Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения",
|
||||
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
|
||||
"route proxy error": "ошибка прокси маршрутов",
|
||||
"AUTHORING": "РЕДАКТОР",
|
||||
@@ -3096,6 +3108,151 @@
|
||||
"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": {
|
||||
"few": "Подходящие снимки: {count} из {total}",
|
||||
"many": "Подходящие снимки: {count} из {total}",
|
||||
"one": "Подходящие снимки: {count} из {total}",
|
||||
"other": "Подходящие снимки: {count} из {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"few": "Просмотрено снимков: {n}",
|
||||
"many": "Просмотрено снимков: {n}",
|
||||
"one": "Просмотрено снимков: {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}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"few": "Нарисовано частей: {drawn} из {total}",
|
||||
"many": "Нарисовано частей: {drawn} из {total}",
|
||||
"one": "Нарисовано частей: {drawn} из {total}",
|
||||
"other": "Нарисовано частей: {drawn} из {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"few": "Объединено дублей: {count}",
|
||||
"many": "Объединено дублей: {count}",
|
||||
"one": "Объединено дублей: {count}",
|
||||
"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": "Чат с ИИ",
|
||||
"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.": "Текстовый чат использует ваш ключ API OpenAI. Вход в 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.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках."
|
||||
}
|
||||
+158
-1
@@ -3058,6 +3058,18 @@
|
||||
"fire intensity filter|All": "Усі",
|
||||
"Detection confidence": "Достовірність виявлення",
|
||||
"{shown} of {total} shown": "показано {shown} з {total}",
|
||||
"{lower} to {upper}": "від {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "Від {lower}",
|
||||
"Informational only · not for flight planning": "Лише для довідки · не для планування польотів",
|
||||
"Unnamed zone": "Зона без назви",
|
||||
"Restricted airspace source unavailable": "Джерело даних про обмежений повітряний простір недоступне",
|
||||
"Prohibited (P)": "Заборонена зона (P)",
|
||||
"Restricted (R)": "Зона обмеження (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
|
||||
"Prohibited": "Заборонена зона",
|
||||
"Restricted": "Зона обмеження",
|
||||
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження",
|
||||
"Malformed cyclone snapshot": "Некоректний знімок циклонів",
|
||||
"Cyclone request timed out": "Минув час очікування запиту циклонів",
|
||||
"scene|PLAY": "СТАРТ",
|
||||
@@ -3096,6 +3108,151 @@
|
||||
"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": {
|
||||
"few": "Відповідні знімки: {count} з {total}",
|
||||
"many": "Відповідні знімки: {count} з {total}",
|
||||
"one": "Відповідні знімки: {count} з {total}",
|
||||
"other": "Відповідні знімки: {count} з {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"few": "Переглянуто знімків: {n}",
|
||||
"many": "Переглянуто знімків: {n}",
|
||||
"one": "Переглянуто знімків: {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}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"few": "Намальовано частин: {drawn} з {total}",
|
||||
"many": "Намальовано частин: {drawn} з {total}",
|
||||
"one": "Намальовано частин: {drawn} з {total}",
|
||||
"other": "Намальовано частин: {drawn} з {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"few": "Об’єднано дублікатів: {count}",
|
||||
"many": "Об’єднано дублікатів: {count}",
|
||||
"one": "Об’єднано дублікатів: {count}",
|
||||
"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": "ШІ-чат",
|
||||
"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.": "Текстовий чат використовує ваш ключ API OpenAI. Вхід у 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.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях."
|
||||
}
|
||||
+143
-1
@@ -2856,6 +2856,18 @@
|
||||
"fire intensity filter|All": "全部",
|
||||
"Detection confidence": "检测置信度",
|
||||
"{shown} of {total} shown": "显示 {shown} / {total}",
|
||||
"{lower} to {upper}": "{lower} 至 {upper}",
|
||||
"Up to {upper}": "最高 {upper}",
|
||||
"From {lower}": "自 {lower} 起",
|
||||
"Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划",
|
||||
"Unnamed zone": "未命名区域",
|
||||
"Restricted airspace source unavailable": "受限空域数据源不可用",
|
||||
"Prohibited (P)": "禁区 (P)",
|
||||
"Restricted (R)": "限制区 (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "仅包含 FAA 禁区和限制区特殊用途空域,不含 UAS Facility Map 和无人机飞行限制。仅供参考,不能替代官方无人机飞行规划工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁区",
|
||||
"Restricted": "限制区",
|
||||
"Prohibited & Restricted Airspace": "禁区与限制区空域",
|
||||
"routing busy — too many routes at once": "路线服务繁忙:同时请求的路线过多",
|
||||
"route proxy error": "路线代理出错",
|
||||
"AUTHORING": "创作",
|
||||
@@ -2886,6 +2898,136 @@
|
||||
"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} 米",
|
||||
"{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.": "最多可添加四个附件。",
|
||||
"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 服务商。"
|
||||
}
|
||||
@@ -2845,6 +2845,18 @@
|
||||
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。",
|
||||
"routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多",
|
||||
"route proxy error": "路線代理出錯",
|
||||
"{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 與無人機飛行限制。僅供參考,不能取代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁航區",
|
||||
"Restricted": "限航區",
|
||||
"Prohibited & Restricted Airspace": "禁航區與限航區空域",
|
||||
"AUTHORING": "創作",
|
||||
"Create and edit scenes": "創建和編輯場景",
|
||||
"SCENE": "場景",
|
||||
@@ -2886,6 +2898,136 @@
|
||||
"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} 米",
|
||||
"{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.": "最多可添加四個附件。",
|
||||
"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 服務商。"
|
||||
}
|
||||
@@ -51,9 +51,11 @@
|
||||
"Blues",
|
||||
"Bus",
|
||||
"CCTV",
|
||||
"CHAT",
|
||||
"COCKPIT",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Chat",
|
||||
"Country",
|
||||
"Cyan",
|
||||
"Cyber",
|
||||
@@ -220,8 +222,10 @@
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CCTV",
|
||||
"CHAT",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Chat",
|
||||
"Color: {units}",
|
||||
"Country",
|
||||
"DEST ---°",
|
||||
@@ -360,6 +364,7 @@
|
||||
"CCTV",
|
||||
"CCTV OFF",
|
||||
"CCTV ON",
|
||||
"CHAT",
|
||||
"COCKPIT",
|
||||
"COMMERCIAL",
|
||||
"CONTACT",
|
||||
@@ -367,6 +372,7 @@
|
||||
"CONTACTS · 250 KM",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Conversation",
|
||||
"Country",
|
||||
"Cyan",
|
||||
"Cyber",
|
||||
@@ -469,6 +475,7 @@
|
||||
"Station",
|
||||
"Station {id}",
|
||||
"Style",
|
||||
"Suggestions",
|
||||
"TAGS · {tags}",
|
||||
"TEMP",
|
||||
"TYPE",
|
||||
@@ -544,9 +551,11 @@
|
||||
"CCTV OFF",
|
||||
"CCTV ON",
|
||||
"CH {index} / {total}",
|
||||
"CHAT",
|
||||
"COCKPIT",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Chat",
|
||||
"Country",
|
||||
"Cyber",
|
||||
"DEST ---°",
|
||||
@@ -667,6 +676,7 @@
|
||||
"satellite",
|
||||
"standard",
|
||||
"view band|METRO",
|
||||
"voice area|Area",
|
||||
"voice|OFF",
|
||||
"{count} MSGS",
|
||||
"{count} feeds live",
|
||||
@@ -757,8 +767,10 @@
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CCTV",
|
||||
"CHAT",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Chat",
|
||||
"Country",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cameraPoseSignature } from '../../data/iconOrientation.js';
|
||||
import { AWARENESS_RANGE_PULSE } from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
@@ -8,6 +9,86 @@ export function createLifecycle({
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
|
||||
/**
|
||||
* Note the user handling the map's camera (pointer, wheel or touch on the
|
||||
* Cesium canvas, where its camera controller listens), so a camera move the
|
||||
* user made can wake the range pulses and one the app made (tracking,
|
||||
* cockpit, orbit, a fly-to) does not. Keys are left out: Cesium's camera
|
||||
* controller reads none, and typing elsewhere must not count. Nor does
|
||||
* input while the camera controller's inputs are off: the cockpit (or a
|
||||
* gizmo drag, or a drawing tool) owns the camera then, and the user's
|
||||
* gesture moves nothing.
|
||||
*/
|
||||
function noteCameraInput(event) {
|
||||
if (event?.type === 'pointermove' && !event.buttons) return;
|
||||
if (!cameraInputsEnabled()) return;
|
||||
layerState.lastCameraInputMs = Date.now();
|
||||
if (event?.type === 'pointerdown') activePointers.add(event.pointerId);
|
||||
if (event?.type === 'pointerdown' || event?.type === 'touchstart')
|
||||
followGesture(true);
|
||||
}
|
||||
|
||||
// A drag can leave the canvas, and Cesium follows it at document level
|
||||
// (in Firefox, and wherever there are no pointer events). So does this,
|
||||
// from a gesture's start on the canvas to its end, so a long gesture keeps
|
||||
// the input window open the whole way.
|
||||
const GESTURE_MOVE_EVENTS = ['pointermove', 'touchmove'];
|
||||
const GESTURE_END_EVENTS = [
|
||||
'pointerup',
|
||||
'pointercancel',
|
||||
'touchend',
|
||||
'touchcancel',
|
||||
];
|
||||
let followingGesture = false;
|
||||
const activePointers = new Set();
|
||||
|
||||
function followGesture(on) {
|
||||
if (followingGesture === on) return;
|
||||
followingGesture = on;
|
||||
if (!on) activePointers.clear();
|
||||
const listen = on ? 'addEventListener' : 'removeEventListener';
|
||||
for (const type of GESTURE_MOVE_EVENTS)
|
||||
globalThis.document?.[listen]?.(type, noteCameraInput, { passive: true });
|
||||
for (const type of GESTURE_END_EVENTS)
|
||||
globalThis.document?.[listen]?.(type, endGesture, { passive: true });
|
||||
globalThis[listen]?.('blur', dropGesture);
|
||||
globalThis.document?.[listen]?.('visibilitychange', dropGesture);
|
||||
}
|
||||
|
||||
// A gesture ends with its last contact: lifting one finger of a pinch
|
||||
// leaves Cesium a one-finger drag.
|
||||
function endGesture(event) {
|
||||
noteCameraInput(event);
|
||||
activePointers.delete(event?.pointerId);
|
||||
if (activePointers.size || event?.touches?.length) return;
|
||||
followGesture(false);
|
||||
}
|
||||
|
||||
// Losing the window or the page mid-gesture means its end may never
|
||||
// arrive here, so stop following it and drop its pending input.
|
||||
function dropGesture(event) {
|
||||
if (
|
||||
event?.type === 'visibilitychange' &&
|
||||
document.visibilityState !== 'hidden'
|
||||
)
|
||||
return;
|
||||
followGesture(false);
|
||||
layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
function cameraInputsEnabled() {
|
||||
const controller = layerState.viewer?.scene?.screenSpaceCameraController;
|
||||
return controller?.enableInputs !== false;
|
||||
}
|
||||
|
||||
const CAMERA_INPUT_EVENTS = [
|
||||
'pointerdown',
|
||||
'pointermove',
|
||||
'pointerup',
|
||||
'wheel',
|
||||
'touchstart',
|
||||
];
|
||||
|
||||
/**
|
||||
* Quantized camera-pose signature, or '' when the camera cannot report a full
|
||||
* pose yet. An unknown pose reads as PARKED, so a camera that is still coming
|
||||
@@ -50,10 +131,18 @@ export function createLifecycle({
|
||||
'gev:awareness-subject-cleared',
|
||||
layerState.subjectClearListener,
|
||||
);
|
||||
const input = layerState.viewer.canvas ?? layerState.viewer.scene?.canvas;
|
||||
for (const type of CAMERA_INPUT_EVENTS)
|
||||
input?.addEventListener?.(type, noteCameraInput, { passive: true });
|
||||
layerState.cameraInputTarget = input ?? null;
|
||||
layerState.preRenderRemover =
|
||||
layerState.viewer.scene.preRender.addEventListener(() => {
|
||||
if (!layerState.enabled) return;
|
||||
const now = Date.now();
|
||||
// Another owner took the camera: a gesture begun before it did must
|
||||
// not wake the pulses on that owner's first move.
|
||||
if (!cameraInputsEnabled())
|
||||
layerState.lastCameraInputMs = Number.NEGATIVE_INFINITY;
|
||||
// "Did the camera move" uses the SAME quantized pose signature the fleet
|
||||
// rotation pass gates on (iconOrientation.cameraPoseSignature) rather than
|
||||
// camera.changed, whose granularity is globally degraded by other layers
|
||||
@@ -62,6 +151,14 @@ export function createLifecycle({
|
||||
if (poseSig !== layerState.lastCameraPoseSig) {
|
||||
layerState.lastCameraPoseSig = poseSig;
|
||||
layerState.lastCameraPoseChangeMs = now;
|
||||
// A camera move the user made runs the range pulses again; parked,
|
||||
// or moved by the app (tracking, cockpit, orbit, a fly-to), they
|
||||
// rest.
|
||||
if (
|
||||
now - layerState.lastCameraInputMs <
|
||||
AWARENESS_RANGE_PULSE.inputWakeMs
|
||||
)
|
||||
parts.rendering.wakePulses();
|
||||
}
|
||||
const decision = parts.model.awarenessRefreshDecision({
|
||||
nowMs: now,
|
||||
@@ -108,6 +205,13 @@ export function createLifecycle({
|
||||
'gev:awareness-subject-cleared',
|
||||
layerState.subjectClearListener,
|
||||
);
|
||||
for (const type of CAMERA_INPUT_EVENTS)
|
||||
layerState.cameraInputTarget?.removeEventListener?.(
|
||||
type,
|
||||
noteCameraInput,
|
||||
);
|
||||
layerState.cameraInputTarget = null;
|
||||
followGesture(false);
|
||||
}
|
||||
layerState.preRenderRemover?.();
|
||||
layerState.preRenderRemover = null;
|
||||
|
||||
@@ -44,6 +44,93 @@ export const AWARENESS_MAX_NAVIGATION_EXAMPLES = 10000;
|
||||
|
||||
export const CONTEXT_RIM_HEIGHT_M = 2500;
|
||||
|
||||
/** @constant The 250 km range ring around the Global Context subject.
|
||||
* The old 1 px ellipse outline vanished over desert, snow and under
|
||||
* the FLIR, NVG and CRT styles. A bright core inside a dark halo keeps
|
||||
* luminance contrast on light and dark ground alike, and luminance is what
|
||||
* the monochrome styles keep. The core is the Context panel's blue; the halo
|
||||
* is the map-card ink. Widths are screen pixels; `outlineWidth` is per side.
|
||||
* It is drawn as an unpickable primitive, so clicks on its edge reach the
|
||||
* contacts underneath.
|
||||
* While the sonar pulses run, the ring dims to `restOpacity` and flashes
|
||||
* to full as each pulse reaches it, fading over `flashMs`. `restOpacity`
|
||||
* keeps the 250 km edge readable between flashes, in screenshots and for
|
||||
* low-vision viewers. Once the pulses rest, and without pulses (reduced
|
||||
* motion, or a scene that cannot animate), it stays on screen at full. */
|
||||
export const AWARENESS_RANGE_RING_STYLE = Object.freeze({
|
||||
color: 'rgba(98, 181, 255, 0.95)',
|
||||
outlineColor: 'rgba(4, 12, 16, 0.8)',
|
||||
width: 4,
|
||||
outlineWidth: 1,
|
||||
segments: 128,
|
||||
flashMs: 1800,
|
||||
restOpacity: 0.4,
|
||||
});
|
||||
|
||||
/** @constant Sonar-style pulses that grow slowly from the subject to the
|
||||
* range ring and are almost gone when they reach it. `count` pulses share
|
||||
* one `periodMs`, evenly staggered, so a new one starts every
|
||||
* `periodMs / count`. Each starts at `startFraction` of the radius, fades in
|
||||
* over `fadeInFraction` of its travel, then fades as (1 - travel) to the
|
||||
* `fadeOutPower` down to `endOpacityFraction` of `peakOpacity`, so a pulse
|
||||
* is dim but still visible when it reaches the ring and the ring flashes.
|
||||
* Each line is `width` CSS pixels wide, its outer `outlineWidth` a dark
|
||||
* halo, in the ring's colours, as Cesium's polyline outline material draws.
|
||||
* Behind each line a radial gradient in the same blue fades inward over
|
||||
* `gradientDepthFraction` of the radius, at most
|
||||
* `gradientOpacity` strong. One unpickable disc draws the lines and the
|
||||
* gradient, so pulses never catch a click meant for a contact; its shader
|
||||
* takes at most four pulses. The disc follows the subject by a model-matrix
|
||||
* rotation, so it stays concentric with the ring, and is rebuilt only after
|
||||
* the subject moves `gradientRebuildM` from where it was built; while the
|
||||
* pulses rest the hidden disc is left alone and catches up when they wake. The
|
||||
* disc is hidden while the camera is below its height, where it would sweep
|
||||
* the sky.
|
||||
* The pulses run for `activeMs` (three ring flashes) after a selection or
|
||||
* a camera move, fading in and out over `settleMs`, then rest: the ring
|
||||
* stays on screen, static, and nothing asks for frames, so a parked view
|
||||
* goes quiet again. Only a camera move within `inputWakeMs` of the user
|
||||
* handling the map (pointer, wheel or touch on the canvas, which covers a
|
||||
* drag's inertia, while the camera controls take input) wakes them; a moving subject and the app's own camera moves
|
||||
* (tracking, cockpit, orbit, a fly-to) do not. Below the disc's height they
|
||||
* rest too. While they run they never hold
|
||||
* continuous rendering; each drawn frame arms one more request after the
|
||||
* Cyber sonar's 80 ms cadence, so the two never beat. When something else
|
||||
* already renders continuously they update every frame. Skipped under
|
||||
* prefers-reduced-motion. */
|
||||
export const AWARENESS_RANGE_PULSE = Object.freeze({
|
||||
count: 3,
|
||||
periodMs: 9000,
|
||||
startFraction: 0.02,
|
||||
fadeInFraction: 0.08,
|
||||
fadeOutPower: 1.5,
|
||||
endOpacityFraction: 0.35,
|
||||
peakOpacity: 0.6,
|
||||
width: 3,
|
||||
outlineWidth: 1,
|
||||
gradientDepthFraction: 0.35,
|
||||
gradientOpacity: 0.22,
|
||||
gradientGranularityDeg: 0.25,
|
||||
gradientRebuildM: 20000,
|
||||
activeMs: 9000,
|
||||
settleMs: 1200,
|
||||
inputWakeMs: 1500,
|
||||
});
|
||||
|
||||
/** @constant Sonar echoes on contact brackets. When a pulse front reaches a
|
||||
* flight, military aircraft or AIS vessel in the window, the detection
|
||||
* overlay draws a second bracket in `color` around that contact's own
|
||||
* bracket. It starts on the bracket and spreads outward by up to `spreadPx`
|
||||
* as it fades over `fadeMs`, so the frame pulses. Only `layerIds` echo;
|
||||
* mapped installations have no detection bracket. */
|
||||
export const AWARENESS_RANGE_ECHO = Object.freeze({
|
||||
layerIds: ['flights', 'military', 'ais-live-vessels'],
|
||||
fadeMs: 1400,
|
||||
color: 'rgb(98, 181, 255)',
|
||||
spreadPx: 8,
|
||||
lineWidth: 1.75,
|
||||
});
|
||||
|
||||
export const VESSEL_FOCUS_RADIUS_M = 3000;
|
||||
|
||||
export const SOURCE_LABEL = {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
CYBER_SONAR_RENDER_INTERVAL_MS,
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
} from '../../cyberSonar.js';
|
||||
@@ -10,8 +11,339 @@ import {
|
||||
AWARENESS_RADIUS_M,
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { bearingBetweenCoordinates } from '../../cockpitMath.js';
|
||||
import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js';
|
||||
import {
|
||||
AWARENESS_PAGE_SIZE,
|
||||
AWARENESS_RANGE_ECHO,
|
||||
AWARENESS_RANGE_PULSE,
|
||||
AWARENESS_RANGE_RING_STYLE,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
} from './policy.js';
|
||||
import { formatAwarenessDirection } from './presentation.js';
|
||||
import {
|
||||
awarenessEchoStrength,
|
||||
clearAwarenessEchoField,
|
||||
setAwarenessEchoField,
|
||||
} from '../../awarenessEcho.js';
|
||||
|
||||
/**
|
||||
* A closed ring of positions `radiusM` along the surface from `groundCenter`,
|
||||
* raised to `heightM`. Each bearing steps over a sphere with the ellipsoid's
|
||||
* radius of curvature in that direction (Euler's formula), which keeps the
|
||||
* ring within ~50 m of the true geodesic radius at 250 km.
|
||||
* @param {Cesium.Cartesian3} groundCenter Ring centre on the ellipsoid.
|
||||
* @param {number} radiusM Surface radius in metres.
|
||||
* @param {number} heightM Height of the ring above the ellipsoid.
|
||||
* @param {number} [segments] Chords around the ring.
|
||||
* @returns {Cesium.Cartesian3[]} `segments + 1` positions; the last repeats the first.
|
||||
*/
|
||||
export function awarenessRangeRingPositions(
|
||||
groundCenter,
|
||||
radiusM,
|
||||
heightM,
|
||||
segments = AWARENESS_RANGE_RING_STYLE.segments,
|
||||
) {
|
||||
const { latitude, longitude } =
|
||||
Cesium.Cartographic.fromCartesian(groundCenter);
|
||||
const { x: a, z: b } = Cesium.Ellipsoid.WGS84.radii;
|
||||
const e2 = 1 - (b * b) / (a * a);
|
||||
const sinLat = Math.sin(latitude);
|
||||
const cosLat = Math.cos(latitude);
|
||||
const sinLon = Math.sin(longitude);
|
||||
const cosLon = Math.cos(longitude);
|
||||
const w = 1 - e2 * sinLat * sinLat;
|
||||
const meridional = (a * (1 - e2)) / (w * Math.sqrt(w));
|
||||
const primeVertical = a / Math.sqrt(w);
|
||||
// Step along the great circle in 3D from the centre's up, north and east
|
||||
// unit vectors. Unlike the latitude/longitude destination formula, this
|
||||
// stays well conditioned at and next to the poles.
|
||||
const up = [cosLat * cosLon, cosLat * sinLon, sinLat];
|
||||
const north = [-sinLat * cosLon, -sinLat * sinLon, cosLat];
|
||||
const east = [-sinLon, cosLon, 0];
|
||||
const positions = [];
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const bearing = ((i % segments) / segments) * Cesium.Math.TWO_PI;
|
||||
const cosB = Math.cos(bearing);
|
||||
const sinB = Math.sin(bearing);
|
||||
const angular =
|
||||
radiusM * ((cosB * cosB) / meridional + (sinB * sinB) / primeVertical);
|
||||
const cosA = Math.cos(angular);
|
||||
const sinA = Math.sin(angular);
|
||||
const [x, y, z] = up.map(
|
||||
(u, k) => u * cosA + (north[k] * cosB + east[k] * sinB) * sinA,
|
||||
);
|
||||
const lat = Math.atan2(z, Math.hypot(x, y));
|
||||
const lon = Math.atan2(y, x);
|
||||
positions.push(Cesium.Cartesian3.fromRadians(lon, lat, heightM));
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where pulse `index` is at `nowMs`: its radius and opacity. Pulses travel at
|
||||
* constant speed from `startFraction` of the range to the full range, fade in
|
||||
* over `fadeInFraction` of the way, then fade as (1 - travel) to the
|
||||
* `fadeOutPower` down to `endOpacityFraction` of the peak, so each is dim
|
||||
* but still visible when it reaches the range ring. The opacity already
|
||||
* includes `peakOpacity`.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {number} index Pulse number, 0 to `count - 1`.
|
||||
* @param {number} [radiusM] Range the pulses grow to.
|
||||
* @param {object} [pulse] Timing and shape, see `AWARENESS_RANGE_PULSE`.
|
||||
* @returns {{radiusM: number, alpha: number}}
|
||||
*/
|
||||
export function awarenessPulseState(
|
||||
nowMs,
|
||||
index,
|
||||
radiusM = AWARENESS_RADIUS_M,
|
||||
pulse = AWARENESS_RANGE_PULSE,
|
||||
) {
|
||||
const cycle = nowMs / pulse.periodMs + index / pulse.count;
|
||||
const phase = cycle - Math.floor(cycle);
|
||||
return {
|
||||
radiusM:
|
||||
radiusM * (pulse.startFraction + (1 - pulse.startFraction) * phase),
|
||||
alpha:
|
||||
pulse.peakOpacity *
|
||||
Math.min(1, phase / pulse.fadeInFraction) *
|
||||
(pulse.endOpacityFraction +
|
||||
(1 - pulse.endOpacityFraction) * (1 - phase) ** pulse.fadeOutPower),
|
||||
};
|
||||
}
|
||||
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
/** Whether the viewer asked the system for less motion. */
|
||||
function prefersReducedMotion() {
|
||||
return globalThis.matchMedia?.(REDUCED_MOTION_QUERY)?.matches ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the pulses are switched on at `nowMs`: 0 at rest, rising to 1 over
|
||||
* `settleMs` from `wake.startMs` and falling back to 0 over `settleMs` up to
|
||||
* `wake.untilMs`.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {{startMs: number, untilMs: number}} wake Window from `wakeAwarenessPulses`.
|
||||
* @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`.
|
||||
* @returns {number} 0 to 1.
|
||||
*/
|
||||
export function awarenessPulseLevel(
|
||||
nowMs,
|
||||
wake,
|
||||
pulse = AWARENESS_RANGE_PULSE,
|
||||
) {
|
||||
const level = Math.min(
|
||||
(nowMs - wake.startMs) / pulse.settleMs,
|
||||
(wake.untilMs - nowMs) / pulse.settleMs,
|
||||
);
|
||||
return Math.max(0, Math.min(1, level));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pulses for `activeMs` from `nowMs`. A wake while they already run
|
||||
* keeps their current level, so they never jump back to full mid-fade.
|
||||
* @param {{startMs: number, untilMs: number}} wake Updated in place.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @param {object} [pulse] Timing, see `AWARENESS_RANGE_PULSE`.
|
||||
* @returns {{startMs: number, untilMs: number}} `wake`.
|
||||
*/
|
||||
export function wakeAwarenessPulses(
|
||||
wake,
|
||||
nowMs,
|
||||
pulse = AWARENESS_RANGE_PULSE,
|
||||
) {
|
||||
wake.startMs =
|
||||
nowMs - awarenessPulseLevel(nowMs, wake, pulse) * pulse.settleMs;
|
||||
wake.untilMs = nowMs + pulse.activeMs;
|
||||
return wake;
|
||||
}
|
||||
|
||||
/**
|
||||
* How strongly the 250 km ring shows at `nowMs` while pulses run: 1 as a
|
||||
* pulse front reaches it, fading over `flashMs`, so the ring appears once
|
||||
* per arriving pulse instead of standing on screen.
|
||||
* @param {number} nowMs Monotonic clock in milliseconds.
|
||||
* @returns {number} 0 to 1.
|
||||
*/
|
||||
export function awarenessRingFlash(nowMs) {
|
||||
return awarenessEchoStrength(
|
||||
nowMs,
|
||||
AWARENESS_RADIUS_M,
|
||||
AWARENESS_RADIUS_M,
|
||||
AWARENESS_RANGE_PULSE,
|
||||
{ fadeMs: AWARENESS_RANGE_RING_STYLE.flashMs },
|
||||
);
|
||||
}
|
||||
|
||||
/** Most pulses the pulse shader draws; its uniforms are vec4s. */
|
||||
export const AWARENESS_PULSE_GRADIENT_MAX = 4;
|
||||
|
||||
/** The pulse disc's Cesium material type, one cached template per page. */
|
||||
const AWARENESS_PULSE_MATERIAL_TYPE = 'GevAwarenessPulseGradient';
|
||||
|
||||
// Ellipse texture coordinates put the centre at (0.5, 0.5) and the rim at
|
||||
// distance 0.5, so `r` is the fraction of the range. Each pulse is a line
|
||||
// `lineWidth` CSS pixels wide whose outer `haloWidth` is a dark halo, half
|
||||
// on each side, as Cesium's polyline outline material draws it, with a band
|
||||
// behind it that is strongest at the line and fades to nothing `depth`
|
||||
// inward. Widths come from the screen-space gradient of `r`, as Cesium's
|
||||
// contour material measures its lines; without derivatives the lines are
|
||||
// left out and only the band shows.
|
||||
const PULSE_GRADIENT_SOURCE = `
|
||||
uniform vec4 color;
|
||||
uniform vec4 lineColor;
|
||||
uniform vec4 haloColor;
|
||||
uniform vec4 radii;
|
||||
uniform vec4 alphas;
|
||||
uniform float depth;
|
||||
uniform float lineWidth;
|
||||
uniform float haloWidth;
|
||||
vec4 over(vec4 top, vec4 under) {
|
||||
float a = top.a + under.a * (1.0 - top.a);
|
||||
if (a <= 0.0) return vec4(0.0);
|
||||
return vec4((top.rgb * top.a + under.rgb * under.a * (1.0 - top.a)) / a, a);
|
||||
}
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
float r = length(materialInput.st - vec2(0.5)) * 2.0;
|
||||
float band = 0.0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float inside = radii[i] - r;
|
||||
if (inside >= 0.0 && inside <= depth) {
|
||||
float t = 1.0 - inside / depth;
|
||||
band += alphas[i] * t * t;
|
||||
}
|
||||
}
|
||||
vec4 result = vec4(color.rgb, clamp(band * color.a, 0.0, 1.0));
|
||||
#if (__VERSION__ == 300 || defined(GL_OES_standard_derivatives))
|
||||
// Change in r per CSS pixel: the gradient's length per device pixel,
|
||||
// times the device pixels in one CSS pixel.
|
||||
float cssPx = max(length(vec2(dFdx(r), dFdy(r))), 1e-7) * czm_pixelRatio;
|
||||
float outerHalf = lineWidth * 0.5;
|
||||
float coreHalf = max(lineWidth - haloWidth, 0.0) * 0.5;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float d = abs(radii[i] - r) / cssPx;
|
||||
float cover = clamp(outerHalf + 0.5 - d, 0.0, 1.0);
|
||||
if (cover <= 0.0) continue;
|
||||
float core = clamp(coreHalf + 0.5 - d, 0.0, 1.0);
|
||||
vec4 pulse = mix(haloColor, lineColor, core);
|
||||
result = over(vec4(pulse.rgb, pulse.a * cover * alphas[i]), result);
|
||||
}
|
||||
#endif
|
||||
material.diffuse = result.rgb;
|
||||
material.alpha = result.a;
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The pulses: one flat, translucent, unpickable disc the size of the range
|
||||
* ring whose shader draws each pulse line and the band behind it. Drawing
|
||||
* them here rather than as entities keeps them out of picking, so clicks and
|
||||
* taps near a pulse still reach the contact underneath. Moving pulses only
|
||||
* change its uniforms; the disc is rebuilt only when the subject moves.
|
||||
* @param {Cesium.Cartesian3} groundCenter Disc centre on the ellipsoid.
|
||||
* @param {Cesium.Material} material Shared pulse-gradient material.
|
||||
* @returns {Cesium.Primitive}
|
||||
*/
|
||||
function createPulseGradient(groundCenter, material) {
|
||||
return new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.EllipseGeometry({
|
||||
center: groundCenter,
|
||||
semiMajorAxis: AWARENESS_RADIUS_M,
|
||||
semiMinorAxis: AWARENESS_RADIUS_M,
|
||||
height: CONTEXT_RIM_HEIGHT_M,
|
||||
granularity: Cesium.Math.toRadians(
|
||||
AWARENESS_RANGE_PULSE.gradientGranularityDeg,
|
||||
),
|
||||
vertexFormat:
|
||||
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
|
||||
}),
|
||||
}),
|
||||
appearance: new Cesium.MaterialAppearance({
|
||||
material,
|
||||
flat: true,
|
||||
translucent: true,
|
||||
faceForward: true,
|
||||
}),
|
||||
asynchronous: false,
|
||||
allowPicking: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring's polyline geometry: `AWARENESS_RANGE_RING_STYLE.width` screen
|
||||
* pixels, straight chords between the given positions (they already follow
|
||||
* the surface), in the material appearance's vertex format.
|
||||
* @param {Cesium.Cartesian3[]} positions Ring positions.
|
||||
* @returns {object} PolylineGeometry options.
|
||||
*/
|
||||
export function awarenessRingGeometryOptions(positions) {
|
||||
return {
|
||||
positions,
|
||||
width: AWARENESS_RANGE_RING_STYLE.width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The 250 km ring as an unpickable polyline primitive in the ring's outline
|
||||
* material, so a click or tap on its edge reaches the contact underneath.
|
||||
* Its positions are geometry, so a moved subject rebuilds it; the material
|
||||
* is shared between builds so a flash keeps going.
|
||||
* @param {Cesium.Cartesian3[]} positions Ring positions.
|
||||
* @param {Cesium.Material} material Shared PolylineOutline material.
|
||||
* @returns {Cesium.Primitive}
|
||||
*/
|
||||
function createRingPrimitive(positions, material) {
|
||||
return new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.PolylineGeometry(
|
||||
awarenessRingGeometryOptions(positions),
|
||||
),
|
||||
}),
|
||||
appearance: new Cesium.PolylineMaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: false,
|
||||
allowPicking: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The rotation about the Earth's centre that carries `from` onto `to`, as a
|
||||
* model matrix, so a disc built around `from` sits around `to`.
|
||||
* @param {Cesium.Cartesian3} from Where the disc was built.
|
||||
* @param {Cesium.Cartesian3} to Where it should be.
|
||||
* @param {Cesium.Matrix4} [result]
|
||||
* @returns {Cesium.Matrix4}
|
||||
*/
|
||||
export function discFollowMatrix(from, to, result = new Cesium.Matrix4()) {
|
||||
const a = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
|
||||
const b = Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3());
|
||||
const axis = Cesium.Cartesian3.cross(a, b, new Cesium.Cartesian3());
|
||||
const sin = Cesium.Cartesian3.magnitude(axis);
|
||||
if (sin < 1e-12) return Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY, result);
|
||||
const angle = Math.atan2(sin, Cesium.Cartesian3.dot(a, b));
|
||||
const rotation = Cesium.Matrix3.fromQuaternion(
|
||||
Cesium.Quaternion.fromAxisAngle(
|
||||
Cesium.Cartesian3.divideByScalar(axis, sin, axis),
|
||||
angle,
|
||||
),
|
||||
);
|
||||
return Cesium.Matrix4.fromRotationTranslation(
|
||||
rotation,
|
||||
Cesium.Cartesian3.ZERO,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
/** Camera height above the ellipsoid, or NaN when the scene cannot say. */
|
||||
function cameraHeightM(scene) {
|
||||
return scene.camera?.positionCartographic?.height ?? Number.NaN;
|
||||
}
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
@@ -271,7 +603,62 @@ export function createRendering({
|
||||
presentDirection({ hidden: false, ring, heading, labels, markers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the range pulses again for `activeMs`, after a camera move. Call it
|
||||
* during a frame (lifecycle's preRender): that frame's postRender arms the
|
||||
* next request, so waking asks for no extra frame of its own.
|
||||
*/
|
||||
function wakePulses() {
|
||||
const wake = layerState.visual?.wake;
|
||||
if (!wake) return;
|
||||
wakeAwarenessPulses(wake, performance.now());
|
||||
followPulseDisc();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the pulse disc centred on the subject. Within `gradientRebuildM` of
|
||||
* where it was built, the disc is rotated about the Earth's centre onto
|
||||
* the subject by its model matrix, which keeps it concentric with the ring
|
||||
* at no build cost; the ellipsoid curves a little differently from that
|
||||
* sphere, a few tens of metres in height at 20 km. Further away it is
|
||||
* rebuilt, which costs a few milliseconds.
|
||||
*/
|
||||
function followPulseDisc() {
|
||||
const { gradient, gradientCenter, center } = layerState.visual;
|
||||
if (!gradient) return;
|
||||
if (
|
||||
Cesium.Cartesian3.distance(gradientCenter, center) <
|
||||
AWARENESS_RANGE_PULSE.gradientRebuildM
|
||||
) {
|
||||
gradient.modelMatrix = discFollowMatrix(
|
||||
gradientCenter,
|
||||
center,
|
||||
gradient.modelMatrix,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const primitives = layerState.viewer.scene.primitives;
|
||||
layerState.visual.gradient = primitives.add(
|
||||
createPulseGradient(center, gradient.appearance.material),
|
||||
);
|
||||
// Primitive.destroy leaves the material alone, so the new disc keeps
|
||||
// sharing it.
|
||||
primitives.remove(gradient);
|
||||
Cesium.Cartesian3.clone(center, gradientCenter);
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
layerState.visual?.stopPulses?.();
|
||||
layerState.visual?.removeMotionListener?.();
|
||||
if (layerState.visual?.gradient && layerState.viewer)
|
||||
layerState.viewer.scene.primitives.remove(layerState.visual.gradient);
|
||||
if (layerState.visual?.ringPrimitive && layerState.viewer) {
|
||||
layerState.viewer.scene.primitives.remove?.(
|
||||
layerState.visual.ringPrimitive,
|
||||
);
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
clearAwarenessEchoField();
|
||||
if (layerState.visual?.entities && layerState.viewer) {
|
||||
for (const entity of layerState.visual.entities)
|
||||
layerState.viewer.entities.remove(entity);
|
||||
@@ -291,17 +678,39 @@ export function createRendering({
|
||||
0,
|
||||
);
|
||||
const key = `${subject.layerId}:${subject.id}`;
|
||||
if (layerState.visual?.key === key) {
|
||||
// Large ellipse geometry is more reliable as a ConstantPositionProperty.
|
||||
// Move it only after a meaningful displacement so live tracking does not
|
||||
// churn ten large geometries for sub-pixel motion every 750 ms.
|
||||
const reducedMotion = prefersReducedMotion();
|
||||
// A reduced-motion change rebuilds the visual, with or without pulses.
|
||||
if (
|
||||
layerState.visual?.key === key &&
|
||||
layerState.visual.reducedMotion === reducedMotion
|
||||
) {
|
||||
// Move the ring only after a meaningful displacement so live tracking
|
||||
// does not rebuild its geometry for sub-pixel motion every 750 ms.
|
||||
if (
|
||||
Cesium.Cartesian3.distance(layerState.visual.center, groundCenter) >=
|
||||
250
|
||||
) {
|
||||
for (const entity of layerState.visual.entities)
|
||||
entity.position.setValue(groundCenter);
|
||||
const positions = awarenessRangeRingPositions(
|
||||
groundCenter,
|
||||
AWARENESS_RADIUS_M,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
);
|
||||
// The echo field reads `visual.center` in place and follows on its
|
||||
// own. The pulse disc follows by its model matrix and is rebuilt only
|
||||
// every `gradientRebuildM`: a build costs a few milliseconds.
|
||||
layerState.visual.ringPositions = positions;
|
||||
const { ringPrimitive } = layerState.visual;
|
||||
if (ringPrimitive) {
|
||||
const primitives = layerState.viewer.scene.primitives;
|
||||
layerState.visual.ringPrimitive = primitives.add(
|
||||
createRingPrimitive(positions, ringPrimitive.appearance.material),
|
||||
);
|
||||
primitives.remove(ringPrimitive);
|
||||
} else layerState.visual.ring.polyline.positions.setValue(positions);
|
||||
Cesium.Cartesian3.clone(groundCenter, layerState.visual.center);
|
||||
// A resting disc is hidden, so it catches up when the pulses wake.
|
||||
if (performance.now() < layerState.visual.wake?.untilMs)
|
||||
followPulseDisc();
|
||||
// Content actually changed — buy exactly one frame instead of holding.
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
@@ -310,26 +719,188 @@ export function createRendering({
|
||||
clearVisual();
|
||||
|
||||
const entities = [];
|
||||
|
||||
entities.push(
|
||||
layerState.viewer.entities.add({
|
||||
position: groundCenter,
|
||||
ellipse: {
|
||||
semiMajorAxis: AWARENESS_RADIUS_M,
|
||||
semiMinorAxis: AWARENESS_RADIUS_M,
|
||||
fill: false,
|
||||
outline: true,
|
||||
outlineColor:
|
||||
Cesium.Color.fromCssColorString('#62b5ff').withAlpha(0.72),
|
||||
height: CONTEXT_RIM_HEIGHT_M,
|
||||
},
|
||||
}),
|
||||
const scene = layerState.viewer.scene;
|
||||
// A viewer without a primitive collection or a preRender hook (headless
|
||||
// tools, partial stubs) gets the static ring only, as the Cyber sonar
|
||||
// scene does. So does a viewer who prefers reduced motion.
|
||||
const animate = Boolean(
|
||||
!reducedMotion &&
|
||||
scene?.primitives?.add &&
|
||||
scene?.preRender?.addEventListener &&
|
||||
scene?.postRender?.addEventListener,
|
||||
);
|
||||
// Pulses run from now until `wake.untilMs`, then rest.
|
||||
const wake = animate
|
||||
? wakeAwarenessPulses(
|
||||
{ startMs: -Infinity, untilMs: -Infinity },
|
||||
performance.now(),
|
||||
)
|
||||
: null;
|
||||
// From a camera below the rim height the disc would sweep across the sky,
|
||||
// so it is hidden there, and the pulses rest with it: no flash, no
|
||||
// echoes, no frames.
|
||||
const belowRim = () => cameraHeightM(scene) < CONTEXT_RIM_HEIGHT_M;
|
||||
const levelAt = (nowMs) =>
|
||||
wake && !belowRim() ? awarenessPulseLevel(nowMs, wake) : 0;
|
||||
|
||||
// A polyline, not an ellipse outline: WebGL draws ellipse outlines at
|
||||
// 1 px whatever `outlineWidth` says, and the ring needs a halo.
|
||||
const ring = AWARENESS_RANGE_RING_STYLE;
|
||||
const core = Cesium.Color.fromCssColorString(ring.color);
|
||||
const halo = Cesium.Color.fromCssColorString(ring.outlineColor);
|
||||
const ringPositions = awarenessRangeRingPositions(
|
||||
groundCenter,
|
||||
AWARENESS_RADIUS_M,
|
||||
CONTEXT_RIM_HEIGHT_M,
|
||||
);
|
||||
// While pulses run, the ring flashes as each one arrives and otherwise
|
||||
// dims to `restOpacity`; as they settle it returns to full and stays.
|
||||
const ringAlpha = (nowMs) => {
|
||||
const pulsed = Math.max(ring.restOpacity, awarenessRingFlash(nowMs));
|
||||
return 1 - levelAt(nowMs) * (1 - pulsed);
|
||||
};
|
||||
let ringEntity = null;
|
||||
let ringMaterial = null;
|
||||
let ringPrimitive = null;
|
||||
if (scene?.primitives?.add) {
|
||||
ringMaterial = Cesium.Material.fromType('PolylineOutline', {
|
||||
color: core.clone(),
|
||||
outlineColor: halo.clone(),
|
||||
outlineWidth: ring.outlineWidth,
|
||||
});
|
||||
ringPrimitive =
|
||||
scene.primitives.add(
|
||||
createRingPrimitive(ringPositions, ringMaterial),
|
||||
) ?? null;
|
||||
}
|
||||
if (!ringPrimitive) {
|
||||
// Headless tools and partial stubs have no primitive collection; they
|
||||
// get the static ring as an entity.
|
||||
ringEntity = layerState.viewer.entities.add({
|
||||
polyline: {
|
||||
positions: new Cesium.ConstantProperty(ringPositions),
|
||||
width: ring.width,
|
||||
arcType: Cesium.ArcType.NONE,
|
||||
material: new Cesium.PolylineOutlineMaterialProperty({
|
||||
color: core,
|
||||
outlineColor: halo,
|
||||
outlineWidth: ring.outlineWidth,
|
||||
}),
|
||||
},
|
||||
});
|
||||
entities.push(ringEntity);
|
||||
}
|
||||
|
||||
const center = Cesium.Cartesian3.clone(groundCenter);
|
||||
let gradient = null;
|
||||
let stopPulses = null;
|
||||
if (animate) {
|
||||
const pulse = AWARENESS_RANGE_PULSE;
|
||||
const count = Math.min(pulse.count, AWARENESS_PULSE_GRADIENT_MAX);
|
||||
const uniforms = {
|
||||
color: core.clone(),
|
||||
lineColor: core.clone(),
|
||||
haloColor: halo.clone(),
|
||||
radii: new Cesium.Cartesian4(),
|
||||
alphas: new Cesium.Cartesian4(),
|
||||
depth: pulse.gradientDepthFraction,
|
||||
lineWidth: pulse.width,
|
||||
haloWidth: pulse.outlineWidth,
|
||||
};
|
||||
uniforms.color.alpha = pulse.gradientOpacity;
|
||||
// A named type: Cesium caches every material type for the page's
|
||||
// lifetime, so an unnamed fabric (a fresh GUID each time) would leave
|
||||
// one entry per new visual. Built from the cached template, the
|
||||
// uniforms come back as plain copies; restore this visual's own.
|
||||
const material = new Cesium.Material({
|
||||
fabric: {
|
||||
type: AWARENESS_PULSE_MATERIAL_TYPE,
|
||||
uniforms,
|
||||
source: PULSE_GRADIENT_SOURCE,
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
Object.assign(material.uniforms, uniforms);
|
||||
gradient = scene.primitives.add(
|
||||
createPulseGradient(groundCenter, material),
|
||||
);
|
||||
const radii = [0, 0, 0, 0];
|
||||
const alphas = [0, 0, 0, 0];
|
||||
const removePreRender = scene.preRender.addEventListener(() => {
|
||||
const nowMs = performance.now();
|
||||
const level = levelAt(nowMs);
|
||||
const alpha = ringAlpha(nowMs);
|
||||
ringMaterial.uniforms.color.alpha = core.alpha * alpha;
|
||||
ringMaterial.uniforms.outlineColor.alpha = halo.alpha * alpha;
|
||||
// The disc is looked up each frame: a subject move swaps it.
|
||||
const disc = layerState.visual?.gradient;
|
||||
if (disc) disc.show = level > 0;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const state = awarenessPulseState(nowMs, index);
|
||||
radii[index] = state.radiusM / AWARENESS_RADIUS_M;
|
||||
alphas[index] = state.alpha * level;
|
||||
}
|
||||
Cesium.Cartesian4.fromArray(radii, 0, material.uniforms.radii);
|
||||
Cesium.Cartesian4.fromArray(alphas, 0, material.uniforms.alphas);
|
||||
});
|
||||
// Each drawn frame, whoever asked for it, arms one request a Cyber
|
||||
// sonar interval later, as the Cyber sonar scene does. Requests never
|
||||
// land within 80 ms of a frame, so they cannot beat the detection
|
||||
// lane's 80 ms gate, and they stop once the pulses rest.
|
||||
let timer = null;
|
||||
const removePostRender = scene.postRender.addEventListener(() => {
|
||||
globalThis.clearTimeout(timer);
|
||||
timer = null;
|
||||
if (performance.now() < wake.untilMs && !belowRim())
|
||||
timer = globalThis.setTimeout(() => {
|
||||
timer = null;
|
||||
governorRequestRender('awareness-pulse');
|
||||
}, CYBER_SONAR_RENDER_INTERVAL_MS);
|
||||
});
|
||||
stopPulses = () => {
|
||||
removePreRender();
|
||||
removePostRender();
|
||||
globalThis.clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
// The detection overlay pulses the brackets of flights and vessels a
|
||||
// front crosses; it reads the centre in place, so it follows too.
|
||||
setAwarenessEchoField({
|
||||
center,
|
||||
radiusM: AWARENESS_RADIUS_M,
|
||||
pulse,
|
||||
echo: AWARENESS_RANGE_ECHO,
|
||||
level: levelAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Turning reduced motion on or off mid-session rebuilds the visual.
|
||||
const motionQuery = globalThis.matchMedia?.(REDUCED_MOTION_QUERY);
|
||||
const onMotionChange = () => {
|
||||
if (layerState.subject) renderVisual(layerState.subject);
|
||||
};
|
||||
// Safari before 14 has only the older addListener / removeListener.
|
||||
if (motionQuery?.addEventListener)
|
||||
motionQuery.addEventListener('change', onMotionChange);
|
||||
else motionQuery?.addListener?.(onMotionChange);
|
||||
|
||||
layerState.visual = {
|
||||
key,
|
||||
entities,
|
||||
center: Cesium.Cartesian3.clone(groundCenter),
|
||||
ring: ringEntity,
|
||||
ringPrimitive,
|
||||
ringPositions,
|
||||
center,
|
||||
gradient,
|
||||
gradientCenter: Cesium.Cartesian3.clone(groundCenter),
|
||||
reducedMotion,
|
||||
wake,
|
||||
stopPulses,
|
||||
removeMotionListener: () => {
|
||||
if (motionQuery?.removeEventListener)
|
||||
motionQuery.removeEventListener('change', onMotionChange);
|
||||
else motionQuery?.removeListener?.(onMotionChange);
|
||||
},
|
||||
};
|
||||
governorRequestRender('awareness-visual');
|
||||
}
|
||||
@@ -343,5 +914,6 @@ export function createRendering({
|
||||
updateDirectionOverlay,
|
||||
clearVisual,
|
||||
renderVisual,
|
||||
wakePulses,
|
||||
};
|
||||
}
|
||||
@@ -59,6 +59,10 @@ export function createState({ services }) {
|
||||
lastCameraPoseSig: '',
|
||||
/** When the pose signature last changed bins (hysteresis anchor). */
|
||||
lastCameraPoseChangeMs: 0,
|
||||
/** When the viewer last handled the camera (pointer, wheel, touch, key). */
|
||||
lastCameraInputMs: Number.NEGATIVE_INFINITY,
|
||||
/** Element the camera-input listeners are attached to. */
|
||||
cameraInputTarget: null,
|
||||
/** Whether the view currently counts as moving (hysteretic, not per-frame). */
|
||||
cameraMoving: false,
|
||||
lastEvaluatedPosition: null,
|
||||
|
||||
@@ -12,20 +12,28 @@
|
||||
* Cameras without an identified model are untouched — unknown never implies
|
||||
* anything.
|
||||
*
|
||||
* Spec records are vendored verbatim from the CCTV Camera Database
|
||||
* Specification fields are vendored from the CCTV Camera Database
|
||||
* (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset
|
||||
* of 28,000+ camera models transcribed from manufacturer datasheets (84% with
|
||||
* FOV, as of v2.26.0). This module vendors the records that the currently integrated public
|
||||
* of 29,000+ camera models transcribed from manufacturer datasheets (76% with
|
||||
* FOV, as of v2.29.0). This module vendors records that the integrated public
|
||||
* catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis
|
||||
* Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that
|
||||
* appear in Sarasota County FL and Sioux Falls SD open data; packs that
|
||||
* appear in Sarasota County FL and Sioux Falls SD open data, plus the Bosch MIC
|
||||
* PTZs for the City of Amsterdam camera register proposed in #251; packs that
|
||||
* publish other hardware models can grow it, or vendor the full extract.
|
||||
*
|
||||
* Scope guardrail: records describe camera PRODUCTS (optics, resolution,
|
||||
* night vision) — never endpoints, stream paths, or access/config data.
|
||||
*/
|
||||
|
||||
/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */
|
||||
/**
|
||||
* Records from the dataset's model-enrichment extract: v2.26.0, plus the Bosch
|
||||
* MIC records from v2.29.0 with titles shortened to model codes.
|
||||
* A Bosch MIC order code ends in a colour letter (B, G, W; registries write
|
||||
* `x`) on one shared datasheet. The colours are aliases of one record whose
|
||||
* `model` leaves the letter out, so the HUD never names a variant the source
|
||||
* did not.
|
||||
*/
|
||||
const RECORDS = [
|
||||
{
|
||||
id: 'axis-q6135-le',
|
||||
@@ -264,6 +272,67 @@ const RECORDS = [
|
||||
autotracking: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-mic-7130-pb4',
|
||||
brand: 'Bosch',
|
||||
model: 'MIC-7130-P_4',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-mic-7130-pb4/',
|
||||
aliases: [
|
||||
'MIC IP starlight 7000 HD',
|
||||
'MIC-7130-PB4',
|
||||
'MIC-7130-PG4',
|
||||
'MIC-7130-PW4',
|
||||
'MIC-7130-Px4',
|
||||
],
|
||||
fov_deg: '2.1-59',
|
||||
resolution: { mp: 1, w: 1280, h: 720, label: '720p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-mic-7502-z30b',
|
||||
brand: 'Bosch',
|
||||
model: 'MIC-7502-Z30',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-mic-7502-z30b/',
|
||||
aliases: [
|
||||
'MIC-7502-Z30B',
|
||||
'MIC-7502-Z30G',
|
||||
'MIC-7502-Z30W',
|
||||
'MIC-7502-Z30x',
|
||||
],
|
||||
fov_deg: '2.3-63.7',
|
||||
resolution: { mp: 2.13, w: 1920, h: 1080, label: '1080p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
night_vision: { type: 'color' },
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-mic-7522-z30b',
|
||||
brand: 'Bosch',
|
||||
model: 'MIC-7522-Z30',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-mic-7522-z30b/',
|
||||
aliases: [
|
||||
'MIC-7522-Z30B',
|
||||
'MIC-7522-Z30G',
|
||||
'MIC-7522-Z30W',
|
||||
'MIC-7522-Z30x',
|
||||
],
|
||||
fov_deg: '2.1-58.3',
|
||||
// i18n-ignore: datasheet label
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
|
||||
ptz: true,
|
||||
varifocal: true,
|
||||
night_vision: { type: 'color' },
|
||||
environment: ['outdoor'],
|
||||
ndaa_compliant: true,
|
||||
},
|
||||
{
|
||||
id: 'cohu-3920-idome',
|
||||
brand: 'Cohu',
|
||||
@@ -431,7 +500,7 @@ const NAME_INDEX = RECORDS.flatMap((record) => {
|
||||
* guesses).
|
||||
* @param {string} model - Hardware model as named by a source pack or seed.
|
||||
* @param {string} [manufacturer] - The source's manufacturer, when published.
|
||||
* @returns {Object|null} The verbatim spec record, or null when unknown.
|
||||
* @returns {Object|null} The matched spec record, or null when unknown.
|
||||
*/
|
||||
export function lookupModelSpec(model, manufacturer) {
|
||||
const query = words(model).map(stripVariantSuffix);
|
||||
|
||||
@@ -245,6 +245,95 @@ test('King County snapshot model strings still resolve', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('Amsterdam camera register model strings resolve', () => {
|
||||
// The register writes the Bosch MIC colour letter as `x`.
|
||||
assert.equal(
|
||||
lookupModelSpec('Bosch MIC-7522-Z30x')?.id,
|
||||
'bosch-mic-7522-z30b',
|
||||
);
|
||||
assert.equal(
|
||||
lookupModelSpec('Bosch MIC-7502-Z30x')?.id,
|
||||
'bosch-mic-7502-z30b',
|
||||
);
|
||||
assert.equal(lookupModelSpec('Bosch MIC-7130-Px4')?.id, 'bosch-mic-7130-pb4');
|
||||
assert.equal(lookupModelSpec('MIC-7522-Z30W')?.id, 'bosch-mic-7522-z30b');
|
||||
// Its ANPR units are not in the table: unknown, never a guess.
|
||||
assert.equal(lookupModelSpec('CSG-8800'), null);
|
||||
// The MIC and AUTODOME starlight 7000 HD (code-less names, so the brand
|
||||
// is needed) stay apart.
|
||||
assert.equal(
|
||||
lookupModelSpec('MIC IP starlight 7000 HD', 'Bosch')?.id,
|
||||
'bosch-mic-7130-pb4',
|
||||
);
|
||||
assert.equal(
|
||||
lookupModelSpec('AUTODOME IP starlight 7000 HD', 'Bosch')?.id,
|
||||
'bosch-autodome-ip-starlight-7000-hd',
|
||||
);
|
||||
// The MIC datasheets state FOV without an axis, so no horizontal range.
|
||||
assert.equal(horizontalFovRangeDeg(lookupModelSpec('MIC-7522-Z30B')), null);
|
||||
});
|
||||
|
||||
test('Bosch MIC colour aliases resolve without accepting unknown variants', () => {
|
||||
for (const [pattern, id] of [
|
||||
['MIC-7130-P?4', 'bosch-mic-7130-pb4'],
|
||||
['MIC-7502-Z30?', 'bosch-mic-7502-z30b'],
|
||||
['MIC-7522-Z30?', 'bosch-mic-7522-z30b'],
|
||||
]) {
|
||||
for (const colour of ['B', 'G', 'W', 'x']) {
|
||||
const model = pattern.replace('?', colour);
|
||||
assert.equal(lookupModelSpec(model)?.id, id, model);
|
||||
assert.equal(lookupModelSpec(`bosch ${model.toLowerCase()}`)?.id, id);
|
||||
assert.equal(lookupModelSpec(model, 'Bosch')?.id, id);
|
||||
assert.equal(lookupModelSpec(model, 'Axis'), null);
|
||||
assert.equal(lookupModelSpec(`${model}-UNKNOWN`), null);
|
||||
}
|
||||
assert.equal(lookupModelSpec(pattern.replace('?', 'R')), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('Bosch MIC HUD summary never names a colour the source did not', () => {
|
||||
assert.equal(
|
||||
specSummary(lookupModelSpec('MIC-7522-Z30W')),
|
||||
'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ',
|
||||
);
|
||||
assert.equal(
|
||||
specSummary(lookupModelSpec('Bosch MIC-7522-Z30x')),
|
||||
'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ',
|
||||
);
|
||||
for (const pattern of ['MIC-7130-P?4', 'MIC-7502-Z30?', 'MIC-7522-Z30?']) {
|
||||
for (const colour of ['B', 'G', 'W', 'x']) {
|
||||
const spec = lookupModelSpec(pattern.replace('?', colour));
|
||||
const summary = specSummary(spec);
|
||||
for (const other of ['B', 'G', 'W', 'x']) {
|
||||
assert.ok(!summary.includes(pattern.replace('?', other)), summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Bosch MIC catalog enrichment preserves estimated and explicit optics', () => {
|
||||
for (const model of [
|
||||
'Bosch MIC-7130-Px4',
|
||||
'Bosch MIC-7502-Z30x',
|
||||
'Bosch MIC-7522-Z30x',
|
||||
]) {
|
||||
for (const pose of [{}, { fovDeg: 44 }, { poseSource: 'curated' }]) {
|
||||
const [camera] = browserCatalog().buildCatalogFromSources([
|
||||
{ id: 'amsterdam-mic', lat: 52.37, lon: 4.9, model, ...pose },
|
||||
]);
|
||||
assert.ok(camera.spec, model);
|
||||
assert.equal(camera.fovDeg, pose.fovDeg ?? 74, model);
|
||||
assert.equal(camera.fovSource, undefined);
|
||||
assert.equal(camera.specFovMinDeg, undefined);
|
||||
assert.equal(camera.specFovMaxDeg, undefined);
|
||||
assert.equal(horizontalFovRangeDeg(camera.spec), null);
|
||||
assert.equal(fixedHorizontalFovDeg(camera.spec), null);
|
||||
assert.equal(specFovCapabilityToken(camera), null);
|
||||
assert.match(specSummary(camera.spec), /^Bosch MIC-.* · PTZ$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('catalog never overwrites an explicit FOV or a curated pose', () => {
|
||||
const [explicit, curated, cohu, pelco] =
|
||||
browserCatalog().buildCatalogFromSources([
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/** Card model for one selected special-use airspace zone. Pure — no Cesium types. */
|
||||
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const DRONE_AIRSPACE_OVERLAY_SOURCE_ID = 'drone-airspace';
|
||||
|
||||
/** Fill/line CSS color for a zone by TYPE_CODE. */
|
||||
export function droneAirspaceAccent(typeCode) {
|
||||
return typeCode === 'P' ? '#ff3b30' : '#ff7a00';
|
||||
}
|
||||
|
||||
/** Shoelace area (degree², sign dropped) — relative sizes only. */
|
||||
function ringArea(ring) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < ring.length - 1; i++) {
|
||||
const [x1, y1] = ring[i];
|
||||
const [x2, y2] = ring[i + 1];
|
||||
sum += x1 * y2 - x2 * y1;
|
||||
}
|
||||
return Math.abs(sum / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor point for the zone card: the centroid of the largest polygon's
|
||||
* outer ring. Degrees, so callers own the Cartesian conversion.
|
||||
* @param {Array<Array<Array<[number, number]>>>} polygons
|
||||
* @returns {{lon: number, lat: number}}
|
||||
*/
|
||||
export function droneAirspaceAnchorDegrees(polygons) {
|
||||
let best = null;
|
||||
let bestArea = -1;
|
||||
for (const rings of polygons) {
|
||||
const outer = rings[0];
|
||||
const area = ringArea(outer);
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = outer;
|
||||
}
|
||||
}
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
const count = best.length - 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
lon += best[i][0];
|
||||
lat += best[i][1];
|
||||
}
|
||||
return { lon: lon / count, lat: lat / count };
|
||||
}
|
||||
|
||||
function altitudeLine(row) {
|
||||
const lower = row.lowerAltitude;
|
||||
const upper = row.upperAltitude;
|
||||
if (lower && upper) return t('{lower} to {upper}', { lower, upper });
|
||||
if (upper) return t('Up to {upper}', { upper });
|
||||
if (lower) return t('From {lower}', { lower });
|
||||
return null;
|
||||
}
|
||||
|
||||
function placeLine(row) {
|
||||
if (row.city && row.state) return `${row.city}, ${row.state}`;
|
||||
return row.city || row.state || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the overlay-host entry for one selected zone. The caller supplies
|
||||
* `position` (Cartesian) separately — this model stays JSON-safe for tests.
|
||||
* @param {Object} row - Normalized zone row (see records.js).
|
||||
* @returns {Object} World-overlay entry without `position`.
|
||||
*/
|
||||
export function buildDroneAirspaceCard(row) {
|
||||
const facts = [row.type ? t(row.type) : row.typeCode].filter(Boolean);
|
||||
const altitude = altitudeLine(row);
|
||||
if (altitude) facts.push(altitude);
|
||||
if (row.timesOfUse) facts.push(row.timesOfUse);
|
||||
|
||||
const details = [facts.join(' · ')];
|
||||
const place = placeLine(row);
|
||||
if (place) details.push(place);
|
||||
if (row.agency) details.push(row.agency);
|
||||
details.push(t('Informational only · not for flight planning'));
|
||||
|
||||
const type = row.type ? t(row.type) : 'SUA';
|
||||
const title = `${type.toUpperCase()} · ${row.name || t('Unnamed zone')}`;
|
||||
return {
|
||||
id: `drone-airspace-card:${row.stableId}`,
|
||||
selected: true,
|
||||
interactive: false,
|
||||
title,
|
||||
details,
|
||||
accent: droneAirspaceAccent(row.typeCode),
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
droneAirspaceAnchorDegrees,
|
||||
droneAirspaceAccent,
|
||||
buildDroneAirspaceCard,
|
||||
} from './cards.js';
|
||||
|
||||
const square = [
|
||||
[-77.2, 38.8],
|
||||
[-77.0, 38.8],
|
||||
[-77.0, 39.0],
|
||||
[-77.2, 39.0],
|
||||
[-77.2, 38.8],
|
||||
];
|
||||
const tiny = [
|
||||
[-76.01, 38.0],
|
||||
[-76.0, 38.0],
|
||||
[-76.0, 38.01],
|
||||
[-76.01, 38.0],
|
||||
];
|
||||
|
||||
test('the card anchors at the centroid of the largest polygon', () => {
|
||||
const anchor = droneAirspaceAnchorDegrees([[tiny], [square]]);
|
||||
assert.ok(Math.abs(anchor.lon - -77.1) < 1e-9);
|
||||
assert.ok(Math.abs(anchor.lat - 38.9) < 1e-9);
|
||||
});
|
||||
|
||||
test('a prohibited zone card names the type, altitudes and the disclaimer', () => {
|
||||
const card = buildDroneAirspaceCard({
|
||||
stableId: '310',
|
||||
name: 'P-56',
|
||||
typeCode: 'P',
|
||||
type: 'Prohibited',
|
||||
lowerAltitude: 'SFC',
|
||||
upperAltitude: '18,000 ft MSL',
|
||||
timesOfUse: 'CONTINUOUS',
|
||||
agency: 'WASHINGTON CENTER',
|
||||
city: 'WASHINGTON',
|
||||
state: 'DC',
|
||||
});
|
||||
assert.equal(card.id, 'drone-airspace-card:310');
|
||||
assert.equal(card.title, 'PROHIBITED · P-56');
|
||||
assert.deepEqual(card.details, [
|
||||
'Prohibited · SFC to 18,000 ft MSL · CONTINUOUS',
|
||||
'WASHINGTON, DC',
|
||||
'WASHINGTON CENTER',
|
||||
'Informational only · not for flight planning',
|
||||
]);
|
||||
assert.equal(card.accent, droneAirspaceAccent('P'));
|
||||
assert.equal(card.interactive, false);
|
||||
assert.equal(card.selected, true);
|
||||
});
|
||||
|
||||
test('missing attributes degrade to available facts instead of placeholders', () => {
|
||||
const card = buildDroneAirspaceCard({
|
||||
stableId: 'x',
|
||||
name: null,
|
||||
typeCode: 'R',
|
||||
type: 'Restricted',
|
||||
lowerAltitude: null,
|
||||
upperAltitude: null,
|
||||
timesOfUse: null,
|
||||
agency: null,
|
||||
city: null,
|
||||
state: null,
|
||||
});
|
||||
assert.equal(card.title, 'RESTRICTED · Unnamed zone');
|
||||
assert.deepEqual(card.details, [
|
||||
'Restricted',
|
||||
'Informational only · not for flight planning',
|
||||
]);
|
||||
assert.equal(card.accent, droneAirspaceAccent('R'));
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { VOICE_OFF_LAYERS } from '../../voice/layerManifest.js';
|
||||
import { ANALYST_LAYERS } from '../../data/analystEngine.js';
|
||||
|
||||
const read = (path) =>
|
||||
readFileSync(new URL(`../../../${path}`, import.meta.url), 'utf8');
|
||||
const currentState = read('docs/CURRENT-STATE.md');
|
||||
const section = currentState.slice(
|
||||
currentState.indexOf('## Prohibited & Restricted Airspace'),
|
||||
currentState.indexOf('\n## ', currentState.indexOf('## Prohibited')) + 1,
|
||||
);
|
||||
const tokens = JSON.parse(read('src/data/layerStateTokenReservations.json'));
|
||||
|
||||
test('CURRENT-STATE names the reserved share-link token', () => {
|
||||
const token = JSON.stringify(tokens).match(
|
||||
/\["drone-airspace","([^"]+)"\]/,
|
||||
)?.[1];
|
||||
assert.ok(token, 'drone-airspace has a reserved token');
|
||||
assert.match(section, new RegExp(`share-link token \`${token}\``));
|
||||
});
|
||||
|
||||
test('CURRENT-STATE matches the layer being outside voice and the analyst', () => {
|
||||
assert.ok(Object.hasOwn(VOICE_OFF_LAYERS, 'drone-airspace'));
|
||||
assert.ok(!Object.hasOwn(ANALYST_LAYERS, 'drone-airspace'));
|
||||
assert.doesNotMatch(section, /Voice can toggle/);
|
||||
assert.match(section, /Voice is off for this layer for now/);
|
||||
const analyst = currentState.match(/Analyst records come on demand[^\n]*/)[0];
|
||||
assert.doesNotMatch(analyst, /drone/i);
|
||||
});
|
||||
|
||||
test('CURRENT-STATE states what the layer does not cover', () => {
|
||||
assert.match(section, /UAS Facility Map/);
|
||||
assert.match(section, /LAANC/);
|
||||
assert.match(section, /TFRs/);
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import {
|
||||
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
|
||||
droneAirspaceAnchorDegrees,
|
||||
buildDroneAirspaceCard,
|
||||
droneAirspaceAccent,
|
||||
} from './cards.js';
|
||||
export { normalizeDroneAirspaceSnapshot } from './records.js';
|
||||
export { createDroneAirspaceSource } from './source.js';
|
||||
export * from './cards.js';
|
||||
|
||||
/** Fill/line color for a zone by TYPE_CODE. */
|
||||
export function droneAirspaceColor(typeCode) {
|
||||
return Cesium.Color.fromCssColorString(droneAirspaceAccent(typeCode));
|
||||
}
|
||||
|
||||
const ringPositions = (ring) =>
|
||||
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
|
||||
|
||||
/**
|
||||
* FNV-1a over a zone's rings: a short, deterministic fingerprint so a
|
||||
* boundary change with unchanged attributes still alters the refresh signature.
|
||||
*/
|
||||
function geometryHash(polygons) {
|
||||
const text = JSON.stringify(polygons);
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
const PICK_PREFIX = 'drone-airspace:';
|
||||
const CARD_HOST_OPTIONS = Object.freeze({
|
||||
cohortLimit: 1,
|
||||
collisionCapacity: 1,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
/** Own one drone-restriction display, its refresh lifecycle, and click selection. */
|
||||
export function createDroneAirspaceLayer({
|
||||
source,
|
||||
overlayHost = null,
|
||||
screenSpaceEventHandlerFactory = null,
|
||||
picking = null,
|
||||
pointer = null,
|
||||
} = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('Drone airspace requires a snapshot source');
|
||||
let _viewer = null;
|
||||
let _request = null;
|
||||
let _snapshotSignature = null;
|
||||
let _dataSource = null;
|
||||
let _count = 0;
|
||||
let _lastUpdate = null;
|
||||
let _lastError = null;
|
||||
let _stale = false;
|
||||
let _enabled = false;
|
||||
let _clickHandler = null;
|
||||
let _selectedId = null;
|
||||
let _selectedCardId = null;
|
||||
/** @type {Map<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
|
||||
const _rowById = new Map();
|
||||
|
||||
const canSelect = () =>
|
||||
overlayHost && screenSpaceEventHandlerFactory && picking;
|
||||
|
||||
function publishSelectedCard() {
|
||||
if (!canSelect()) return;
|
||||
const row = _selectedId ? _rowById.get(_selectedId) : null;
|
||||
if (!row) {
|
||||
_selectedId = null;
|
||||
_selectedCardId = null;
|
||||
overlayHost.setEntries(
|
||||
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
|
||||
[],
|
||||
CARD_HOST_OPTIONS,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const card = {
|
||||
...buildDroneAirspaceCard(row),
|
||||
position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat),
|
||||
};
|
||||
_selectedCardId = card.id;
|
||||
overlayHost.setEntries(
|
||||
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
|
||||
[card],
|
||||
CARD_HOST_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a scene pick to one of this layer's zone ids, or null. */
|
||||
function pickedZoneId(picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||
return null;
|
||||
const zoneId = pickId.slice(PICK_PREFIX.length).split(':')[0];
|
||||
return _rowById.has(zoneId) ? zoneId : null;
|
||||
}
|
||||
|
||||
function installClickHandler() {
|
||||
if (!canSelect() || _clickHandler || !_viewer) return;
|
||||
// Deliberately NOT registered in the pick-ownership registry: sibling
|
||||
// layers yield to owned picks before their own overlay-card hit-tests,
|
||||
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
|
||||
// cards inert anywhere over a zone. Unowned, our picks read as
|
||||
// "empty space" to siblings — the same contract as fire perimeters.
|
||||
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
|
||||
_clickHandler.setInputAction((click) => {
|
||||
if (pointer && !pointer.isPointerFree()) return;
|
||||
const cardHit = overlayHost.hitTest?.(
|
||||
click.position?.x,
|
||||
click.position?.y,
|
||||
{ sourceId: DRONE_AIRSPACE_OVERLAY_SOURCE_ID },
|
||||
);
|
||||
if (cardHit && cardHit.entryId === _selectedCardId) return;
|
||||
const picked = _viewer.scene.pick(click.position);
|
||||
const zoneId = picked ? pickedZoneId(picked) : null;
|
||||
if (zoneId) {
|
||||
_selectedId = zoneId;
|
||||
publishSelectedCard();
|
||||
return;
|
||||
}
|
||||
if (picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return;
|
||||
}
|
||||
if (_selectedId) {
|
||||
_selectedId = null;
|
||||
publishSelectedCard();
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function removeClickHandler() {
|
||||
if (_clickHandler) {
|
||||
_clickHandler.destroy();
|
||||
_clickHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
_selectedId = null;
|
||||
_selectedCardId = null;
|
||||
if (overlayHost) {
|
||||
overlayHost.clearSource(DRONE_AIRSPACE_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, false);
|
||||
}
|
||||
}
|
||||
|
||||
const layer = {
|
||||
id: 'drone-airspace',
|
||||
name: 'Prohibited & Restricted Airspace',
|
||||
icon: '🚫',
|
||||
source: 'FAA SUA',
|
||||
updateInterval: 3600000,
|
||||
|
||||
init(viewer) {
|
||||
if (_viewer)
|
||||
throw new Error('Drone airspace layer is already initialized');
|
||||
_viewer = viewer;
|
||||
_dataSource = new Cesium.CustomDataSource('drone-airspace');
|
||||
_dataSource.show = false;
|
||||
viewer.dataSources.add(_dataSource);
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
_enabled = false;
|
||||
console.log('[Data:DroneAirspace] Initialized');
|
||||
},
|
||||
|
||||
enable() {
|
||||
_enabled = true;
|
||||
if (_dataSource) _dataSource.show = true;
|
||||
overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true);
|
||||
installClickHandler();
|
||||
},
|
||||
|
||||
disable() {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
_enabled = false;
|
||||
if (_dataSource) _dataSource.show = false;
|
||||
removeClickHandler();
|
||||
clearSelection();
|
||||
},
|
||||
|
||||
async update() {
|
||||
if (!_enabled || !_dataSource) return false;
|
||||
_request?.abort();
|
||||
const request = new AbortController();
|
||||
_request = request;
|
||||
try {
|
||||
// The source contract is getSnapshot; a source that can also say how
|
||||
// fresh its rows are (the FAA proxy) offers getSnapshotDetail.
|
||||
const snapshot =
|
||||
typeof source.getSnapshotDetail === 'function'
|
||||
? await source.getSnapshotDetail({ signal: request.signal })
|
||||
: { rows: await source.getSnapshot({ signal: request.signal }) };
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
const { rows } = snapshot;
|
||||
_stale = snapshot.stale === true;
|
||||
// A stale snapshot is as old as its fetch, not this poll.
|
||||
const updatedAt =
|
||||
_stale && Number.isFinite(snapshot.fetchedAt)
|
||||
? snapshot.fetchedAt
|
||||
: Date.now();
|
||||
|
||||
// FAA amends boundaries in its 8-week cycle without any attribute
|
||||
// changing, so the geometry is part of the signature.
|
||||
const signature = JSON.stringify(
|
||||
rows
|
||||
.map(({ polygons, ...facts }) => ({
|
||||
...facts,
|
||||
geometry: geometryHash(polygons),
|
||||
}))
|
||||
.sort((a, b) => a.stableId.localeCompare(b.stableId)),
|
||||
);
|
||||
if (signature === _snapshotSignature) {
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_lastUpdate = updatedAt;
|
||||
_lastError = null;
|
||||
return true;
|
||||
}
|
||||
const nextEntities = [];
|
||||
_rowById.clear();
|
||||
for (const row of rows) {
|
||||
const color = droneAirspaceColor(row.typeCode);
|
||||
for (const [index, rings] of row.polygons.entries()) {
|
||||
const [outer, ...holes] = rings;
|
||||
const outerPositions = ringPositions(outer);
|
||||
nextEntities.push(
|
||||
new Cesium.Entity({
|
||||
id: `drone-airspace:${row.stableId}:${index}`,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
outerPositions,
|
||||
holes.map(
|
||||
(hole) =>
|
||||
new Cesium.PolygonHierarchy(ringPositions(hole)),
|
||||
),
|
||||
),
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(0.25),
|
||||
),
|
||||
},
|
||||
polyline: {
|
||||
positions: outerPositions,
|
||||
clampToGround: true,
|
||||
width: 2,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(0.9),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
const { polygons, ...facts } = row;
|
||||
_rowById.set(row.stableId, {
|
||||
...facts,
|
||||
anchor: droneAirspaceAnchorDegrees(polygons),
|
||||
});
|
||||
}
|
||||
|
||||
_dataSource.entities.removeAll();
|
||||
for (const entity of nextEntities) _dataSource.entities.add(entity);
|
||||
_snapshotSignature = signature;
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_count = rows.length;
|
||||
_lastUpdate = updatedAt;
|
||||
_lastError = null;
|
||||
console.log(
|
||||
`[Data:DroneAirspace] Updated: ${_count} zones, ${nextEntities.length} polygons`,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
console.warn('[Data:DroneAirspace] Fetch error:', e);
|
||||
_lastError =
|
||||
e?.message || msg('Restricted airspace source unavailable');
|
||||
// A failure now outranks the stale snapshot still drawn.
|
||||
_stale = false;
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer = _viewer) {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
removeClickHandler();
|
||||
clearSelection();
|
||||
_rowById.clear();
|
||||
_snapshotSignature = null;
|
||||
_viewer = null;
|
||||
_enabled = false;
|
||||
if (_dataSource) {
|
||||
viewer.dataSources.remove(_dataSource, true);
|
||||
_dataSource = null;
|
||||
}
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
},
|
||||
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (!_dataSource || !_dataSource.show) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
const result = [];
|
||||
for (const row of _rowById.values()) {
|
||||
if (result.length >= limit) break;
|
||||
const { anchor, stableId, ...facts } = row;
|
||||
result.push({
|
||||
id: stableId,
|
||||
...facts,
|
||||
lat: anchor.lat,
|
||||
lon: anchor.lon,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getRowControls() {
|
||||
const bands = [
|
||||
{ label: t('Prohibited (P)'), color: droneAirspaceAccent('P') },
|
||||
{ label: t('Restricted (R)'), color: droneAirspaceAccent('R') },
|
||||
];
|
||||
return {
|
||||
chips: [],
|
||||
legend: bands.map((band, index) => ({
|
||||
...band,
|
||||
count: [..._rowById.values()].filter(
|
||||
(row) => droneAirspaceAccent(row.typeCode) === band.color,
|
||||
).length,
|
||||
...(index === 0
|
||||
? {
|
||||
blurb: t(
|
||||
'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).',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: _count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
stale: _stale,
|
||||
};
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createDroneAirspaceLayer } from './index.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
function harness(source, { pick = () => null, cardHit = () => null } = {}) {
|
||||
const sources = [];
|
||||
const overlay = { entries: new Map(), visible: null };
|
||||
const clicks = { handler: null, destroyed: 0 };
|
||||
const owners = new Map();
|
||||
const viewer = {
|
||||
scene: { pick },
|
||||
dataSources: {
|
||||
add(value) {
|
||||
sources.push(value);
|
||||
},
|
||||
remove(value) {
|
||||
sources.splice(sources.indexOf(value), 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
const layer = createDroneAirspaceLayer({
|
||||
source,
|
||||
overlayHost: {
|
||||
setEntries(sourceId, entries) {
|
||||
overlay.entries.set(sourceId, entries);
|
||||
},
|
||||
setVisible(sourceId, visible) {
|
||||
overlay.visible = visible;
|
||||
},
|
||||
clearSource(sourceId) {
|
||||
overlay.entries.delete(sourceId);
|
||||
},
|
||||
hitTest: (x, y, options) => cardHit(x, y, options),
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction(callback) {
|
||||
clicks.handler = callback;
|
||||
},
|
||||
destroy() {
|
||||
clicks.destroyed += 1;
|
||||
clicks.handler = null;
|
||||
},
|
||||
}),
|
||||
picking: {
|
||||
resolvePickId: (picked) => picked?.id ?? null,
|
||||
isOwnedByOtherLayer: (layerId, pickedId) =>
|
||||
String(pickedId).startsWith('other-layer:'),
|
||||
registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate),
|
||||
unregisterPickOwner: (layerId) => owners.delete(layerId),
|
||||
},
|
||||
pointer: { isPointerFree: () => true },
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
return { layer, viewer, sources, overlay, clicks, owners };
|
||||
}
|
||||
|
||||
const ring = [
|
||||
[-77.04, 38.89],
|
||||
[-77.03, 38.89],
|
||||
[-77.03, 38.9],
|
||||
[-77.04, 38.89],
|
||||
];
|
||||
const row = {
|
||||
stableId: '310',
|
||||
name: 'P-56',
|
||||
typeCode: 'P',
|
||||
type: 'Prohibited',
|
||||
lowerAltitude: 'SFC',
|
||||
upperAltitude: '18,000 ft MSL',
|
||||
timesOfUse: 'CONTINUOUS',
|
||||
agency: null,
|
||||
city: 'WASHINGTON',
|
||||
state: 'DC',
|
||||
polygons: [[ring]],
|
||||
};
|
||||
|
||||
test('each zone polygon renders as one filled entity with its outline', async () => {
|
||||
const h = harness({
|
||||
getSnapshot: async () => [
|
||||
row,
|
||||
{ ...row, stableId: '400', typeCode: 'R', polygons: [[ring], [ring]] },
|
||||
],
|
||||
});
|
||||
assert.equal(await h.layer.update(h.viewer), true);
|
||||
const entities = h.sources[0].entities.values;
|
||||
assert.equal(entities.length, 3);
|
||||
assert.equal(entities[0].id, 'drone-airspace:310:0');
|
||||
assert.ok(entities[0].polygon, 'zone fill missing');
|
||||
assert.ok(entities[0].polyline, 'zone outline missing');
|
||||
assert.equal(h.layer.getStats().count, 2);
|
||||
});
|
||||
|
||||
test('clicking a zone publishes its card; empty space clears it', async () => {
|
||||
let pickResult = null;
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{ pick: () => pickResult },
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
|
||||
assert.equal(h.owners.size, 0);
|
||||
|
||||
pickResult = { id: 'drone-airspace:310:0' };
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
const entries = h.overlay.entries.get('drone-airspace');
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].id, 'drone-airspace-card:310');
|
||||
assert.equal(entries[0].title, 'PROHIBITED · P-56');
|
||||
assert.ok(entries[0].position, 'card must carry a world anchor');
|
||||
|
||||
pickResult = { id: 'other-layer:aircraft-1' };
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
assert.equal(
|
||||
h.overlay.entries.get('drone-airspace')?.length,
|
||||
1,
|
||||
'sibling-layer picks must not clear the selection',
|
||||
);
|
||||
|
||||
pickResult = null;
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
assert.equal(h.overlay.entries.get('drone-airspace')?.length ?? 0, 0);
|
||||
});
|
||||
|
||||
test('late refresh cannot publish after disable or destroy', async () => {
|
||||
for (const action of ['disable', 'destroy']) {
|
||||
let resolve, signal;
|
||||
const h = harness({
|
||||
getSnapshot(options) {
|
||||
signal = options.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
});
|
||||
const pending = h.layer.update(h.viewer);
|
||||
h.layer[action](h.viewer);
|
||||
assert.equal(signal.aborted, true);
|
||||
if (action === 'disable') h.layer.enable(h.viewer);
|
||||
resolve([row]);
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.layer.getStats().count, 0);
|
||||
h.layer.destroy(h.viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('the row legend names prohibited and restricted with the disclaimer', async () => {
|
||||
const h = harness({
|
||||
getSnapshot: async () => [
|
||||
row,
|
||||
{ ...row, stableId: '400', typeCode: 'R', type: 'Restricted' },
|
||||
],
|
||||
});
|
||||
await h.layer.update(h.viewer);
|
||||
const legend = h.layer.getRowControls().legend;
|
||||
assert.equal(legend[0].label, 'Prohibited (P)');
|
||||
assert.equal(legend[1].label, 'Restricted (R)');
|
||||
assert.equal(legend[0].count, 1);
|
||||
assert.equal(legend[1].count, 1);
|
||||
assert.match(legend[0].blurb, /not a substitute/);
|
||||
});
|
||||
|
||||
test('a stale snapshot reads STALE, dated by its own fetch, and a fresh one clears it', async () => {
|
||||
const fetchedAt = Date.now() - 3 * 60 * 60 * 1000;
|
||||
let snapshot = { rows: [row], stale: true, fetchedAt };
|
||||
const h = harness({
|
||||
getSnapshot: async () => snapshot.rows,
|
||||
getSnapshotDetail: async () => snapshot,
|
||||
});
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, true);
|
||||
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'stale');
|
||||
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, false);
|
||||
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'nominal');
|
||||
});
|
||||
|
||||
test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => {
|
||||
let fail = false;
|
||||
const h = harness({
|
||||
getSnapshot: async () => [row],
|
||||
getSnapshotDetail: async () => {
|
||||
if (fail) throw new Error('FAA SUA HTTP 502');
|
||||
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
|
||||
},
|
||||
});
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'stale');
|
||||
fail = true;
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, false);
|
||||
assert.equal(h.layer.getStats().error, 'FAA SUA HTTP 502');
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'degraded');
|
||||
});
|
||||
|
||||
test('a boundary change alone redraws the zone; identical geometry does not', async () => {
|
||||
const moved = ring.map(([lon, lat]) => [lon + 0.5, lat]);
|
||||
let rows = [row];
|
||||
const h = harness({ getSnapshot: async () => rows });
|
||||
await h.layer.update(h.viewer);
|
||||
const [first] = h.sources[0].entities.values;
|
||||
rows = [{ ...row, polygons: [[ring.map((point) => [...point])]] }];
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.sources[0].entities.values[0], first, 'same geometry kept');
|
||||
rows = [{ ...row, polygons: [[moved]] }];
|
||||
await h.layer.update(h.viewer);
|
||||
const [redrawn] = h.sources[0].entities.values;
|
||||
assert.notEqual(redrawn, first, 'moved boundary must redraw');
|
||||
assert.notDeepEqual(
|
||||
redrawn.polyline.positions.getValue(),
|
||||
first.polyline.positions.getValue(),
|
||||
);
|
||||
assert.ok(h.layer.getAnalystRecords()[0].lon > -76.6);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Normalize an FAA Special Use Airspace feed, skipping individually invalid
|
||||
* features. Only a payload that is not a feature collection at all rejects
|
||||
* the whole snapshot: a single degenerate polygon must never blank the layer.
|
||||
*/
|
||||
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
|
||||
const TYPE_LABELS = Object.freeze({
|
||||
P: msg('Prohibited'),
|
||||
R: msg('Restricted'),
|
||||
});
|
||||
|
||||
const finiteOrNull = (value) => (Number.isFinite(value) ? value : null);
|
||||
const textOrNull = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const text = value.trim();
|
||||
return text ? text : null;
|
||||
};
|
||||
|
||||
/** FAA marks line breaks in TIMESOFUSE with `%`; read them as `; ` separators. */
|
||||
const timesOfUseOrNull = (value) =>
|
||||
textOrNull(
|
||||
typeof value === 'string'
|
||||
? value.replace(/\s*;?\s*%\s*/g, '; ').replace(/^[;\s]+|[;\s]+$/g, '')
|
||||
: value,
|
||||
);
|
||||
|
||||
function validRing(ring) {
|
||||
if (!Array.isArray(ring) || ring.length < 4) return false;
|
||||
for (const position of ring) {
|
||||
if (!Array.isArray(position) || position.length < 2) return false;
|
||||
const [lon, lat] = position;
|
||||
if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false;
|
||||
if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an
|
||||
* array of rings). Returns null on malformed geometry, [] when empty. */
|
||||
function normalizePolygons(geometry) {
|
||||
if (!geometry || typeof geometry !== 'object') return null;
|
||||
let polygons;
|
||||
if (geometry.type === 'Polygon') polygons = [geometry.coordinates];
|
||||
else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates;
|
||||
else return null;
|
||||
if (!Array.isArray(polygons)) return null;
|
||||
const result = [];
|
||||
for (const rings of polygons) {
|
||||
if (!Array.isArray(rings)) return null;
|
||||
if (!rings.length) continue;
|
||||
if (!rings.every(validRing)) return null;
|
||||
result.push(rings);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* One SUA altitude limit from its VAL/UOM/CODE triple, in aviation notation:
|
||||
* `SFC`, `500 ft AGL`, `5,000 ft MSL`, `FL180` or `Unlimited`. The *_DESC
|
||||
* fields are inclusion qualifiers (TI/TNI/ANI), not altitudes. Returns null
|
||||
* when the triple does not describe a limit.
|
||||
*/
|
||||
export function droneAirspaceAltitude(value, uom, code) {
|
||||
const unit = textOrNull(uom)?.toUpperCase() ?? null;
|
||||
const reference = textOrNull(code)?.toUpperCase() ?? null;
|
||||
if (reference === 'UNLTD') return 'Unlimited';
|
||||
const number = Number(value);
|
||||
if (value == null || value === '' || !Number.isFinite(number) || number < 0)
|
||||
return null;
|
||||
if (unit === 'FL' || reference === 'STD') return `FL${Math.round(number)}`;
|
||||
const feet = `${Math.round(number).toLocaleString('en-US')} ft`;
|
||||
if (reference === 'SFC') return number === 0 ? 'SFC' : `${feet} AGL`;
|
||||
if (reference === 'MSL') return `${feet} MSL`;
|
||||
return unit === 'FT' || unit == null ? feet : null;
|
||||
}
|
||||
|
||||
/** Human label for a TYPE_CODE, or null when the code is not in the pack. */
|
||||
export function droneAirspaceTypeLabel(typeCode) {
|
||||
return TYPE_LABELS[typeCode] || null;
|
||||
}
|
||||
|
||||
export function normalizeDroneAirspaceSnapshot(geojson) {
|
||||
if (!Array.isArray(geojson?.features)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const feature of geojson.features) {
|
||||
const properties = feature?.properties;
|
||||
if (
|
||||
!properties ||
|
||||
typeof properties !== 'object' ||
|
||||
Array.isArray(properties)
|
||||
)
|
||||
continue;
|
||||
const typeCode = textOrNull(properties.TYPE_CODE);
|
||||
const type = droneAirspaceTypeLabel(typeCode);
|
||||
if (!type) continue;
|
||||
const polygons = normalizePolygons(feature.geometry);
|
||||
if (polygons === null || !polygons.length) continue;
|
||||
const objectId = finiteOrNull(Number(properties.OBJECTID));
|
||||
const featureId =
|
||||
feature.id == null || feature.id === '' ? null : String(feature.id);
|
||||
const stableId =
|
||||
objectId != null
|
||||
? String(objectId)
|
||||
: featureId && !ids.has(featureId)
|
||||
? featureId
|
||||
: null;
|
||||
if (stableId == null || ids.has(stableId)) continue;
|
||||
ids.add(stableId);
|
||||
rows.push({
|
||||
stableId,
|
||||
name: textOrNull(properties.NAME),
|
||||
typeCode,
|
||||
type,
|
||||
lowerAltitude: droneAirspaceAltitude(
|
||||
properties.LOWER_VAL,
|
||||
properties.LOWER_UOM,
|
||||
properties.LOWER_CODE,
|
||||
),
|
||||
upperAltitude: droneAirspaceAltitude(
|
||||
properties.UPPER_VAL,
|
||||
properties.UPPER_UOM,
|
||||
properties.UPPER_CODE,
|
||||
),
|
||||
timesOfUse: timesOfUseOrNull(properties.TIMESOFUSE),
|
||||
agency: textOrNull(properties.CONT_AGENT),
|
||||
city: textOrNull(properties.CITY),
|
||||
state: textOrNull(properties.STATE),
|
||||
polygons,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
droneAirspaceAltitude,
|
||||
droneAirspaceTypeLabel,
|
||||
normalizeDroneAirspaceSnapshot,
|
||||
} from './records.js';
|
||||
|
||||
const ring = [
|
||||
[-77.04, 38.89],
|
||||
[-77.03, 38.89],
|
||||
[-77.03, 38.9],
|
||||
[-77.04, 38.89],
|
||||
];
|
||||
|
||||
function feature(overrides = {}) {
|
||||
return {
|
||||
id: 310,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: {
|
||||
NAME: 'P-56',
|
||||
TYPE_CODE: 'P',
|
||||
UPPER_DESC: 'TNI',
|
||||
UPPER_VAL: '18000',
|
||||
UPPER_UOM: 'FT',
|
||||
UPPER_CODE: 'MSL',
|
||||
LOWER_DESC: null,
|
||||
LOWER_VAL: '0',
|
||||
LOWER_UOM: 'FT',
|
||||
LOWER_CODE: 'SFC',
|
||||
TIMESOFUSE: 'CONTINUOUS',
|
||||
CONT_AGENT: 'WASHINGTON CENTER',
|
||||
CITY: 'WASHINGTON',
|
||||
STATE: 'DC',
|
||||
OBJECTID: 310,
|
||||
...overrides.properties,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('a valid snapshot yields one row per prohibited or restricted zone', () => {
|
||||
const rows = normalizeDroneAirspaceSnapshot({
|
||||
features: [
|
||||
feature(),
|
||||
feature({
|
||||
id: 400,
|
||||
properties: {
|
||||
NAME: 'R-2508',
|
||||
TYPE_CODE: 'R',
|
||||
OBJECTID: 400,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(rows[0], {
|
||||
stableId: '310',
|
||||
name: 'P-56',
|
||||
typeCode: 'P',
|
||||
type: 'Prohibited',
|
||||
lowerAltitude: 'SFC',
|
||||
upperAltitude: '18,000 ft MSL',
|
||||
timesOfUse: 'CONTINUOUS',
|
||||
agency: 'WASHINGTON CENTER',
|
||||
city: 'WASHINGTON',
|
||||
state: 'DC',
|
||||
polygons: [[ring]],
|
||||
});
|
||||
assert.equal(rows[1].type, 'Restricted');
|
||||
assert.equal(rows[1].typeCode, 'R');
|
||||
});
|
||||
|
||||
test('altitudes come from VAL/UOM/CODE, not the TI/TNI/ANI qualifiers', () => {
|
||||
// P-40 and R-2508 as the FAA service publishes them.
|
||||
assert.equal(droneAirspaceAltitude('0', 'FT', 'SFC'), 'SFC');
|
||||
assert.equal(droneAirspaceAltitude('5000', 'FT', 'MSL'), '5,000 ft MSL');
|
||||
assert.equal(droneAirspaceAltitude('20000', 'FT', 'MSL'), '20,000 ft MSL');
|
||||
assert.equal(droneAirspaceAltitude('-9998', null, 'UNLTD'), 'Unlimited');
|
||||
assert.equal(droneAirspaceAltitude('1500', 'FT', 'SFC'), '1,500 ft AGL');
|
||||
assert.equal(droneAirspaceAltitude('180', 'FL', 'STD'), 'FL180');
|
||||
assert.equal(droneAirspaceAltitude('10000', 'FT', null), '10,000 ft');
|
||||
assert.equal(droneAirspaceAltitude(null, null, null), null);
|
||||
assert.equal(droneAirspaceAltitude('-9998', 'FT', 'MSL'), null);
|
||||
const [row] = normalizeDroneAirspaceSnapshot({
|
||||
features: [
|
||||
feature({
|
||||
properties: {
|
||||
NAME: 'R-2508',
|
||||
TYPE_CODE: 'R',
|
||||
OBJECTID: 400,
|
||||
UPPER_DESC: null,
|
||||
UPPER_VAL: '-9998',
|
||||
UPPER_UOM: null,
|
||||
UPPER_CODE: 'UNLTD',
|
||||
LOWER_VAL: '20000',
|
||||
LOWER_UOM: 'FT',
|
||||
LOWER_CODE: 'MSL',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(row.lowerAltitude, '20,000 ft MSL');
|
||||
assert.equal(row.upperAltitude, 'Unlimited');
|
||||
});
|
||||
|
||||
test('MOA, warning and advisory codes are dropped, as are bad geometries', () => {
|
||||
const rows = normalizeDroneAirspaceSnapshot({
|
||||
features: [
|
||||
feature({ properties: { TYPE_CODE: 'MOA', OBJECTID: 1 } }),
|
||||
feature({ properties: { TYPE_CODE: 'W', OBJECTID: 2 } }),
|
||||
feature({ properties: { TYPE_CODE: 'A', OBJECTID: 3 } }),
|
||||
feature({ geometry: null, properties: { OBJECTID: 4 } }),
|
||||
feature({
|
||||
geometry: { type: 'Point', coordinates: [-77, 38] },
|
||||
properties: { OBJECTID: 5 },
|
||||
}),
|
||||
feature(),
|
||||
],
|
||||
});
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].name, 'P-56');
|
||||
});
|
||||
|
||||
test('duplicate ids and a missing collection reject or skip as documented', () => {
|
||||
const dup = normalizeDroneAirspaceSnapshot({
|
||||
features: [feature(), feature({ id: 311 })],
|
||||
});
|
||||
assert.equal(dup.length, 1);
|
||||
assert.equal(normalizeDroneAirspaceSnapshot({}), null);
|
||||
assert.equal(normalizeDroneAirspaceSnapshot(null), null);
|
||||
assert.equal(droneAirspaceTypeLabel('P'), 'Prohibited');
|
||||
assert.equal(droneAirspaceTypeLabel('R'), 'Restricted');
|
||||
assert.equal(droneAirspaceTypeLabel('MOA'), null);
|
||||
});
|
||||
|
||||
test("FAA's % line-break markers in times of use read as separators", () => {
|
||||
const timesOfUse = (TIMESOFUSE) =>
|
||||
normalizeDroneAirspaceSnapshot({
|
||||
features: [
|
||||
feature({
|
||||
properties: {
|
||||
NAME: 'R-2508',
|
||||
TYPE_CODE: 'R',
|
||||
OBJECTID: 400,
|
||||
TIMESOFUSE,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})[0].timesOfUse;
|
||||
// The shape 56 live records carry: a marker after the semicolon.
|
||||
assert.equal(
|
||||
timesOfUse(
|
||||
'0700 - 2200 MON - FRI; 0800 - 1600 SAT - SUN; %OTHER TIMES BY NOTAM',
|
||||
),
|
||||
'0700 - 2200 MON - FRI; 0800 - 1600 SAT - SUN; OTHER TIMES BY NOTAM',
|
||||
);
|
||||
assert.equal(
|
||||
timesOfUse('SR - SS DAILY %OTHER TIMES BY NOTAM'),
|
||||
'SR - SS DAILY; OTHER TIMES BY NOTAM',
|
||||
);
|
||||
assert.equal(timesOfUse('%CONTINUOUS%'), 'CONTINUOUS');
|
||||
assert.equal(timesOfUse(' % '), null);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
/** Request a normalized snapshot through the bounded, same-origin FAA proxy. */
|
||||
export function createDroneAirspaceSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
/**
|
||||
* The rows with how fresh they are: the proxy answers from its last
|
||||
* snapshot, marked stale, while the FAA service is unreachable.
|
||||
* @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>}
|
||||
*/
|
||||
async function getSnapshotDetail({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/drone-airspace', { signal });
|
||||
if (!response.ok) throw new Error(`FAA SUA HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
80 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error('Malformed drone airspace snapshot');
|
||||
return {
|
||||
rows: payload.rows,
|
||||
stale: payload.stale === true,
|
||||
fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
/** The zone rows (see getSnapshotDetail for staleness). */
|
||||
async getSnapshot(options = {}) {
|
||||
return (await getSnapshotDetail(options)).rows;
|
||||
},
|
||||
getSnapshotDetail,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDroneAirspaceSource } from './source.js';
|
||||
import { normalizeDroneAirspaceSnapshot } from './records.js';
|
||||
import { droneAirspaceProxy } from '../../../server/providers/droneAirspace.js';
|
||||
|
||||
const ring = [
|
||||
[-77.04, 38.89],
|
||||
[-77.03, 38.89],
|
||||
[-77.03, 38.9],
|
||||
[-77.04, 38.89],
|
||||
];
|
||||
const validPayload = {
|
||||
features: [
|
||||
{
|
||||
id: 310,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: {
|
||||
NAME: 'P-56',
|
||||
TYPE_CODE: 'P',
|
||||
OBJECTID: 310,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('a successful response yields normalized zone rows', async () => {
|
||||
let requested;
|
||||
const source = createDroneAirspaceSource({
|
||||
fetchImpl: async (url) => {
|
||||
requested = String(url);
|
||||
return Response.json({
|
||||
rows: normalizeDroneAirspaceSnapshot(validPayload),
|
||||
});
|
||||
},
|
||||
});
|
||||
const rows = await source.getSnapshot();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].stableId, '310');
|
||||
assert.equal(requested, '/api/drone-airspace');
|
||||
});
|
||||
|
||||
test('a truncated response pages until the feed is complete', async () => {
|
||||
const pageRing = (id) => ({
|
||||
id,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: { NAME: `P-${id}`, TYPE_CODE: 'P', OBJECTID: id },
|
||||
});
|
||||
const requests = [];
|
||||
let handler;
|
||||
const plugin = droneAirspaceProxy({
|
||||
fetchImpl: async (url) => {
|
||||
requests.push(new URL(String(url)).searchParams.get('resultOffset'));
|
||||
const page = requests.length;
|
||||
return Response.json({
|
||||
features: [pageRing(page)],
|
||||
properties: { exceededTransferLimit: page < 3 },
|
||||
});
|
||||
},
|
||||
});
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use: (_path, callback) => {
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
let payload;
|
||||
await handler(
|
||||
{ url: '/', method: 'GET' },
|
||||
{
|
||||
writeHead(status) {
|
||||
assert.equal(status, 200);
|
||||
},
|
||||
end(body) {
|
||||
payload = JSON.parse(body);
|
||||
},
|
||||
},
|
||||
);
|
||||
const rows = payload.rows;
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.stableId),
|
||||
['1', '2', '3'],
|
||||
);
|
||||
assert.deepEqual(requests, [null, '1', '2']);
|
||||
});
|
||||
|
||||
test('an upstream failure surfaces its HTTP status', async () => {
|
||||
const source = createDroneAirspaceSource({
|
||||
fetchImpl: async () => ({ ok: false, status: 503 }),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /FAA SUA HTTP 503/);
|
||||
});
|
||||
|
||||
test('a malformed successful response is never accepted as an empty snapshot', async () => {
|
||||
for (const payload of [{}, { rows: null }, { rows: {} }]) {
|
||||
const source = createDroneAirspaceSource({
|
||||
fetchImpl: async () => Response.json(payload),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot(),
|
||||
/Malformed drone airspace snapshot/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the snapshot detail carries the proxy staleness and fetch time', async () => {
|
||||
const fetchedAt = Date.UTC(2026, 10, 3, 12);
|
||||
const rows = normalizeDroneAirspaceSnapshot(validPayload);
|
||||
const source = createDroneAirspaceSource({
|
||||
fetchImpl: async () => Response.json({ rows, stale: true, fetchedAt }),
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshotDetail(), {
|
||||
rows,
|
||||
stale: true,
|
||||
fetchedAt,
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshot(), rows);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import { OSM_PLACES_LAYER_ID } from './model.js';
|
||||
import { createNominatimStatus } from '../../sources/nominatimStatus.js';
|
||||
export * from './model.js';
|
||||
|
||||
const OVERLAY_SOURCE_ID = OSM_PLACES_LAYER_ID;
|
||||
/** Named results that get a map label; the rest are points. */
|
||||
const LABEL_LIMIT = 48;
|
||||
const COLOR = '#7fe3ff';
|
||||
|
||||
/**
|
||||
* OSM Places: the results of the last voice OpenStreetMap search ("hospitals
|
||||
* in Kathmandu"), drawn as points with name labels and queryable by
|
||||
* analyst_query. It fetches nothing itself; `setResults` replaces the set.
|
||||
* The search runs only on an operator-configured Overpass, so without one
|
||||
* the layer stays empty. Session-only: never in share links or stored layer
|
||||
* state.
|
||||
* @param {{overlayHost: object, overpassConfigured?: () => Promise<boolean|null>}} options
|
||||
*/
|
||||
export function createOsmPlacesLayer({
|
||||
overlayHost,
|
||||
// The same once-per-page capability probe as Nominatim's, on Overpass.
|
||||
overpassConfigured = createNominatimStatus({ url: '/api/overpass/status' }),
|
||||
} = {}) {
|
||||
if (!overlayHost) throw new TypeError('OSM Places requires an overlay host');
|
||||
let viewer = null;
|
||||
let dataSource = null;
|
||||
let enabled = false;
|
||||
let records = [];
|
||||
let meta = null;
|
||||
let lastUpdate = null;
|
||||
/** true / false from the server's probe, null while unknown. */
|
||||
let configured = null;
|
||||
|
||||
function render() {
|
||||
if (!dataSource) return;
|
||||
dataSource.entities.removeAll();
|
||||
const color = Cesium.Color.fromCssColorString(COLOR);
|
||||
const overlay = [];
|
||||
for (const record of records) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(record.lon, record.lat);
|
||||
dataSource.entities.add({
|
||||
id: `osm-place:${record.id}`,
|
||||
position,
|
||||
point: {
|
||||
pixelSize: 9,
|
||||
color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
|
||||
outlineWidth: 2,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
// On top only up close (as bikeshare stations): beyond that the
|
||||
// depth test keeps far-side points from showing through the globe.
|
||||
disableDepthTestDistance: 2500,
|
||||
},
|
||||
});
|
||||
if (record.name && overlay.length < LABEL_LIMIT)
|
||||
overlay.push({
|
||||
id: record.id,
|
||||
position,
|
||||
variant: 'label',
|
||||
title: record.name.slice(0, 40),
|
||||
accent: COLOR,
|
||||
priority: LABEL_LIMIT - overlay.length,
|
||||
collisionGroup: 'ambient-label',
|
||||
paintLane: 'ambient-label',
|
||||
interactive: false,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 12,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
});
|
||||
}
|
||||
if (enabled)
|
||||
overlayHost.setEntries(OVERLAY_SOURCE_ID, overlay, {
|
||||
cohortLimit: LABEL_LIMIT,
|
||||
collisionCapacity: LABEL_LIMIT,
|
||||
moving: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: OSM_PLACES_LAYER_ID,
|
||||
name: 'OSM Places',
|
||||
icon: '⌖',
|
||||
source: 'OpenStreetMap',
|
||||
updateInterval: 0,
|
||||
|
||||
/** Unavailable only once the server says no Overpass is configured. */
|
||||
getSourceAvailability() {
|
||||
return configured === false
|
||||
? {
|
||||
available: false,
|
||||
source: OSM_PLACES_LAYER_ID,
|
||||
reason: 'Needs an operator-configured Overpass server',
|
||||
}
|
||||
: { available: true, source: OSM_PLACES_LAYER_ID, reason: null };
|
||||
},
|
||||
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
Promise.resolve(overpassConfigured?.())
|
||||
.then((answer) => {
|
||||
if (typeof answer === 'boolean') configured = answer;
|
||||
})
|
||||
.catch(() => {});
|
||||
dataSource = new Cesium.CustomDataSource(OSM_PLACES_LAYER_ID);
|
||||
dataSource.show = false;
|
||||
viewer.dataSources.add(dataSource);
|
||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
||||
},
|
||||
|
||||
enable() {
|
||||
enabled = true;
|
||||
if (dataSource) dataSource.show = true;
|
||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, true);
|
||||
render();
|
||||
return true;
|
||||
},
|
||||
|
||||
disable() {
|
||||
enabled = false;
|
||||
if (dataSource) dataSource.show = false;
|
||||
overlayHost.clearSource(OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Nothing to poll: results change only through setResults. */
|
||||
async update() {
|
||||
return true;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
enabled = false;
|
||||
overlayHost.clearSource(OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
||||
if (dataSource && viewer) viewer.dataSources.remove(dataSource, true);
|
||||
dataSource = null;
|
||||
viewer = null;
|
||||
records = [];
|
||||
meta = null;
|
||||
lastUpdate = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the result set.
|
||||
* @param {object[]} next Records from `mapOsmFeature`.
|
||||
* @param {{kind: string, label: string, area: string}} nextMeta
|
||||
*/
|
||||
setResults(next, nextMeta) {
|
||||
records = Array.isArray(next) ? next.slice() : [];
|
||||
meta = nextMeta ? { ...nextMeta } : null;
|
||||
lastUpdate = Date.now();
|
||||
render();
|
||||
},
|
||||
|
||||
/** The current search, for panels and voice. */
|
||||
getResultsMeta() {
|
||||
return meta ? { ...meta, count: records.length } : null;
|
||||
},
|
||||
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (!enabled) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
return records.slice(0, limit).map((record) => ({ ...record }));
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: records.length,
|
||||
lastUpdate,
|
||||
error: null,
|
||||
statusMessage: meta
|
||||
? `${meta.label} · ${meta.area}`
|
||||
: msg('Voice place search (needs an Overpass server)'),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// OSM Places holds the last voice search's results: queryable while shown,
|
||||
// labelled through the overlay host, and nothing when switched off.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createOsmPlacesLayer } from './index.js';
|
||||
|
||||
function fakeOverlayHost() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
setEntries: (...args) => calls.push(['setEntries', ...args]),
|
||||
clearSource: (...args) => calls.push(['clearSource', ...args]),
|
||||
setVisible: (...args) => calls.push(['setVisible', ...args]),
|
||||
};
|
||||
}
|
||||
|
||||
const RECORDS = [
|
||||
{
|
||||
id: 'node/1',
|
||||
lat: 27.7,
|
||||
lon: 85.3,
|
||||
name: 'Bir Hospital',
|
||||
kind: 'hospital',
|
||||
},
|
||||
{ id: 'node/2', lat: 27.71, lon: 85.31, name: null, kind: 'hospital' },
|
||||
];
|
||||
|
||||
test('results are queryable only while the layer is on, and labels follow names', () => {
|
||||
const host = fakeOverlayHost();
|
||||
const layer = createOsmPlacesLayer({ overlayHost: host });
|
||||
layer.setResults(RECORDS, {
|
||||
kind: 'hospital',
|
||||
label: 'hospitals',
|
||||
area: 'Kathmandu',
|
||||
});
|
||||
assert.deepEqual(layer.getAnalystRecords(), [], 'off: nothing to count');
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(layer.getStats().statusMessage, 'hospitals · Kathmandu');
|
||||
layer.enable();
|
||||
const records = layer.getAnalystRecords();
|
||||
assert.equal(records.length, 2);
|
||||
records[0].name = 'changed';
|
||||
assert.equal(
|
||||
layer.getAnalystRecords()[0].name,
|
||||
'Bir Hospital',
|
||||
'callers get copies',
|
||||
);
|
||||
assert.deepEqual(layer.getResultsMeta(), {
|
||||
kind: 'hospital',
|
||||
label: 'hospitals',
|
||||
area: 'Kathmandu',
|
||||
count: 2,
|
||||
});
|
||||
layer.disable();
|
||||
assert.ok(
|
||||
host.calls.some(
|
||||
([name, id]) => name === 'clearSource' && id === 'osm-places',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty layer tells the operator how to fill it', () => {
|
||||
const layer = createOsmPlacesLayer({ overlayHost: fakeOverlayHost() });
|
||||
assert.match(layer.getStats().statusMessage, /needs an Overpass server/);
|
||||
assert.throws(() => createOsmPlacesLayer(), TypeError);
|
||||
});
|
||||
|
||||
test('destroy and re-init expose a fresh empty session', () => {
|
||||
const host = fakeOverlayHost();
|
||||
const layer = createOsmPlacesLayer({ overlayHost: host });
|
||||
layer.setResults(RECORDS, {
|
||||
kind: 'hospital',
|
||||
label: 'hospitals',
|
||||
area: 'Kathmandu',
|
||||
});
|
||||
assert.ok(layer.getStats().lastUpdate);
|
||||
layer.destroy();
|
||||
const dataSources = {
|
||||
added: [],
|
||||
removed: [],
|
||||
add(source) {
|
||||
this.added.push(source);
|
||||
return source;
|
||||
},
|
||||
remove(source, destroy) {
|
||||
this.removed.push([source, destroy]);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
layer.init({ dataSources });
|
||||
layer.enable();
|
||||
assert.equal(layer.getResultsMeta(), null);
|
||||
assert.deepEqual(layer.getAnalystRecords(), []);
|
||||
assert.deepEqual(layer.getStats(), {
|
||||
count: 0,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
statusMessage: 'Voice place search (needs an Overpass server)',
|
||||
});
|
||||
});
|
||||
|
||||
test('without an operator Overpass the layer shows as unavailable', async () => {
|
||||
const added = [];
|
||||
const viewer = { dataSources: { add: (ds) => added.push(ds), remove() {} } };
|
||||
const off = createOsmPlacesLayer({
|
||||
overlayHost: fakeOverlayHost(),
|
||||
overpassConfigured: async () => false,
|
||||
});
|
||||
assert.equal(
|
||||
off.getSourceAvailability().available,
|
||||
true,
|
||||
'unknown until asked',
|
||||
);
|
||||
off.init(viewer);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(off.getSourceAvailability().available, false);
|
||||
const unknown = createOsmPlacesLayer({
|
||||
overlayHost: fakeOverlayHost(),
|
||||
overpassConfigured: async () => null,
|
||||
});
|
||||
unknown.init(viewer);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(
|
||||
unknown.getSourceAvailability().available,
|
||||
true,
|
||||
'an unknown answer keeps it listed',
|
||||
);
|
||||
});
|
||||
|
||||
test('places are depth-tested past close range, so they never show through the globe', () => {
|
||||
const added = [];
|
||||
const viewer = { dataSources: { add: (ds) => added.push(ds), remove() {} } };
|
||||
const layer = createOsmPlacesLayer({
|
||||
overlayHost: fakeOverlayHost(),
|
||||
overpassConfigured: async () => true,
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable();
|
||||
layer.setResults(RECORDS, {
|
||||
kind: 'hospital',
|
||||
label: 'hospitals',
|
||||
area: 'Kathmandu',
|
||||
});
|
||||
const point = added[0].entities.values[0].point;
|
||||
const distance = point.disableDepthTestDistance.getValue();
|
||||
assert.ok(
|
||||
Number.isFinite(distance) && distance <= 5000,
|
||||
`depth test off only within ${distance} m`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* OSM Places records: OpenStreetMap features found by a voice search,
|
||||
* mapped to plain analyst records. Pure — no Cesium.
|
||||
* @module layers/osmPlaces/model
|
||||
*/
|
||||
|
||||
export const OSM_PLACES_LAYER_ID = 'osm-places';
|
||||
/** A node and a way/relation of the same name this close are one site mapped twice. */
|
||||
const SAME_SITE_M = 75;
|
||||
|
||||
/** Longest tag value kept; OSM names are open text that reaches the model. */
|
||||
const MAX_TEXT = 120;
|
||||
|
||||
/**
|
||||
* A tag value as plain bounded text: control characters (newlines, tabs)
|
||||
* become spaces; format characters (bidi overrides, zero-width spaces) are
|
||||
* dropped, except the zero-width (non-)joiners Persian and Indic names spell
|
||||
* with; runs of whitespace collapse, and long values are cut with an
|
||||
* ellipsis.
|
||||
*/
|
||||
function text(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const clean = value
|
||||
.replace(/\p{Cc}+/gu, ' ')
|
||||
.replace(/(?![\u200c\u200d])\p{Cf}/gu, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!clean) return null;
|
||||
// Cut on code points so an emoji's surrogate pair is never split.
|
||||
const chars = Array.from(clean);
|
||||
return chars.length <= MAX_TEXT
|
||||
? clean
|
||||
: `${chars
|
||||
.slice(0, MAX_TEXT - 1)
|
||||
.join('')
|
||||
.trimEnd()}…`;
|
||||
}
|
||||
|
||||
function distanceM(a, b) {
|
||||
const toRad = Math.PI / 180;
|
||||
const x = (b.lon - a.lon) * toRad * Math.cos(((a.lat + b.lat) / 2) * toRad);
|
||||
const y = (b.lat - a.lat) * toRad;
|
||||
return Math.hypot(x, y) * 6_371_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* One feature from `/api/osm/features` → an analyst record.
|
||||
* @param {{id: string, lat: number, lon: number, tags?: object}} feature
|
||||
* @param {{id: string, label: string}} preset
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function mapOsmFeature(feature, preset) {
|
||||
if (!Number.isFinite(feature?.lat) || !Number.isFinite(feature?.lon))
|
||||
return null;
|
||||
const tags = feature.tags || {};
|
||||
const [osmType] = String(feature.id || '').split('/');
|
||||
return {
|
||||
id: String(feature.id),
|
||||
lat: feature.lat,
|
||||
lon: feature.lon,
|
||||
name: text(tags['name:en']) || text(tags.name),
|
||||
kind: preset.id,
|
||||
operator: text(tags.operator) || text(tags.brand),
|
||||
emergency: text(tags.emergency),
|
||||
wheelchair: text(tags.wheelchair),
|
||||
openingHours: text(tags.opening_hours),
|
||||
city: text(tags['addr:city']),
|
||||
osmType: ['node', 'way', 'relation'].includes(osmType) ? osmType : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove double counting without merging distinct places:
|
||||
*
|
||||
* - the same OSM element (identical `type/id`) is counted once;
|
||||
* - a node and a way/relation with the same name within 75 m are one site
|
||||
* mapped twice (a point on the building and the building outline), and
|
||||
* merge;
|
||||
* - two nodes, or two ways, are always distinct places — two neighbouring
|
||||
* cafés of one chain are two cafés — and never merge, whatever their names.
|
||||
*
|
||||
* @param {object[]} records
|
||||
* @returns {{records: object[], merged: number}}
|
||||
*/
|
||||
export function dedupeOsmRecords(records) {
|
||||
const kept = [];
|
||||
const seen = new Set();
|
||||
let merged = 0;
|
||||
for (const record of records) {
|
||||
if (seen.has(record.id)) {
|
||||
merged += 1;
|
||||
continue;
|
||||
}
|
||||
seen.add(record.id);
|
||||
const name = record.name?.toLowerCase();
|
||||
const isNode = record.osmType === 'node';
|
||||
const sameSite =
|
||||
name &&
|
||||
record.osmType &&
|
||||
kept.some(
|
||||
(other) =>
|
||||
other.osmType &&
|
||||
(other.osmType === 'node') !== isNode &&
|
||||
other.name?.toLowerCase() === name &&
|
||||
distanceM(other, record) <= SAME_SITE_M,
|
||||
);
|
||||
if (sameSite) merged += 1;
|
||||
else kept.push(record);
|
||||
}
|
||||
return { records: kept, merged };
|
||||
}
|
||||
@@ -1209,6 +1209,16 @@ export function createRecentImageryLayer({
|
||||
notify('alpha');
|
||||
},
|
||||
|
||||
/** Stop catalog/preview work owned by a cancelled voice action. */
|
||||
cancelPendingSearch() {
|
||||
// Without a note the emptied strip would read as "no imagery".
|
||||
if (_searching) _notice = msg('Search cancelled');
|
||||
cancelSearch();
|
||||
clearPending();
|
||||
syncRender();
|
||||
notify();
|
||||
},
|
||||
|
||||
/**
|
||||
* Forget the box, the candidates, both pins, the preview and any pending
|
||||
* pick; reset opacity and split and cancel the box tool. Mode and
|
||||
|
||||
@@ -1385,3 +1385,13 @@ test('with SELECT BOX armed, Escape clears the preview first and the next Escape
|
||||
assert.equal(f.snap().toolActive, false);
|
||||
await tool.destroy();
|
||||
});
|
||||
|
||||
test('a voice search cancelled mid-flight says so instead of looking empty', async () => {
|
||||
const f = fixture();
|
||||
f.layer.enable();
|
||||
f.layer.setBox(BOX);
|
||||
assert.equal(f.snap().searching, true);
|
||||
f.layer.cancelPendingSearch();
|
||||
assert.equal(f.snap().searching, false);
|
||||
assert.equal(f.snap().notice, 'Search cancelled');
|
||||
});
|
||||
@@ -20,6 +20,19 @@ function tileFreshness(response) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip a flow line to a box. Every piece keeps its uncut line as `line`, so
|
||||
* pieces of one line that doubles back are never paired with each other.
|
||||
*/
|
||||
function clipFlowSegment(segment, bbox) {
|
||||
const line = segment.line || segment;
|
||||
return clipTileLine(segment.coords, bbox).map((coords) => ({
|
||||
...segment,
|
||||
coords,
|
||||
line,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Own one bounded decoded flow cache; every request still passes the server tile budget. */
|
||||
export function createFlowTileSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
@@ -41,12 +54,7 @@ export function createFlowTileSource({
|
||||
entry?.release();
|
||||
const segments = decodeFlowTile(bytes, z, x, y, { strict: true })
|
||||
.filter(isDrivableFlowSegment)
|
||||
.flatMap((segment) =>
|
||||
clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({
|
||||
...segment,
|
||||
coords,
|
||||
})),
|
||||
);
|
||||
.flatMap((segment) => clipFlowSegment(segment, tileToBBox(z, x, y)));
|
||||
if (mark) freshness.set(segments, mark);
|
||||
return segments;
|
||||
},
|
||||
@@ -87,12 +95,9 @@ export function createFlowTileSource({
|
||||
partial = result.partial;
|
||||
const segments = bounds.coverage
|
||||
? result.tiles.flat()
|
||||
: result.tiles.flat().flatMap((segment) =>
|
||||
clipTileLine(segment.coords, bounds).map((coords) => ({
|
||||
...segment,
|
||||
coords,
|
||||
})),
|
||||
);
|
||||
: result.tiles
|
||||
.flat()
|
||||
.flatMap((segment) => clipFlowSegment(segment, bounds));
|
||||
const marks = result.tiles.map((tile) => freshness.get(tile) || {});
|
||||
const times = marks
|
||||
.map((mark) => mark.fetchedAt)
|
||||
|
||||
@@ -35,6 +35,14 @@ const MIN_FILL_M = 40;
|
||||
* Same-class overlap keeps the full 35 m matcher radius.
|
||||
*/
|
||||
const CLASS_MISMATCH_RADIUS_M = 15;
|
||||
/**
|
||||
* Opposite-direction TomTom lines this close share one street: TomTom draws
|
||||
* both directions of a two-way street a metre or two apart, and a divided
|
||||
* carriageway's sides further apart.
|
||||
*/
|
||||
const PAIRED_SIDE_RADIUS_M = 5;
|
||||
/** Arc-length positions sampled along a `one_side` line for its partner. */
|
||||
const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75];
|
||||
const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']);
|
||||
const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']);
|
||||
|
||||
@@ -85,20 +93,69 @@ export function isDrivableFlowSegment(segment) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share of a `one_side` line's sample points (PAIRED_SIDE_SAMPLES) with an
|
||||
* opposite-direction TomTom line beside them: TomTom draws a two-way street
|
||||
* whose directions differ as two `one_side` lines on one centreline, while a
|
||||
* divided carriageway's other side is further away than PAIRED_SIDE_RADIUS_M.
|
||||
* @returns {number} Paired share in [0, 1].
|
||||
*/
|
||||
function pairedSampleShare(segment, index) {
|
||||
const xs = [];
|
||||
const ys = [];
|
||||
const cum = [0];
|
||||
for (const [lon, lat] of segment.coords) {
|
||||
const [x, y] = index.project(lon, lat);
|
||||
const i = xs.push(x) - 1;
|
||||
ys.push(y);
|
||||
if (i) cum.push(cum[i - 1] + Math.hypot(x - xs[i - 1], y - ys[i - 1]));
|
||||
}
|
||||
const length = cum[cum.length - 1];
|
||||
if (!(length > 0)) return 0;
|
||||
const line = segment.line || segment;
|
||||
let paired = 0;
|
||||
let i = 1;
|
||||
for (const share of PAIRED_SIDE_SAMPLES) {
|
||||
const target = share * length;
|
||||
while (i < cum.length - 1 && cum[i] < target) i++;
|
||||
const dx = xs[i] - xs[i - 1];
|
||||
const dy = ys[i] - ys[i - 1];
|
||||
const t = (target - cum[i - 1]) / (cum[i] - cum[i - 1] || 1);
|
||||
const reverse = (Math.atan2(-dx, -dy) * 180) / Math.PI;
|
||||
if (
|
||||
index.nearest(
|
||||
xs[i - 1] + dx * t,
|
||||
ys[i - 1] + dy * t,
|
||||
reverse,
|
||||
// A line that doubles back beside itself, even clipped into pieces,
|
||||
// is not its own pair.
|
||||
(piece) =>
|
||||
(piece.flow.line || piece.flow) === line ? 0 : PAIRED_SIDE_RADIUS_M,
|
||||
).best
|
||||
)
|
||||
paired++;
|
||||
}
|
||||
return paired / PAIRED_SIDE_SAMPLES.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert decoded TomTom flow lines into road records that carry their own
|
||||
* flow. A line with `coverage: 'full'` describes both travel directions;
|
||||
* `one_side` lines describe the direction they are drawn in.
|
||||
* `one_side` lines describe the direction they are drawn in. A `one_side`
|
||||
* line paired with the opposite direction on the same street gets that
|
||||
* direction's half share of dots, as a two-way road's direction does.
|
||||
* @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} segments
|
||||
* @returns {Array<object>} Road records (`directFlow: true`).
|
||||
*/
|
||||
export function flowSegmentsToRoads(segments) {
|
||||
return (segments || [])
|
||||
.filter(
|
||||
(segment) =>
|
||||
segment?.coords?.length >= 2 && isDrivableFlowSegment(segment),
|
||||
)
|
||||
.map((segment) => ({
|
||||
const drivable = (segments || []).filter(
|
||||
(segment) => segment?.coords?.length >= 2 && isDrivableFlowSegment(segment),
|
||||
);
|
||||
const sides = createFlowSegmentIndex(
|
||||
drivable.filter((segment) => segment.coverage !== 'full'),
|
||||
);
|
||||
return drivable.map((segment) => {
|
||||
const road = {
|
||||
coordinates: segment.coords,
|
||||
type: FLOW_ROAD_TYPES[segment.roadType],
|
||||
drivable: true,
|
||||
@@ -110,7 +167,15 @@ export function flowSegmentsToRoads(segments) {
|
||||
oneway: segment.coverage === 'full' ? 0 : 1,
|
||||
flow: { level: segment.trafficLevel, closure: segment.closure === true },
|
||||
directFlow: true,
|
||||
}));
|
||||
};
|
||||
if (
|
||||
segment.coverage !== 'full' &&
|
||||
sides &&
|
||||
pairedSampleShare(segment, sides) >= 0.5
|
||||
)
|
||||
road.densityWeight = 0.5;
|
||||
return road;
|
||||
});
|
||||
}
|
||||
|
||||
/** Cut a projected polyline to the arc-length interval [from, to]. */
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
flowSegmentsToRoads,
|
||||
resolveRoadMode,
|
||||
selectTrafficRoads,
|
||||
} from './roadModes.js';
|
||||
import { createFlowTileSource } from './flowSource.js';
|
||||
import { createTrafficSource } from './source.js';
|
||||
import { createTrafficLayer } from './index.js';
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
serializeStoredLayerState,
|
||||
parseStoredLayerState,
|
||||
} from '../../data/layerState.js';
|
||||
import { tileToBBox } from '../../data/tomtomTiles.js';
|
||||
|
||||
const coordinates = [
|
||||
[-97.744, 30.267],
|
||||
@@ -116,6 +119,133 @@ test('a full-coverage TomTom line is two-way and covers both OSM directions', ()
|
||||
assert.equal(roads.length, 1);
|
||||
assert.equal(roads[0].directFlow, true);
|
||||
});
|
||||
test("opposite TomTom sides of one street share a two-way street's dots", () => {
|
||||
// About 1 m and 20 m north of `coordinates`, drawn westbound.
|
||||
const back = (dLat) => ({
|
||||
...flow,
|
||||
coords: coordinates.map(([lon, lat]) => [lon, lat + dLat]).reverse(),
|
||||
});
|
||||
const weights = (segments) =>
|
||||
flowSegmentsToRoads(segments).map((road) => road.densityWeight ?? 1);
|
||||
assert.deepEqual(weights([flow, back(0.00001)]), [0.5, 0.5]);
|
||||
assert.deepEqual(weights([flow]), [1], 'a lone side keeps the one-way share');
|
||||
assert.deepEqual(
|
||||
weights([flow, back(0.00018)]),
|
||||
[1, 1],
|
||||
'a divided carriageway keeps one-way shares',
|
||||
);
|
||||
assert.deepEqual(
|
||||
weights([
|
||||
flow,
|
||||
{
|
||||
...flow,
|
||||
coords: coordinates.map(([lon, lat]) => [lon, lat + 0.00001]),
|
||||
},
|
||||
]),
|
||||
[1, 1],
|
||||
'same-direction lines are not a pair',
|
||||
);
|
||||
assert.deepEqual(
|
||||
weights([{ ...flow, coverage: 'full' }, back(0.00001)]),
|
||||
[1, 1],
|
||||
'a full-coverage line is not a side, and pairs with nothing',
|
||||
);
|
||||
});
|
||||
test('a TomTom side paired along only part of its length keeps its share', () => {
|
||||
// Westbound partners about 1 m north, from the start of `flow` to 30% and
|
||||
// to 60% of its length: one or two of its three sample points.
|
||||
const partner = (toLon) => ({
|
||||
...flow,
|
||||
coords: [
|
||||
[toLon, 30.26701],
|
||||
[-97.744, 30.26701],
|
||||
],
|
||||
});
|
||||
const weightOfFlow = (other) =>
|
||||
flowSegmentsToRoads([flow, other])[0].densityWeight ?? 1;
|
||||
assert.equal(weightOfFlow(partner(-97.7434)), 1);
|
||||
assert.equal(weightOfFlow(partner(-97.7428)), 0.5);
|
||||
});
|
||||
test('a TomTom side that doubles back beside itself is not its own pair', () => {
|
||||
// East along `coordinates`, then back west about 1 m north.
|
||||
const hairpin = {
|
||||
...flow,
|
||||
coords: [...coordinates, [-97.742, 30.26701], [-97.744, 30.26701]],
|
||||
};
|
||||
assert.equal(flowSegmentsToRoads([hairpin])[0].densityWeight ?? 1, 1);
|
||||
});
|
||||
/** A flow tile of one-way Major roads; each line is [x, y, x, y, ...]. */
|
||||
function flowTile(lines) {
|
||||
const writer = new PbfWriter();
|
||||
writer.writeMessage(3, writeFlowLayer, lines);
|
||||
return writer.finish();
|
||||
}
|
||||
function writeFlowLayer(lines, layer) {
|
||||
layer.writeVarintField(15, 2);
|
||||
layer.writeStringField(1, 'Traffic flow');
|
||||
layer.writeVarintField(5, 4096);
|
||||
layer.writeStringField(3, 'traffic_level');
|
||||
layer.writeMessage(4, (_, value) => value.writeDoubleField(3, 0.6), null);
|
||||
layer.writeStringField(3, 'road_type');
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(_, value) => value.writeStringField(1, 'Major road'),
|
||||
null,
|
||||
);
|
||||
layer.writeStringField(3, 'traffic_road_coverage');
|
||||
layer.writeMessage(
|
||||
4,
|
||||
(_, value) => value.writeStringField(1, 'one_side'),
|
||||
null,
|
||||
);
|
||||
for (const xy of lines) {
|
||||
// MoveTo one point, LineTo the rest, as zigzag deltas.
|
||||
const geometry = [9];
|
||||
for (let i = 0; i < xy.length; i++) {
|
||||
if (i === 2) geometry.push(2 | ((xy.length / 2 - 1) << 3));
|
||||
const delta = xy[i] - (i < 2 ? 0 : xy[i - 2]);
|
||||
geometry.push((delta << 1) ^ (delta >> 31));
|
||||
}
|
||||
layer.writeMessage(
|
||||
2,
|
||||
(_, feature) => {
|
||||
feature.writeVarintField(3, 2);
|
||||
feature.writePackedVarint(2, [0, 0, 1, 1, 2, 2]);
|
||||
feature.writePackedVarint(4, geometry);
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
test('TomTom sides clipped by their tile and the view pair only with other lines', async () => {
|
||||
// A one-way hairpin turning in the tile buffer, its arms one tile unit
|
||||
// (about 2 m) apart, and a separate opposite pair. The tile clip cuts the
|
||||
// hairpin into two arms; the view clip then cuts every line again.
|
||||
const lines = [
|
||||
[500, 1000, 4300, 1000, 4300, 1001, 500, 1001],
|
||||
[-200, 3000, 4300, 3000],
|
||||
[4300, 3001, -200, 3001],
|
||||
];
|
||||
const source = createFlowTileSource({
|
||||
fetchImpl: async () => new Response(flowTile(lines)),
|
||||
});
|
||||
const tile = tileToBBox(12, 935, 1686);
|
||||
const west = tile.west + (tile.east - tile.west) * 0.2;
|
||||
const { segments } = await source.fetchFlowDetail({
|
||||
...tile,
|
||||
west,
|
||||
east: tile.east - 1e-9,
|
||||
south: tile.south + 1e-9,
|
||||
});
|
||||
assert.equal(segments.length, 4);
|
||||
assert.ok(
|
||||
segments.every((s) => s.coords.every(([lon]) => lon >= west - 1e-10)),
|
||||
);
|
||||
assert.deepEqual(
|
||||
flowSegmentsToRoads(segments).map((road) => road.densityWeight ?? 1),
|
||||
[1, 1, 0.5, 0.5],
|
||||
);
|
||||
});
|
||||
test('bearing and distance reject nearby crossing and separate OSM roads', () => {
|
||||
const parallel = {
|
||||
...main,
|
||||
|
||||
+179
-39
@@ -19,23 +19,52 @@ const DATA = { status: 200, body: '{"elements":[]}' };
|
||||
test('disk cache rejects old refusals for fresh and stale reads but preserves last-good data', async () => {
|
||||
const key = `overpass-cache-regression-${randomUUID()}`;
|
||||
const directory = path.join(process.cwd(), '.gev-cache', 'overpass');
|
||||
const file = path.join(directory, `${createHash('sha1').update(key).digest('hex')}.json`);
|
||||
const file = path.join(
|
||||
directory,
|
||||
`${createHash('sha1').update(key).digest('hex')}.json`,
|
||||
);
|
||||
await mkdir(directory, { recursive: true });
|
||||
try {
|
||||
for (const refusal of [
|
||||
{ status: 406 }, { status: 429 }, { status: 503 },
|
||||
{ status: 200, rateLimited: true }, { status: 200, runtimeError: true },
|
||||
{ status: 406 },
|
||||
{ status: 429 },
|
||||
{ status: 503 },
|
||||
{ status: 200, rateLimited: true },
|
||||
{ status: 200, runtimeError: true },
|
||||
]) {
|
||||
await writeFile(file, JSON.stringify({ ...DATA, cachedAt: Date.now(), ...refusal }));
|
||||
assert.equal(await readOverpassDisk(key, 60000), null, `fresh ${JSON.stringify(refusal)}`);
|
||||
assert.equal(await readOverpassDisk(key, Infinity), null, `stale ${JSON.stringify(refusal)}`);
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({ ...DATA, cachedAt: Date.now(), ...refusal }),
|
||||
);
|
||||
assert.equal(
|
||||
await readOverpassDisk(key, 60000),
|
||||
null,
|
||||
`fresh ${JSON.stringify(refusal)}`,
|
||||
);
|
||||
assert.equal(
|
||||
await readOverpassDisk(key, Infinity),
|
||||
null,
|
||||
`stale ${JSON.stringify(refusal)}`,
|
||||
);
|
||||
}
|
||||
const good = { ...DATA, cachedAt: Date.now() - 120000 };
|
||||
await writeFile(file, JSON.stringify(good));
|
||||
assert.equal(await readOverpassDisk(key, 60000), null, 'expired good data misses normal TTL');
|
||||
assert.deepEqual(await readOverpassDisk(key, Infinity), good, 'last-good data survives an outage');
|
||||
assert.equal(
|
||||
await readOverpassDisk(key, 60000),
|
||||
null,
|
||||
'expired good data misses normal TTL',
|
||||
);
|
||||
assert.deepEqual(
|
||||
await readOverpassDisk(key, Infinity),
|
||||
good,
|
||||
'last-good data survives an outage',
|
||||
);
|
||||
await writeFile(file, '{invalid');
|
||||
assert.equal(await readOverpassDisk(key, Infinity), null, 'corrupt cache is ignored');
|
||||
assert.equal(
|
||||
await readOverpassDisk(key, Infinity),
|
||||
null,
|
||||
'corrupt cache is ignored',
|
||||
);
|
||||
} finally {
|
||||
await unlink(file);
|
||||
}
|
||||
@@ -49,27 +78,45 @@ test('only a 2xx that is neither rate-limited nor a runtime error is data', () =
|
||||
|
||||
// The measured refusal, and its neighbours. `< 500` admitted every one.
|
||||
for (const status of [400, 403, 406, 410, 429]) {
|
||||
assert.equal(overpassPayloadIsData({ status }), false, `${status} is not data`);
|
||||
assert.equal(
|
||||
overpassPayloadIsData({ status }),
|
||||
false,
|
||||
`${status} is not data`,
|
||||
);
|
||||
}
|
||||
assert.equal(overpassPayloadIsData({ status: 502 }), false);
|
||||
// A 200 can still not be data: Overpass reports runtime failures in the body.
|
||||
assert.equal(overpassPayloadIsData({ status: 200, runtimeError: true }), false);
|
||||
assert.equal(overpassPayloadIsData({ status: 200, rateLimited: true }), false);
|
||||
assert.equal(
|
||||
overpassPayloadIsData({ status: 200, runtimeError: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
overpassPayloadIsData({ status: 200, rateLimited: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(overpassPayloadIsData({}), false);
|
||||
assert.equal(overpassPayloadIsData(null), false);
|
||||
});
|
||||
|
||||
// Configured endpoints replace defaults; refusals are sanitized and cooled down.
|
||||
test('configured failover accepts empty data and keeps the application identity', async () => {
|
||||
const endpoints = ['https://first.example/query?secret=one', 'http://localhost:12345/api'];
|
||||
const endpoints = [
|
||||
'https://first.example/query?secret=one',
|
||||
'http://localhost:12345/api',
|
||||
];
|
||||
const seen = [];
|
||||
const payload = await fetchOverpassPayload('data=x', 1024, {
|
||||
endpoints,
|
||||
fetchImpl: async (url, options) => {
|
||||
seen.push(url);
|
||||
assert.equal(options.headers['User-Agent'], 'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)');
|
||||
assert.equal(
|
||||
options.headers['User-Agent'],
|
||||
'gods-eye-view/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
);
|
||||
assert.equal(options.redirect, 'error');
|
||||
return new Response(seen.length === 1 ? url : DATA.body, { status: seen.length === 1 ? 406 : 200 });
|
||||
return new Response(seen.length === 1 ? url : DATA.body, {
|
||||
status: seen.length === 1 ? 406 : 200,
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.deepEqual(seen, endpoints);
|
||||
@@ -80,11 +127,19 @@ test('configured failover accepts empty data and keeps the application identity'
|
||||
test('406 and 429 honor Retry-After across different queries without more egress', async () => {
|
||||
for (const status of [406, 429]) {
|
||||
const endpoint = `https://cooldown-${status}.example/private?token=secret`;
|
||||
let count = 0, now = 1_000_000;
|
||||
const options = { endpoints: [endpoint], now: () => now, fetchImpl: async () => {
|
||||
count++;
|
||||
return new Response(`Refused ${endpoint}`, { status, headers: { 'Retry-After': '90' } });
|
||||
} };
|
||||
let count = 0,
|
||||
now = 1_000_000;
|
||||
const options = {
|
||||
endpoints: [endpoint],
|
||||
now: () => now,
|
||||
fetchImpl: async () => {
|
||||
count++;
|
||||
return new Response(`Refused ${endpoint}`, {
|
||||
status,
|
||||
headers: { 'Retry-After': '90' },
|
||||
});
|
||||
},
|
||||
};
|
||||
const first = await fetchOverpassPayload('first', 1024, options);
|
||||
assert.equal(first.status, status);
|
||||
assert.equal(first.retryAfterMs, 90_000);
|
||||
@@ -102,11 +157,23 @@ test('406 and 429 honor Retry-After across different queries without more egress
|
||||
test('HTTP-date Retry-After is respected, and absent delays use bounded backoff', async () => {
|
||||
let now = Date.parse('2026-09-23T12:00:00Z');
|
||||
const endpoint = 'https://dated.example/api';
|
||||
const payload = await fetchOverpassPayload('x', 1024, { endpoints: [endpoint], now: () => now,
|
||||
fetchImpl: async () => new Response('busy', { status: 429, headers: { 'Retry-After': 'Wed, 23 Sep 2026 12:02:00 GMT' } }) });
|
||||
const payload = await fetchOverpassPayload('x', 1024, {
|
||||
endpoints: [endpoint],
|
||||
now: () => now,
|
||||
fetchImpl: async () =>
|
||||
new Response('busy', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': 'Wed, 23 Sep 2026 12:02:00 GMT' },
|
||||
}),
|
||||
});
|
||||
assert.equal(payload.retryAfterMs, 120_000);
|
||||
const options = { endpoints: ['https://bounded.example'], now: () => now,
|
||||
fetchImpl: async () => { throw new Error('https://bounded.example/secret'); } };
|
||||
const options = {
|
||||
endpoints: ['https://bounded.example'],
|
||||
now: () => now,
|
||||
fetchImpl: async () => {
|
||||
throw new Error('https://bounded.example/secret');
|
||||
},
|
||||
};
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const reply = await fetchOverpassPayload('x', 1024, options);
|
||||
assert.ok(reply.retryAfterMs <= 300_000);
|
||||
@@ -116,13 +183,27 @@ test('HTTP-date Retry-After is respected, and absent delays use bounded backoff'
|
||||
});
|
||||
|
||||
test('production reader rejects oversized, malformed and runtime-failure bodies', async () => {
|
||||
for (const [i, body] of ['x'.repeat(200), '{"remark":"runtime error: timed out","elements":[]}', 'rate_limited', '{}', 'not json'].entries()) {
|
||||
const endpoints = [`https://bad-${i}.example/`, `https://good-${i}.example/`];
|
||||
for (const [i, body] of [
|
||||
'x'.repeat(200),
|
||||
'{"remark":"runtime error: timed out","elements":[]}',
|
||||
'rate_limited',
|
||||
'{}',
|
||||
'not json',
|
||||
].entries()) {
|
||||
const endpoints = [
|
||||
`https://bad-${i}.example/`,
|
||||
`https://good-${i}.example/`,
|
||||
];
|
||||
const seen = [];
|
||||
const payload = await fetchOverpassPayload('data=x', 100, { endpoints, fetchImpl: async (url) => {
|
||||
seen.push(url); return new Response(seen.length === 1 ? body : DATA.body);
|
||||
} });
|
||||
assert.equal(payload.body, DATA.body); assert.deepEqual(seen, endpoints);
|
||||
const payload = await fetchOverpassPayload('data=x', 100, {
|
||||
endpoints,
|
||||
fetchImpl: async (url) => {
|
||||
seen.push(url);
|
||||
return new Response(seen.length === 1 ? body : DATA.body);
|
||||
},
|
||||
});
|
||||
assert.equal(payload.body, DATA.body);
|
||||
assert.deepEqual(seen, endpoints);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -152,20 +233,69 @@ test('element data that mentions a failure phrase is data, and sets no cooldown'
|
||||
assert.equal(error.retryAfterMs, 120_000);
|
||||
});
|
||||
|
||||
test('caller cancellation aborts transport without cooling down the upstream', async () => {
|
||||
const endpoint = `https://cancelled-${randomUUID()}.example/api`;
|
||||
const controller = new AbortController();
|
||||
const entered = Promise.withResolvers();
|
||||
const first = fetchOverpassPayload('data=x', 1024, {
|
||||
endpoints: [endpoint],
|
||||
signal: controller.signal,
|
||||
fetchImpl: async (_url, { signal }) => {
|
||||
entered.resolve();
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
await entered.promise;
|
||||
controller.abort();
|
||||
await assert.rejects(first);
|
||||
|
||||
let retried = 0;
|
||||
const next = await fetchOverpassPayload('data=y', 1024, {
|
||||
endpoints: [endpoint],
|
||||
fetchImpl: async () => {
|
||||
retried += 1;
|
||||
return new Response(DATA.body, { status: 200 });
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
retried,
|
||||
1,
|
||||
'the next live caller is not held behind a cooldown',
|
||||
);
|
||||
assert.equal(next.body, DATA.body);
|
||||
});
|
||||
|
||||
function proxyHandler() {
|
||||
const plugin = createViteConfig({ mode: 'test' }).plugins.find(p => p.name === 'overpass-proxy');
|
||||
const plugin = createViteConfig({ mode: 'test' }).plugins.find(
|
||||
(p) => p.name === 'overpass-proxy',
|
||||
);
|
||||
const routes = new Map();
|
||||
plugin.configureServer({ middlewares: { use: (route, handler) => routes.set(route, handler) } });
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (route, handler) => routes.set(route, handler) },
|
||||
});
|
||||
return routes.get('/api/overpass');
|
||||
}
|
||||
|
||||
function invoke(handler, body) {
|
||||
const req = Readable.from([Buffer.from(body)]);
|
||||
Object.assign(req, { method: 'POST', headers: {}, socket: { remoteAddress: '127.0.0.1' } });
|
||||
Object.assign(req, {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const res = {
|
||||
writeHead(status, headers) { this.status = status; this.headers = headers; },
|
||||
end(body) { resolve({ status: this.status, headers: this.headers, body }); },
|
||||
writeHead(status, headers) {
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
},
|
||||
end(body) {
|
||||
resolve({ status: this.status, headers: this.headers, body });
|
||||
},
|
||||
};
|
||||
Promise.resolve(handler(req, res)).catch(reject);
|
||||
});
|
||||
@@ -174,13 +304,19 @@ function invoke(handler, body) {
|
||||
test('coalesced outage callers both receive last-good data, never a cached refusal', async (t) => {
|
||||
const handler = proxyHandler();
|
||||
const prior = process.env.OVERPASS_UPSTREAMS;
|
||||
t.after(() => { if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS; else process.env.OVERPASS_UPSTREAMS = prior; });
|
||||
t.after(() => {
|
||||
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
|
||||
else process.env.OVERPASS_UPSTREAMS = prior;
|
||||
});
|
||||
for (const status of [406, 503, 429]) {
|
||||
process.env.OVERPASS_UPSTREAMS = `https://outage-${status}.example/api`;
|
||||
const query = `[out:json][timeout:12];node(around:10,30.27,-97.74)["name"="${randomUUID()}"];out;`;
|
||||
const body = `data=${encodeURIComponent(query)}`;
|
||||
const directory = path.join(process.cwd(), '.gev-cache', 'overpass');
|
||||
const file = path.join(directory, `${createHash('sha1').update(body).digest('hex')}.json`);
|
||||
const file = path.join(
|
||||
directory,
|
||||
`${createHash('sha1').update(body).digest('hex')}.json`,
|
||||
);
|
||||
await mkdir(directory, { recursive: true });
|
||||
// Last-good data was fetched from the instance configured above, so it
|
||||
// carries that instance's tag the way the transport now writes it.
|
||||
@@ -205,10 +341,14 @@ test('coalesced outage callers both receive last-good data, never a cached refus
|
||||
const second = invoke(handler, body);
|
||||
// The second request consumes its in-memory stream and joins the pending
|
||||
// promise before releasing upstream. No network or elapsed-time sleep.
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
release.resolve();
|
||||
for (const response of await Promise.all([first, second])) {
|
||||
assert.equal(response.status, 200, `${status}: both callers use last-good data`);
|
||||
assert.equal(
|
||||
response.status,
|
||||
200,
|
||||
`${status}: both callers use last-good data`,
|
||||
);
|
||||
assert.equal(response.body, DATA.body);
|
||||
assert.equal(response.headers['X-Overpass-Cache'], 'STALE');
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ const PROVIDER_FIELDS = [
|
||||
const RATE_LIMIT_FIELDS = [
|
||||
'GEV_RATELIMIT_OPENAI_PER_MIN',
|
||||
'GEV_RATELIMIT_GOOGLE_PER_MIN',
|
||||
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
|
||||
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
|
||||
];
|
||||
const APP_VALUE_FIELDS = [...PROVIDER_FIELDS, ...RATE_LIMIT_FIELDS];
|
||||
const SHARING_FIELDS = [
|
||||
|
||||
@@ -25,11 +25,14 @@ const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
function realtimeTools() { return GEV_REALTIME_TOOLS; }
|
||||
|
||||
test('Realtime schema exposes the authoritative 31-tool inventory', () => {
|
||||
test('Realtime schema exposes the authoritative 34-tool inventory', () => {
|
||||
const tools = realtimeTools();
|
||||
assert.equal(tools.length, 31);
|
||||
assert.equal(tools.length, 34);
|
||||
const names = tools.map((tool) => tool.name);
|
||||
assert.equal(new Set(names).size, 31, 'tool names are unique');
|
||||
assert.equal(new Set(names).size, 34, 'tool names are unique');
|
||||
assert.ok(names.includes('resolve_area'));
|
||||
assert.ok(names.includes('find_imagery'));
|
||||
assert.ok(names.includes('osm_query'));
|
||||
assert.ok(names.includes('set_context_mode'));
|
||||
assert.ok(names.includes('control_cockpit'));
|
||||
assert.ok(names.includes('set_voice_inactivity_timeout'));
|
||||
@@ -199,6 +202,10 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
'track_entity',
|
||||
// Point-and-ask: 'pointer' sentinels and referent arguments.
|
||||
'annotate_map',
|
||||
// Voice geometry: new tools.
|
||||
'resolve_area',
|
||||
'find_imagery',
|
||||
'osm_query',
|
||||
]);
|
||||
const unchanged = realtimeTools()
|
||||
.filter((tool) => !TOUCHED.has(tool.name))
|
||||
|
||||
@@ -54,9 +54,24 @@ function probeTransport(status) {
|
||||
return { seen, fetchImpl };
|
||||
}
|
||||
const stalled = (init) => new Promise((_, reject) => init.signal.addEventListener('abort', () => reject(init.signal.reason)));
|
||||
const stalledWithReferencedWait = (init) =>
|
||||
new Promise((_, reject) => {
|
||||
// AbortSignal.timeout() is intentionally unref'd in Node. Keep this one
|
||||
// timeout test alive until the signal fires so an isolated test process
|
||||
// cannot exit with a still-pending assertion.
|
||||
const keepAlive = setTimeout(() => {}, 1000);
|
||||
init.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(keepAlive);
|
||||
reject(init.signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
test('a stalled capability probe times out and queries proceed; no re-probe during backoff', async () => {
|
||||
const { seen, fetchImpl } = probeTransport(stalled);
|
||||
const { seen, fetchImpl } = probeTransport(stalledWithReferencedWait);
|
||||
const services = createApplicationRequestServices({ fetchImpl, boundaryProbe: { timeoutMs: 20, retryMs: 60_000 } });
|
||||
const started = Date.now();
|
||||
assert.deepEqual(await services.boundaries.query('fixture'), []);
|
||||
|
||||
@@ -159,6 +159,8 @@ export function createNominatimOutlineClient({
|
||||
const outline = {
|
||||
polygons,
|
||||
name: body.outline.name || null,
|
||||
adminArea: body.outline.adminArea || null,
|
||||
adminLevel: body.outline.adminLevel || null,
|
||||
class: body.outline.class || null,
|
||||
type: body.outline.type || null,
|
||||
osm: body.outline.osm || null,
|
||||
|
||||
@@ -44,20 +44,29 @@ test('an accepted outline comes back with its polygons and is remembered', async
|
||||
reply(200, {
|
||||
status: 'OK',
|
||||
outline: {
|
||||
name: 'Paris',
|
||||
name: 'Bagmati Province',
|
||||
adminArea: 'Bagmati Province',
|
||||
adminLevel: 'admin1',
|
||||
class: 'boundary',
|
||||
type: 'administrative',
|
||||
polygons: [[ring, hole]],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const ask = { query: 'Paris', kind: 'city', lat: 48.85, lon: 2.35 };
|
||||
const ask = {
|
||||
query: 'Bagmati Province',
|
||||
kind: 'admin',
|
||||
lat: 27.72,
|
||||
lon: 85.32,
|
||||
};
|
||||
const outline = await lookup(ask);
|
||||
assert.deepEqual(outline.polygons, [[ring, hole]]);
|
||||
assert.equal(outline.class, 'boundary');
|
||||
assert.equal(outline.adminArea, 'Bagmati Province');
|
||||
assert.equal(outline.adminLevel, 'admin1');
|
||||
assert.match(
|
||||
calls[0],
|
||||
/^\/api\/geocode\/outline\?q=Paris&kind=city&lat=48\.850&lon=2\.350$/,
|
||||
/^\/api\/geocode\/outline\?q=Bagmati\+Province&kind=admin&lat=27\.720&lon=85\.320$/,
|
||||
);
|
||||
await lookup(ask);
|
||||
assert.equal(calls.length, 1, 'one request per ask in a session');
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Browser client for `/api/osm/features`: one curated preset inside a box,
|
||||
* as a count or as features. The server writes and bounds the Overpass
|
||||
* query and answers only when an operator configured Overpass; this only
|
||||
* names the preset, box, mode and limit.
|
||||
* @module sources/osmFeatures
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{fetchImpl?: typeof fetch, endpoint?: string}} [options]
|
||||
* @returns {(request: {preset: string, bbox: number[], mode?: 'count'|'features', limit?: number}, options?: {signal?: AbortSignal}) => Promise<object>}
|
||||
*/
|
||||
export function createOsmFeatureSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
endpoint = '/api/osm/features',
|
||||
} = {}) {
|
||||
return async function searchOsmFeatures(
|
||||
{ preset, bbox, mode = 'features', limit },
|
||||
{ signal } = {},
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
preset,
|
||||
bbox: bbox.map((v) => Number(v).toFixed(5)).join(','),
|
||||
mode,
|
||||
});
|
||||
if (Number.isFinite(limit)) params.set('limit', String(limit));
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(`${endpoint}?${params}`, { signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: 'OpenStreetMap search is unreachable.',
|
||||
};
|
||||
}
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (!response.ok)
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
code: body?.code || null,
|
||||
error:
|
||||
body?.error || `OpenStreetMap search failed (${response.status}).`,
|
||||
};
|
||||
// A garbled success (a proxy's error page) is a failure, not zero places.
|
||||
if (!body || typeof body !== 'object')
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
code: null,
|
||||
error: 'OpenStreetMap search returned an unreadable answer.',
|
||||
};
|
||||
return { ok: true, ...body };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createOsmFeatureSource } from './osmFeatures.js';
|
||||
|
||||
const respond = (status, body) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => {
|
||||
if (typeof body === 'string') throw new SyntaxError('not JSON');
|
||||
return body;
|
||||
},
|
||||
});
|
||||
|
||||
test('the request names only the preset, box, mode and limit', async () => {
|
||||
let asked = null;
|
||||
const search = createOsmFeatureSource({
|
||||
fetchImpl: async (url) => {
|
||||
asked = new URL(url, 'http://localhost');
|
||||
return respond(200, { features: [], count: 0 });
|
||||
},
|
||||
});
|
||||
const result = await search({
|
||||
preset: 'hospital',
|
||||
bbox: [85.2, 27.6, 85.4, 27.8],
|
||||
limit: 50,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(asked.pathname, '/api/osm/features');
|
||||
assert.equal(asked.searchParams.get('preset'), 'hospital');
|
||||
assert.equal(
|
||||
asked.searchParams.get('bbox'),
|
||||
'85.20000,27.60000,85.40000,27.80000',
|
||||
);
|
||||
assert.equal(asked.searchParams.get('mode'), 'features');
|
||||
assert.equal(asked.searchParams.get('limit'), '50');
|
||||
});
|
||||
|
||||
test('an aborted search rethrows; a network failure is a refusal', async () => {
|
||||
const controller = new AbortController();
|
||||
const aborting = createOsmFeatureSource({
|
||||
fetchImpl: async () => {
|
||||
controller.abort();
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
aborting(
|
||||
{ preset: 'hospital', bbox: [0, 0, 1, 1] },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
const offline = createOsmFeatureSource({
|
||||
fetchImpl: async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
});
|
||||
const refused = await offline({ preset: 'hospital', bbox: [0, 0, 1, 1] });
|
||||
assert.deepEqual([refused.ok, refused.status], [false, 0]);
|
||||
});
|
||||
|
||||
test('server refusals keep their status and code', async () => {
|
||||
const notConfigured = createOsmFeatureSource({
|
||||
fetchImpl: async () =>
|
||||
respond(503, {
|
||||
error: 'Place search needs a configured Overpass server.',
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
}),
|
||||
});
|
||||
const refused = await notConfigured({
|
||||
preset: 'hospital',
|
||||
bbox: [0, 0, 1, 1],
|
||||
});
|
||||
assert.equal(refused.ok, false);
|
||||
assert.equal(refused.status, 503);
|
||||
assert.equal(refused.code, 'OVERPASS_NOT_CONFIGURED');
|
||||
const busy = createOsmFeatureSource({
|
||||
fetchImpl: async () => respond(429, '<html>Too Many Requests</html>'),
|
||||
});
|
||||
const limited = await busy({ preset: 'hospital', bbox: [0, 0, 1, 1] });
|
||||
assert.equal(limited.ok, false);
|
||||
assert.equal(limited.status, 429);
|
||||
assert.equal(limited.code, null);
|
||||
assert.match(limited.error, /429/);
|
||||
});
|
||||
|
||||
test('a success whose body is not JSON is a failure, not zero places', async () => {
|
||||
const garbled = createOsmFeatureSource({
|
||||
fetchImpl: async () => respond(200, '<html>proxy error</html>'),
|
||||
});
|
||||
const result = await garbled({ preset: 'hospital', bbox: [0, 0, 1, 1] });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, 200);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
|
||||
import { createEcccAirQualitySource } from '../layers/airQuality/source.js';
|
||||
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
|
||||
import { createDroneAirspaceSource } from '../layers/droneAirspace/source.js';
|
||||
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
|
||||
import { createAdsbGnssSource } from '../layers/gnss/source.js';
|
||||
|
||||
@@ -10,6 +11,7 @@ export function createReferenceSources() {
|
||||
earthquakes: createUsgsEarthquakeSource(),
|
||||
'air-quality': createEcccAirQualitySource(),
|
||||
'fire-perimeters': createWfigsPerimeterSource(),
|
||||
'drone-airspace': createDroneAirspaceSource(),
|
||||
cables: createBundledCableSource(),
|
||||
'gnss-integrity': createAdsbGnssSource(),
|
||||
};
|
||||
|
||||
@@ -16,17 +16,20 @@ test('reference factories retain compatibility without starting acquisition or s
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'fire-perimeters',
|
||||
'drone-airspace',
|
||||
'cables',
|
||||
'gnss-integrity',
|
||||
]);
|
||||
assert.notEqual(first.earthquakes, second.earthquakes);
|
||||
assert.notEqual(first['air-quality'], second['air-quality']);
|
||||
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
|
||||
assert.notEqual(first['drone-airspace'], second['drone-airspace']);
|
||||
assert.notEqual(first.cables, second.cables);
|
||||
assert.notEqual(first['gnss-integrity'], second['gnss-integrity']);
|
||||
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
|
||||
assert.equal(typeof first['air-quality'].getSnapshot, 'function');
|
||||
assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function');
|
||||
assert.equal(typeof first['drone-airspace'].getSnapshot, 'function');
|
||||
assert.equal(typeof first.cables.fetch, 'function');
|
||||
assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function');
|
||||
assert.equal(requests, 0);
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { createGeminiChatHandler } from '../server/providers/gemini/chat.js';
|
||||
import { createOpenAiChatHandler } from '../server/providers/openai/chat.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
|
||||
import {
|
||||
realtimeDirectives,
|
||||
realtimeInstructions,
|
||||
} from '../server/providers/openai/instructions.js';
|
||||
import {
|
||||
TEXT_WORDING,
|
||||
textInstructions,
|
||||
} from '../server/providers/common/textInstructions.js';
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: 'fly_to',
|
||||
description: 'Fly',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { lat: { type: 'number', minimum: -90, maximum: 90 } },
|
||||
required: ['lat'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
const providers = {
|
||||
gemini: {
|
||||
create: createGeminiChatHandler,
|
||||
input: { contents: [{ role: 'user', parts: [{ text: 'Hello' }] }] },
|
||||
reply: (bad = false) => ({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
thoughtSignature: 'opaque',
|
||||
functionCall: {
|
||||
name: 'fly_to',
|
||||
id: 'c',
|
||||
args: { lat: bad ? 100 : 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 7,
|
||||
candidatesTokenCount: 2,
|
||||
totalTokenCount: 10,
|
||||
thoughtsTokenCount: 1,
|
||||
},
|
||||
}),
|
||||
},
|
||||
openai: {
|
||||
create: createOpenAiChatHandler,
|
||||
input: { messages: [{ role: 'user', content: 'Hello' }] },
|
||||
reply: (bad = false) => ({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
type: 'function',
|
||||
id: 'c',
|
||||
function: {
|
||||
name: 'fly_to',
|
||||
arguments: bad ? '{' : '{"lat":1}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 },
|
||||
}),
|
||||
},
|
||||
};
|
||||
async function fixture(t, provider, fetchImpl, overrides = {}) {
|
||||
const calls = [];
|
||||
const handler = provider.create({
|
||||
tools,
|
||||
resolveApiKey: () => 'fixture-only',
|
||||
resolveRateLimit: () => '0',
|
||||
resolveHost: () => '',
|
||||
resolveAllowedHosts: () => '',
|
||||
environment: {},
|
||||
fetchImpl:
|
||||
fetchImpl ||
|
||||
(async (url, options) => {
|
||||
calls.push({ url, body: JSON.parse(options.body), options });
|
||||
return Response.json(provider.reply(true));
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
const server = http.createServer((req, res) => void handler(req, res));
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
t.after(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
return {
|
||||
calls,
|
||||
origin,
|
||||
raw: (options, body) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const request = http.request(origin, options, (response) => {
|
||||
let text = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk) => (text += chunk));
|
||||
response.on('end', () =>
|
||||
resolve({
|
||||
status: response.statusCode,
|
||||
headers: response.headers,
|
||||
body: text ? JSON.parse(text) : null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
request.on('error', reject);
|
||||
if (body === undefined) request.end();
|
||||
else request.end(body);
|
||||
}),
|
||||
send: (input) =>
|
||||
fetch(origin, {
|
||||
method: 'POST',
|
||||
headers: { Origin: origin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input ?? provider.input),
|
||||
}),
|
||||
};
|
||||
}
|
||||
for (const [name, provider] of Object.entries(providers)) {
|
||||
test(`${name} text route validates calls, retains usage and only accepts paired history`, async (t) => {
|
||||
const f = await fixture(t, provider);
|
||||
const response = await f.send();
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.equal(body.invalidCalls[0].index, 0);
|
||||
assert.equal(body.usage.totalTokens, 10);
|
||||
assert.equal(body.usage.outputTokens, 3);
|
||||
const nullCall = provider.reply();
|
||||
if (name === 'gemini')
|
||||
nullCall.candidates[0].content.parts[0].functionCall.args = null;
|
||||
else nullCall.choices[0].message.tool_calls[0].function.arguments = 'null';
|
||||
const nullFixture = await fixture(t, provider, async () =>
|
||||
Response.json(nullCall),
|
||||
);
|
||||
const nullBody = await (await nullFixture.send()).json();
|
||||
assert.equal(nullBody.invalidCalls.length, 1);
|
||||
assert.match(nullBody.invalidCalls[0].error, /must be an object/);
|
||||
|
||||
const input =
|
||||
name === 'gemini'
|
||||
? {
|
||||
contents: [
|
||||
...provider.input.contents,
|
||||
body.content,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'fly_to',
|
||||
id: 'c',
|
||||
response: { ok: false, error: 'Invalid' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
messages: [
|
||||
...provider.input.messages,
|
||||
body.content,
|
||||
{ role: 'tool', tool_call_id: 'c', content: '{"ok":false}' },
|
||||
],
|
||||
};
|
||||
assert.equal((await f.send(input)).status, 200);
|
||||
const orphan =
|
||||
name === 'gemini'
|
||||
? { contents: [...input.contents.slice(0, -1)] }
|
||||
: { messages: input.messages.slice(0, -1) };
|
||||
assert.equal((await f.send(orphan)).status, 400);
|
||||
assert.equal(
|
||||
(await f.send({ ...provider.input, model: 'caller-model' })).status,
|
||||
400,
|
||||
);
|
||||
if (name === 'openai') {
|
||||
assert.equal(
|
||||
f.calls[0].url,
|
||||
'https://api.openai.com/v1/chat/completions',
|
||||
);
|
||||
assert.equal(f.calls[0].body.stream, false);
|
||||
assert.equal(f.calls[0].body.n, 1);
|
||||
assert.equal(f.calls[0].body.model, 'gpt-4.1-mini');
|
||||
assert.equal(f.calls[0].body.messages[0].role, 'system');
|
||||
assert.equal(
|
||||
(await f.send({ messages: [{ role: 'system', content: 'override' }] }))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
}
|
||||
});
|
||||
test(
|
||||
`${name} client disconnect aborts upstream and cancels a late body`,
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
async (t) => {
|
||||
let finish,
|
||||
signal,
|
||||
cancelled = 0,
|
||||
ready;
|
||||
const acquired = new Promise((resolve) => (ready = resolve));
|
||||
// A deadline this far out cannot be what aborts the upstream call.
|
||||
const f = await fixture(
|
||||
t,
|
||||
provider,
|
||||
(_url, options) => {
|
||||
signal = options.signal;
|
||||
ready();
|
||||
return new Promise((resolve) => (finish = resolve));
|
||||
},
|
||||
{ timeoutMs: 1e9 },
|
||||
);
|
||||
const request = http.request(f.origin, {
|
||||
method: 'POST',
|
||||
headers: { Origin: f.origin, 'Content-Type': 'application/json' },
|
||||
});
|
||||
request.on('error', () => {});
|
||||
request.end(JSON.stringify(provider.input));
|
||||
await acquired;
|
||||
const aborted = once(signal, 'abort');
|
||||
request.destroy();
|
||||
await aborted;
|
||||
assert.equal(signal.aborted, true);
|
||||
finish({
|
||||
body: {
|
||||
cancel: async () => {
|
||||
cancelled++;
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.ok(cancelled > 0);
|
||||
},
|
||||
);
|
||||
test(`${name} invalid upstream content still returns reported usage`, async (t) => {
|
||||
const f = await fixture(t, provider, async () =>
|
||||
Response.json({
|
||||
...provider.reply(),
|
||||
...(name === 'gemini' ? { candidates: [] } : { choices: [] }),
|
||||
}),
|
||||
);
|
||||
const response = await f.send();
|
||||
assert.equal(response.status, 502);
|
||||
assert.equal((await response.json()).usage.totalTokens, 10);
|
||||
});
|
||||
test(`${name} route refuses bad methods, media types and bodies before upstream`, async (t) => {
|
||||
const f = await fixture(t, provider, undefined, { timeoutMs: 2000 });
|
||||
const json = { Origin: f.origin, 'Content-Type': 'application/json' };
|
||||
const get = await f.raw({ method: 'GET', headers: { Origin: f.origin } });
|
||||
assert.equal(get.status, 405);
|
||||
assert.equal(get.headers.allow, 'POST');
|
||||
const text = await f.raw(
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Origin: f.origin, 'Content-Type': 'text/plain' },
|
||||
},
|
||||
JSON.stringify(provider.input),
|
||||
);
|
||||
assert.equal(text.status, 415);
|
||||
// The declared length alone refuses; the short body never completes it.
|
||||
const declared = await f.raw(
|
||||
{ method: 'POST', headers: { ...json, 'Content-Length': 600 * 1024 } },
|
||||
'{}',
|
||||
);
|
||||
assert.equal(declared.status, 413);
|
||||
const streamed = await f.raw(
|
||||
{ method: 'POST', headers: { ...json, 'Transfer-Encoding': 'chunked' } },
|
||||
JSON.stringify({ ...provider.input, padding: 'x'.repeat(600 * 1024) }),
|
||||
);
|
||||
assert.equal(streamed.status, 413);
|
||||
assert.equal(f.calls.length, 0);
|
||||
});
|
||||
test(`${name} route names a missing key without calling upstream`, async (t) => {
|
||||
const f = await fixture(t, provider, undefined, {
|
||||
resolveApiKey: () => ' ',
|
||||
});
|
||||
const response = await f.send();
|
||||
assert.equal(response.status, 503);
|
||||
assert.deepEqual(await response.json(), {
|
||||
error: 'Add an API key in Provider Settings',
|
||||
code: 'API_KEY_MISSING',
|
||||
});
|
||||
assert.equal(f.calls.length, 0);
|
||||
});
|
||||
test(
|
||||
`${name} upstream deadline answers 504 and aborts the call`,
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
async (t) => {
|
||||
let signal;
|
||||
const f = await fixture(
|
||||
t,
|
||||
provider,
|
||||
(_url, options) => {
|
||||
signal = options.signal;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
{ timeoutMs: 50 },
|
||||
);
|
||||
const response = await f.send();
|
||||
assert.equal(response.status, 504);
|
||||
assert.equal(signal.aborted, true);
|
||||
},
|
||||
);
|
||||
test(`${name} route refuses redirects and unexpected destinations`, async (t) => {
|
||||
const f = await fixture(t, provider);
|
||||
assert.equal((await f.send()).status, 200);
|
||||
assert.equal(f.calls[0].options.redirect, 'error');
|
||||
const moved = await fixture(t, provider, async () => {
|
||||
const response = Response.json(provider.reply());
|
||||
Object.defineProperty(response, 'url', {
|
||||
value: 'https://example.test/',
|
||||
});
|
||||
return response;
|
||||
});
|
||||
assert.equal((await moved.send()).status, 502);
|
||||
const redirected = await fixture(t, provider, async () => {
|
||||
const response = Response.json(provider.reply());
|
||||
Object.defineProperty(response, 'redirected', { value: true });
|
||||
return response;
|
||||
});
|
||||
assert.equal((await redirected.send()).status, 502);
|
||||
});
|
||||
test(`${name} route caps the upstream response and never echoes the key`, async (t) => {
|
||||
const large = await fixture(t, provider, async () =>
|
||||
Response.json({ ...provider.reply(), padding: 'x'.repeat(600 * 1024) }),
|
||||
);
|
||||
assert.equal((await large.send()).status, 502);
|
||||
const echo = provider.reply();
|
||||
if (name === 'gemini')
|
||||
echo.candidates[0].content.parts = [{ text: 'key fixture-only' }];
|
||||
else
|
||||
echo.choices[0].message = {
|
||||
role: 'assistant',
|
||||
content: 'key fixture-only',
|
||||
};
|
||||
const echoed = await fixture(t, provider, async () => Response.json(echo));
|
||||
const response = await echoed.send();
|
||||
assert.equal(response.status, 502);
|
||||
assert.ok(!(await response.text()).includes('fixture-only'));
|
||||
});
|
||||
test(`${name} upstream 429 and 503 keep their status and a sane Retry-After`, async (t) => {
|
||||
for (const [status, retryAfter, expected] of [
|
||||
[429, '7', '7'],
|
||||
[503, '7200', '3600'],
|
||||
[429, 'Wed, 21 Oct 2015 07:28:00 GMT', undefined],
|
||||
[500, '7', undefined],
|
||||
]) {
|
||||
const f = await fixture(t, provider, async () =>
|
||||
Response.json({}, { status, headers: { 'Retry-After': retryAfter } }),
|
||||
);
|
||||
const response = await f.send();
|
||||
assert.equal(response.status, status === 500 ? 502 : status);
|
||||
assert.equal(response.headers.get('retry-after') ?? undefined, expected);
|
||||
assert.deepEqual(await response.json(), {
|
||||
error: 'Could not connect. Try again.',
|
||||
...(status === 429 ? { code: 'RATE_LIMITED' } : {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
test(`${name} rejected key cools down until it changes or the window ends`, async (t) => {
|
||||
for (const status of [401, 403]) {
|
||||
let clock = 1_000_000;
|
||||
let key = 'fixture-only';
|
||||
let upstream = 0;
|
||||
const f = await fixture(
|
||||
t,
|
||||
provider,
|
||||
async () => {
|
||||
upstream++;
|
||||
return Response.json({}, { status });
|
||||
},
|
||||
{ now: () => clock, resolveApiKey: () => key },
|
||||
);
|
||||
assert.equal((await f.send()).status, 502);
|
||||
const cooling = await f.send();
|
||||
assert.equal(cooling.status, 502);
|
||||
assert.equal(cooling.headers.get('retry-after'), '60');
|
||||
assert.equal(upstream, 1, 'a rejected key must not reach upstream again');
|
||||
key = 'replacement-key';
|
||||
assert.equal((await f.send()).status, 502);
|
||||
assert.equal(upstream, 2, 'a new key is tried at once');
|
||||
clock += 59_000;
|
||||
assert.equal((await f.send()).status, 502);
|
||||
assert.equal(upstream, 2);
|
||||
clock += 1_000;
|
||||
assert.equal((await f.send()).status, 502);
|
||||
assert.equal(upstream, 3, 'the cooldown ends after a minute');
|
||||
}
|
||||
});
|
||||
test(`${name} an invalid-key 400 cools down like 401, and every refusal names the key`, async (t) => {
|
||||
let upstream = 0;
|
||||
let body = {
|
||||
error: {
|
||||
code: 400,
|
||||
status: 'INVALID_ARGUMENT',
|
||||
details: [{ reason: 'API_KEY_INVALID' }],
|
||||
},
|
||||
};
|
||||
const f = await fixture(t, provider, async () => {
|
||||
upstream++;
|
||||
return Response.json(body, { status: 400 });
|
||||
});
|
||||
const first = await f.send();
|
||||
assert.equal(first.status, 502);
|
||||
assert.equal((await first.json()).code, 'API_KEY_REJECTED');
|
||||
const cooling = await f.send();
|
||||
assert.equal((await cooling.json()).code, 'API_KEY_REJECTED');
|
||||
assert.equal(upstream, 1, 'the rejected key is not sent again');
|
||||
// Any other 400 is a plain failure and does not cool the key down.
|
||||
body = { error: { code: 400, status: 'INVALID_ARGUMENT' } };
|
||||
const plain = await fixture(t, provider, async () => {
|
||||
upstream++;
|
||||
return Response.json(body, { status: 400 });
|
||||
});
|
||||
const other = await plain.send();
|
||||
assert.equal(other.status, 502);
|
||||
assert.equal((await other.json()).code, undefined);
|
||||
await plain.send();
|
||||
assert.equal(upstream, 3);
|
||||
});
|
||||
test(`${name} a 403 or an out-of-credit 429 cools down with its own code`, async (t) => {
|
||||
for (const [status, body, code] of [
|
||||
[
|
||||
403,
|
||||
{ error: { code: 'unsupported_country_region_territory' } },
|
||||
'ACCESS_DENIED',
|
||||
],
|
||||
[429, { error: { code: 'insufficient_quota' } }, 'QUOTA_EXCEEDED'],
|
||||
]) {
|
||||
let upstream = 0;
|
||||
const f = await fixture(t, provider, async () => {
|
||||
upstream++;
|
||||
return Response.json(body, { status });
|
||||
});
|
||||
const first = await f.send();
|
||||
assert.equal(first.status, 502);
|
||||
assert.equal((await first.json()).code, code);
|
||||
const cooling = await f.send();
|
||||
assert.equal((await cooling.json()).code, code);
|
||||
assert.equal(upstream, 1, `${code} is not retried at once`);
|
||||
}
|
||||
});
|
||||
test(`${name} only an exact invalid-key reason marks a 400 as a rejected key`, async (t) => {
|
||||
let upstream = 0;
|
||||
const f = await fixture(t, provider, async () => {
|
||||
upstream++;
|
||||
return Response.json(
|
||||
{ error: { code: 400, message: 'Field API_KEY_INVALID is unknown' } },
|
||||
{ status: 400 },
|
||||
);
|
||||
});
|
||||
const reply = await f.send();
|
||||
assert.equal(reply.status, 502);
|
||||
assert.equal((await reply.json()).code, undefined);
|
||||
await f.send();
|
||||
assert.equal(upstream, 2);
|
||||
});
|
||||
test(`${name} refusal bodies are read within 16 KB and released when unread`, async (t) => {
|
||||
// An oversized error body is not parsed, so its reason does not count.
|
||||
const padded = await fixture(t, provider, async () =>
|
||||
Response.json(
|
||||
{
|
||||
error: {
|
||||
message: 'x'.repeat(20 * 1024),
|
||||
details: [{ reason: 'API_KEY_INVALID' }],
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
);
|
||||
const reply = await padded.send();
|
||||
assert.equal(reply.status, 502);
|
||||
assert.equal((await reply.json()).code, undefined);
|
||||
// A 401 body is never read, and is cancelled rather than left open.
|
||||
let cancelled = false;
|
||||
const unread = await fixture(
|
||||
t,
|
||||
provider,
|
||||
async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull: () => {},
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 401 },
|
||||
),
|
||||
);
|
||||
assert.equal((await (await unread.send()).json()).code, 'API_KEY_REJECTED');
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
test(`${name} offers osm_query, and names it, only with Overpass, as voice does`, async (t) => {
|
||||
for (const overpass of [false, true]) {
|
||||
const f = await fixture(t, provider, undefined, {
|
||||
tools: GEV_REALTIME_TOOLS,
|
||||
overpassConfigured: () => overpass,
|
||||
});
|
||||
assert.equal((await f.send()).status, 200);
|
||||
const { body } = f.calls[0];
|
||||
const offered =
|
||||
name === 'openai'
|
||||
? body.tools.map((tool) => tool.function.name)
|
||||
: body.tools[0].functionDeclarations.map((tool) => tool.name);
|
||||
const prompt =
|
||||
name === 'openai'
|
||||
? body.messages[0].content
|
||||
: body.systemInstruction.parts[0].text;
|
||||
assert.equal(offered.includes('osm_query'), overpass);
|
||||
assert.equal(prompt.includes('osm_query'), overpass);
|
||||
assert.ok(offered.includes('resolve_area'));
|
||||
}
|
||||
});
|
||||
test(`${name} throttled replies say they are rate limited`, async (t) => {
|
||||
const local = await fixture(t, provider, undefined, {
|
||||
resolveRateLimit: () => '1',
|
||||
});
|
||||
await local.send();
|
||||
const limited = await local.send();
|
||||
assert.equal(limited.status, 429);
|
||||
assert.equal((await limited.json()).code, 'RATE_LIMITED');
|
||||
const upstream = await fixture(t, provider, async () =>
|
||||
Response.json({}, { status: 429, headers: { 'Retry-After': '7' } }),
|
||||
);
|
||||
const busy = await upstream.send();
|
||||
assert.equal(busy.status, 429);
|
||||
assert.equal(busy.headers.get('retry-after'), '7');
|
||||
assert.equal((await busy.json()).code, 'RATE_LIMITED');
|
||||
});
|
||||
test(`${name} chat throttle follows the provider throttle unless set`, async (t) => {
|
||||
const burst = async (overrides, count) => {
|
||||
const f = await fixture(t, provider, undefined, overrides);
|
||||
const statuses = [];
|
||||
for (let i = 0; i < count; i++) statuses.push((await f.send()).status);
|
||||
return { statuses, calls: f.calls.length, last: f };
|
||||
};
|
||||
const unset = await burst(
|
||||
{
|
||||
resolveRateLimit: () => undefined,
|
||||
resolveProviderRateLimit: () => undefined,
|
||||
},
|
||||
31,
|
||||
);
|
||||
assert.equal(
|
||||
unset.calls,
|
||||
30,
|
||||
'the default cap is the provider default, 30',
|
||||
);
|
||||
assert.equal(unset.statuses.at(-1), 429);
|
||||
const follows = await burst(
|
||||
{ resolveRateLimit: () => '', resolveProviderRateLimit: () => '2' },
|
||||
3,
|
||||
);
|
||||
assert.deepEqual(follows.statuses, [200, 200, 429]);
|
||||
const own = await burst(
|
||||
{ resolveRateLimit: () => '3', resolveProviderRateLimit: () => '2' },
|
||||
4,
|
||||
);
|
||||
assert.deepEqual(own.statuses, [200, 200, 200, 429]);
|
||||
const off = await burst(
|
||||
{ resolveRateLimit: () => '0', resolveProviderRateLimit: () => '2' },
|
||||
4,
|
||||
);
|
||||
assert.deepEqual(off.statuses, [200, 200, 200, 200]);
|
||||
const capped = await fixture(t, provider, undefined, {
|
||||
resolveRateLimit: () => '1',
|
||||
});
|
||||
assert.equal((await capped.send()).status, 200);
|
||||
const refused = await capped.send();
|
||||
assert.equal(refused.status, 429);
|
||||
assert.equal(refused.headers.get('retry-after'), '60');
|
||||
assert.deepEqual(await refused.json(), {
|
||||
error: 'Could not connect. Try again.',
|
||||
code: 'RATE_LIMITED',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('text adapts speech wording while retaining shared Radio and annotation policies', () => {
|
||||
const directives = realtimeDirectives();
|
||||
assert.equal(realtimeInstructions(), directives.join('\n'));
|
||||
const text = textInstructions();
|
||||
assert.match(text, /text-only chat/);
|
||||
assert.ok(!text.includes('mic session is active'));
|
||||
for (const line of directives.filter(
|
||||
(line) =>
|
||||
(line.includes('radio') && !line.startsWith('PREAMBLES:')) ||
|
||||
line.startsWith('annotate_map results:'),
|
||||
))
|
||||
assert.ok(text.includes(line));
|
||||
});
|
||||
|
||||
test('every spoken phrase chat rewrites still occurs in the voice directives', () => {
|
||||
const voice = realtimeDirectives().join('\n');
|
||||
const text = textInstructions();
|
||||
for (const [spoken, written] of TEXT_WORDING) {
|
||||
assert.ok(voice.includes(spoken), `voice directives lost "${spoken}"`);
|
||||
assert.ok(!text.includes(spoken), `chat still says "${spoken}"`);
|
||||
assert.ok(text.includes(written), `chat lacks "${written}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('chat throttles read their own variables and fall back per provider', async (t) => {
|
||||
const names = [
|
||||
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
|
||||
'GEV_RATELIMIT_OPENAI_PER_MIN',
|
||||
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
|
||||
'GEV_RATELIMIT_GEMINI_PER_MIN',
|
||||
];
|
||||
const saved = Object.fromEntries(names.map((key) => [key, process.env[key]]));
|
||||
t.after(() => {
|
||||
for (const [key, value] of Object.entries(saved))
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
});
|
||||
const allowed = async (provider, count) => {
|
||||
const f = await fixture(t, provider, undefined, {
|
||||
resolveRateLimit: undefined,
|
||||
});
|
||||
let ok = 0;
|
||||
for (let i = 0; i < count; i++) if ((await f.send()).status === 200) ok++;
|
||||
return ok;
|
||||
};
|
||||
for (const key of names) delete process.env[key];
|
||||
process.env.GEV_RATELIMIT_OPENAI_PER_MIN = '2';
|
||||
process.env.GEV_RATELIMIT_GEMINI_PER_MIN = '3';
|
||||
assert.equal(await allowed(providers.openai, 5), 2);
|
||||
assert.equal(await allowed(providers.gemini, 5), 3);
|
||||
process.env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN = '4';
|
||||
process.env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN = '0';
|
||||
assert.equal(await allowed(providers.openai, 6), 4);
|
||||
assert.equal(await allowed(providers.gemini, 6), 6);
|
||||
});
|
||||
|
||||
test('the ChatGPT OAuth lock refuses OpenAI chat but not Gemini chat', async (t) => {
|
||||
for (const [value, code] of [
|
||||
['true', 'CODEX_OAUTH_REQUIRED'],
|
||||
['maybe', 'CODEX_OAUTH_REQUIRED'],
|
||||
['false', undefined],
|
||||
]) {
|
||||
const environment = { GEV_PREFER_CODEX_OAUTH: value };
|
||||
const openai = await fixture(t, providers.openai, undefined, {
|
||||
environment,
|
||||
});
|
||||
const response = await openai.send();
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, code ? 503 : 200);
|
||||
assert.equal(body.code, code);
|
||||
if (code) {
|
||||
assert.equal(typeof body.error, 'string');
|
||||
assert.equal(openai.calls.length, 0, 'the API key must not be billed');
|
||||
}
|
||||
const gemini = await fixture(t, providers.gemini, undefined, {
|
||||
environment,
|
||||
});
|
||||
assert.equal((await gemini.send()).status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenAI empty tool arrays are removed before history is returned', async (t) => {
|
||||
const f = await fixture(t, providers.openai, async () =>
|
||||
Response.json({
|
||||
choices: [
|
||||
{
|
||||
message: { role: 'assistant', content: 'Hello', tool_calls: [] },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
);
|
||||
const body = await (await f.send()).json();
|
||||
assert.equal(body.content.tool_calls, undefined);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// THE BOUNDED ANSWER CACHE — memory and disk bounds for the Nominatim area
|
||||
// and OSM routes. Every memory insertion is bounded (disk hits included), and
|
||||
// the disk keeps to its entry and byte quotas, removing expired files first.
|
||||
//
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { createBoundedCache } from '../../server/providers/common/boundedCache.js';
|
||||
|
||||
async function tempDir() {
|
||||
return fsp.mkdtemp(path.join(os.tmpdir(), 'gev-bounded-cache-'));
|
||||
}
|
||||
|
||||
test('disk hits are bounded in memory like fresh answers', async () => {
|
||||
const dir = await tempDir();
|
||||
const writer = createBoundedCache({
|
||||
dir,
|
||||
ttlMs: 60_000,
|
||||
maxMemoryEntries: 100,
|
||||
maxDiskEntries: 200,
|
||||
});
|
||||
for (let i = 0; i < 75; i += 1) await writer.set(`k${i}`, `v${i}`);
|
||||
const reader = createBoundedCache({
|
||||
dir,
|
||||
ttlMs: 60_000,
|
||||
maxMemoryEntries: 10,
|
||||
maxDiskEntries: 200,
|
||||
});
|
||||
for (let i = 0; i < 75; i += 1)
|
||||
assert.equal(await reader.get(`k${i}`), `v${i}`);
|
||||
assert.equal(reader.memorySize, 10);
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('the disk keeps to its entry and byte quotas and drops expired files', async () => {
|
||||
const dir = await tempDir();
|
||||
let clock = 1_000_000;
|
||||
const cache = createBoundedCache({
|
||||
dir,
|
||||
ttlMs: 10_000,
|
||||
maxDiskEntries: 5,
|
||||
maxDiskBytes: 10_000,
|
||||
now: () => clock,
|
||||
});
|
||||
for (let i = 0; i < 12; i += 1) await cache.set(`k${i}`, 'x'.repeat(100));
|
||||
await cache.prune();
|
||||
let files = await fsp.readdir(dir);
|
||||
assert.ok(files.length <= 5, `${files.length} files`);
|
||||
await cache.set('big', 'y'.repeat(20_000));
|
||||
await cache.prune();
|
||||
const sizes = await Promise.all(
|
||||
(await fsp.readdir(dir)).map(
|
||||
async (name) => (await fsp.stat(path.join(dir, name))).size,
|
||||
),
|
||||
);
|
||||
assert.ok(sizes.reduce((a, b) => a + b, 0) <= 10_000, 'byte quota holds');
|
||||
// Age every file past the TTL, then write once more: the old files go.
|
||||
const past = new Date(Date.now() - 3_600_000);
|
||||
for (const name of await fsp.readdir(dir))
|
||||
await fsp.utimes(path.join(dir, name), past, past);
|
||||
clock = Date.now();
|
||||
await cache.set('fresh', 'z');
|
||||
await cache.prune();
|
||||
files = await fsp.readdir(dir);
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(await cache.get('k0'), undefined);
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('an expired entry is not served', async () => {
|
||||
let clock = 0;
|
||||
const cache = createBoundedCache({ dir: null, ttlMs: 100, now: () => clock });
|
||||
await cache.set('a', 1);
|
||||
clock = 50;
|
||||
assert.equal(await cache.get('a'), 1);
|
||||
clock = 500;
|
||||
assert.equal(await cache.get('a'), undefined);
|
||||
});
|
||||
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user