Merge branch 'main' into fix/globe-free-look-orbit

This commit is contained in:
Sameh Khamis committed 2026-11-05 15:52:21 -08:00
commit f53342932d
205 files changed
+17659 -465

No files matched your search

+4
View File
@@ -29,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';
@@ -133,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,
+3 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 36);
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'),
@@ -67,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(
@@ -160,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, 36);
assert.equal(catalog.layers.length, 37);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+22
View File
@@ -0,0 +1,22 @@
import * as Cesium from 'cesium';
import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire FAA special-use airspace into the application catalog. */
export function createApplicationDroneAirspace(options) {
return createDroneAirspaceLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+1
View File
@@ -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']),
});
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 36);
assert.equal(actual.layers.length, 37);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+19 -6
View File
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
ui.indexOf("if (normalized === 'exit') {"),
);
assert.ok(branch, 'cockpit enter branch is missing');
const gateIndex = branch.indexOf('isEntryAllowed?.()');
const gateIndex = branch.indexOf('this._cockpitEntryRefusal()');
const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
assert.ok(
gateIndex >= 0,
'entry consults the same gate the manual chip uses',
'entry refuses on the gate it can check before tracking',
);
assert.ok(
retargetIndex > gateIndex,
@@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
ui.indexOf("if (normalized === 'enter') {"),
ui.indexOf("if (normalized === 'exit') {"),
);
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/);
assert.match(branch, /Contacts must be active to enter Cockpit/);
assert.match(branch, /Contacts is still starting up/);
// Contacts is not required: only a Context change in flight or no aircraft
// layer to ride shuts the gate before tracking, and the entry itself then
// applies the manual chip's gate to the aircraft it rides.
assert.match(
branch,
/const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/,
);
assert.doesNotMatch(branch, /Contacts must be active/);
const refusal = ui.slice(
ui.indexOf(' _cockpitEntryRefusal() {'),
ui.indexOf(' getCockpitState() {'),
);
assert.match(refusal, /Context is still changing/);
assert.match(refusal, /No aircraft layer is on/);
assert.doesNotMatch(refusal, /[Cc]ontacts/);
});
test('cockpit state cannot report entryAllowed while already active', () => {
@@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => {
);
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
assert.match(state, /'contacts-starting'/);
assert.match(state, /'contacts-inactive'/);
assert.match(state, /'subject-layer-off'/);
assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required');
assert.match(state, /'no-tracked-aircraft'/);
});
+34
View File
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
],
});
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
const f = fixture(t);
const crowded = {
...snapshot(),
cohorts: [
{
id: 'flights',
count: 6,
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
id: `n${n}`,
label: `N${n}`,
distanceM: n * 1000,
})),
},
],
};
updateCockpitSignals.call(f.owner, crowded, 0, {
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
});
await nextTick();
assert.equal(f.owner.signalItems.length, 5);
assert.equal(f.owner.signalItems[0].key, 'feed-status');
assert.equal(f.owner.signalItems[0].tone, 'warning');
assert.equal(f.owner.signalItems[0].target, null);
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
updateCockpitSignals.call(f.owner, crowded, 0);
assert.equal(
f.owner.signalItems.some((item) => item.key === 'feed-status'),
false,
'with the feed back, the row goes',
);
});
test('rows render the signals the controller presents', async (t) => {
const f = fixture(t);
await render(f, [
+23 -16
View File
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
return restored;
}
/** The layers an aircraft Cockpit subject can come from. */
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
'flights',
'military',
]);
/**
* Cockpit entry belongs to the operational Contacts context bundle. A tracked
* aircraft alone is not sufficient: both observed-flight feeds must still be
* active so the cockpit's surrounding-contact picture is not presented as a
* complete context view when one source is disabled.
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
* (Live Flights for a civilian, Military Flights for a military aircraft) must
* be on. Contacts and the other feeds are not required. When a source the
* surrounding picture uses is off, the Cockpit's Contact card and Live
* Signals say the picture is partial instead of blocking entry, so a partial
* picture is never presented as complete. A Context mode change in flight
* still owns the camera, so entry waits it out.
*
* @param {object} input Current context and dependency visibility.
* @param {string|null} input.contextMode Active Context mode.
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled.
* @param {object} input Current subject and Context state.
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
* ('flights' or 'military'), or null with none tracked.
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
* @returns {boolean} Whether cockpit entry may be offered or activated.
*/
export function cockpitEntryAllowed({
contextMode,
subjectLayerId,
subjectLayerEnabled,
contextModeChanging,
flightsEnabled,
militaryEnabled,
}) {
return (
contextMode === 'flights' &&
!contextModeChanging &&
Boolean(flightsEnabled) &&
Boolean(militaryEnabled)
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
Boolean(subjectLayerEnabled) &&
!contextModeChanging
);
}
+18 -23
View File
@@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued
}
});
test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => {
test("cockpit entry needs only the tracked aircraft's own layer", () => {
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: true,
}), true);
}), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: 'military',
subjectLayerEnabled: true,
contextModeChanging: false,
}), true, 'a military aircraft needs Military Flights alone');
assert.equal(cockpitEntryAllowed({
subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: true,
flightsEnabled: true,
militaryEnabled: true,
}), false, 'Cockpit remains unavailable until Contacts activation settles');
}), false, 'Cockpit remains unavailable until a Context mode change settles');
assert.equal(cockpitEntryAllowed({
contextMode: null,
subjectLayerId: 'military',
subjectLayerEnabled: false,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: true,
}), false, 'ordinary layer selection is not an operational Contacts context');
}), false, "the ridden aircraft's own layer is required");
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: null,
subjectLayerEnabled: false,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: false,
}), false, 'the military contact feed is required');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
contextModeChanging: false,
flightsEnabled: false,
militaryEnabled: true,
}), false, 'the civilian contact feed is required');
}), false, 'there is no aircraft to ride');
});
test('context teardown guard restores coordination state after success and failure', async () => {
+90
View File
@@ -2,9 +2,13 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import { geminiLiveProxy } from '../server/providers/gemini.js';
import { GEMINI_DEFAULT_PER_MIN } from '../server/providers/gemini/chat.js';
import {
makeCostRateLimiter,
resolvePerMinuteCap,
@@ -168,6 +172,92 @@ test('the realtime token route throttles by default, and says so in a 429', asyn
);
});
function chatRequest(handler, input) {
const req = Readable.from([Buffer.from(JSON.stringify(input))]);
Object.assign(req, {
method: 'POST',
url: '/',
headers: {
host: 'localhost:4173',
origin: 'http://localhost:4173',
'content-type': 'application/json',
},
socket: { remoteAddress: '127.0.0.1' },
});
const headers = {};
const res = Object.assign(new EventEmitter(), {
statusCode: 200,
setHeader(name, value) {
headers[name.toLowerCase()] = value;
},
});
return new Promise((resolve, reject) => {
res.end = (body = '') => {
res.writableEnded = true;
resolve({ status: res.statusCode, headers, body: String(body) });
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
test('typed chat follows each provider throttle by default, before upstream', async (t) => {
for (const name of [
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
'GEV_RATELIMIT_OPENAI_PER_MIN',
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
'GEV_RATELIMIT_GEMINI_PER_MIN',
'GEV_PREFER_CODEX_OAUTH',
])
env(t, name, undefined);
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEMINI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
t.mock.method(globalThis, 'fetch', async (url) => {
upstreamCalls += 1;
return Response.json(
String(url).includes('openai')
? { choices: [{ message: { role: 'assistant', content: 'Hi' } }] }
: {
candidates: [
{ content: { role: 'model', parts: [{ text: 'Hi' }] } },
],
},
);
});
// The chat fallback must track the Gemini token route's own default.
const tokenDefault = readFileSync(
new URL('../server/providers/gemini/realtime.js', import.meta.url),
'utf8',
).match(/GEV_RATELIMIT_GEMINI_PER_MIN \?\? '(\d+)'/)?.[1];
assert.equal(Number(tokenDefault), GEMINI_DEFAULT_PER_MIN);
for (const [route, handler, cap, input] of [
[
'/api/openai/chat',
install(openAiRealtimeProxy({})).get('/api/openai/chat'),
OPENAI_DEFAULT_PER_MIN,
{ messages: [{ role: 'user', content: 'Hello' }] },
],
[
'/api/gemini/chat',
install(geminiLiveProxy({})).get('/api/gemini/chat'),
GEMINI_DEFAULT_PER_MIN,
{ contents: [{ role: 'user', parts: [{ text: 'Hello' }] }] },
],
]) {
upstreamCalls = 0;
const statuses = [];
for (let i = 0; i < cap; i += 1)
statuses.push((await chatRequest(handler, input)).status);
assert.deepEqual(statuses, Array(cap).fill(200), route);
const overLimit = await chatRequest(handler, input);
assert.equal(overLimit.status, 429, route);
assert.equal(overLimit.headers['retry-after'], '60');
assert.equal(overLimit.body.includes('fixture-upstream-secret'), false);
assert.equal(upstreamCalls, cap, `${route} refuses before upstream`);
}
});
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
+403
View File
@@ -0,0 +1,403 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
houstonCameraName,
houstonCameraToSource,
loadHoustonSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_HOUSTON_MAX_SOURCES,
HOUSTON_CAMERAS_URL,
HOUSTON_GROUND_ELEVATION_M,
HOUSTON_IMAGE_ORIGIN,
HOUSTON_IMAGE_URL,
HOUSTON_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
CAMERA_CODE_MAX_CHARS,
isLikelyHoustonCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{'));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const DOWNTOWN_ID = '1002';
const GALLERIA_ID = '2001';
/** One TranStar camera row, shaped like the live `cctvSnapshots_json.js` payload. */
const row = (overrides = {}) => ({
location: '10 EAST @ SAN JACINTO',
id: DOWNTOWN_ID,
lat: '29.76824',
lng: '-95.355711',
dir: 'South',
fc: '6',
fp: '300',
url: "showWin('/layers/gc.aspx?cam=1002&loc=IH-10_East_at_SAN_JACINTO')",
...overrides,
});
test('a Houston row maps to a source on the pinned HTTPS frame host', () => {
const source = houstonCameraToSource(row());
assert.equal(source.id, `houston-${DOWNTOWN_ID}`);
assert.equal(source.name, '10 EAST @ SAN JACINTO');
assert.equal(source.city, 'Houston');
assert.equal(source.cityId, 'houston');
assert.equal(source.provider, 'Houston TranStar');
assert.equal(source.lat, 29.76824);
assert.equal(source.lon, -95.355711);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'houston-transtar');
assert.equal(
source.license,
'TranStar disclaimer — redistribution and external framing need TxDOT written permission',
);
assert.equal(source.groundElevationM, HOUSTON_GROUND_ELEVATION_M);
assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID));
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(HOUSTON_IMAGE_ORIGIN));
// dir South = the camera sits south of I-10; the nearest lanes run east.
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg, 90);
});
test('frame URLs are synthesized from the numeric id; the payload URL is ignored', () => {
const source = houstonCameraToSource(
row({ url: 'https://evil.example/cameras/x/image' }),
);
assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID));
assert.equal(source.snapshotUrl, source.url);
});
test('zero ids, off-city geometry and unusable ids are dropped', () => {
assert.equal(houstonCameraToSource(row({ id: '0' })), null);
assert.equal(houstonCameraToSource(row({ id: '00' })), null);
assert.equal(houstonCameraToSource(row({ id: '../etc/passwd' })), null);
assert.equal(houstonCameraToSource(row({ id: '12' })), null);
assert.equal(houstonCameraToSource(row({ id: '1002/../x' })), null);
// Dallas: a plausible lat/lon, but not a Houston camera.
assert.equal(
houstonCameraToSource(row({ lat: '32.7767', lng: '-96.797' })),
null,
);
assert.equal(houstonCameraToSource(row({ lat: '0', lng: '0' })), null);
assert.equal(houstonCameraToSource(null), null);
// 3-digit and 5-digit snapshot ids stay.
assert.equal(houstonCameraToSource(row({ id: '102' })).id, 'houston-102');
assert.equal(houstonCameraToSource(row({ id: '13711' })).id, 'houston-13711');
});
test('dir is the side of the road: it gives a low-confidence travel heading', () => {
// TranStar's map reads dir as "Traffic closest to the camera is traveling":
// South -> Eastbound, North -> Westbound, West -> Southbound, East -> Northbound.
const expected = { South: 90, North: 270, West: 180, East: 0, south: 90 };
for (const [dir, heading] of Object.entries(expected)) {
const source = houstonCameraToSource(row({ dir }));
assert.equal(source.headingDeg, heading, `dir ${dir}`);
assert.equal(source.headingConfidence, 'low', `dir ${dir}`);
assert.equal(source.pitchDeg, -18, `dir ${dir}`);
assert.equal(source.fovDeg, 44, `dir ${dir}`);
assert.equal(source.rangeM, 145, `dir ${dir}`);
assert.equal(source.mountHeightM, 8, `dir ${dir}`);
}
const none = houstonCameraToSource(row({ dir: 'None' }));
assert.equal(none.headingConfidence, 'low');
assert.ok(Number.isFinite(none.headingDeg));
assert.equal(
none.headingDeg,
houstonCameraToSource(row({ dir: '' })).headingDeg,
);
for (const dir of ['Northeast', 'constructor', '__proto__', undefined]) {
assert.equal(
houstonCameraToSource(row({ dir })).headingDeg,
none.headingDeg,
`dir ${dir} falls back to the id hash`,
);
}
});
test('a nameless row still gets a label', () => {
assert.equal(
houstonCameraName({ location: ' 45 GULF @ WEST DALLAS ' }, 'houston-102'),
'45 GULF @ WEST DALLAS',
);
assert.equal(houstonCameraName({}, 'houston-102'), 'Houston Camera 102');
assert.equal(houstonCameraName({}, ''), 'Houston Camera');
});
test('the unselected label is the interchange, trimmed to the code width', () => {
assert.equal(houstonCameraToSource(row()).code, '10 EAST @ SAN JACINTO');
const long = houstonCameraToSource(
row({
location:
'IH-10 Katy Managed Lanes Eastbound at a very long interchange name that must truncate',
}),
);
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.headers.Accept, init.redirect]);
return Response.json({
cameras: [
row(),
row({ location: '10 EAST @ SAN JACINTO (duplicate)' }),
row({
id: GALLERIA_ID,
location: '610 WEST LOOP @ SAN FELIPE',
lat: '29.749',
lng: '-95.461',
dir: 'North',
}),
row({ url: 'https://evil.example/x.jpg', id: '0' }),
],
});
});
const cameras = await loadHoustonSourcesFromOpenData();
assert.deepEqual(requested, [[HOUSTON_CAMERAS_URL, '*/*', 'manual']]);
assert.deepEqual(
cameras.map((camera) => camera.id),
[`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`],
'nearest-to-anchor first: Uptown sits closer to its pin than downtown does',
);
assert.equal(cameras[1].name, '10 EAST @ SAN JACINTO');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.js' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
assert.deepEqual(seen, [[HOUSTON_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify({ cameras: [row()] });
return new Response(body, {
headers: {
'Content-Type': 'application/javascript',
'content-length': String(HOUSTON_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
// Valid JSON with a usable camera, so only the byte cap can refuse it.
const oversized = JSON.stringify({
cameras: [row()],
padding: 'x'.repeat(HOUSTON_MAX_CATALOG_BYTES + 1024),
});
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/javascript' } },
);
});
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
});
test('a reduced pack cap keeps cameras nearest the metro anchors', async (t) => {
t.mock.method(console, 'log', () => {});
const saved = process.env.CCTV_HOUSTON_MAX_SOURCES;
t.after(() => {
if (saved === undefined) delete process.env.CCTV_HOUSTON_MAX_SOURCES;
else process.env.CCTV_HOUSTON_MAX_SOURCES = saved;
});
process.env.CCTV_HOUSTON_MAX_SOURCES = '8';
const rows = [
...[29.32, 29.322, 29.324, 29.326, 29.328, 29.33].map((lat, i) =>
row({
id: String(3000 + i),
lat: String(lat),
lng: '-94.80',
location: `Galveston ${i}`,
dir: 'East',
}),
),
...[29.76, 29.759, 29.758, 29.757, 29.756, 29.755].map((lat, i) =>
row({
id: String(4000 + i),
lat: String(lat),
lng: '-95.3698',
location: `Downtown ${i}`,
dir: 'North',
}),
),
];
t.mock.method(globalThis, 'fetch', async () =>
Response.json({ cameras: rows }),
);
const cameras = await loadHoustonSourcesFromOpenData();
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras.map((camera) => camera.name.slice(0, 8)),
[
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Downtown',
'Galvesto',
'Galvesto',
],
'downtown fills first; the Galveston anchor still keeps a share',
);
});
/**
* Serve the Houston catalog to the Houston endpoint and an empty payload to
* every other pack, so one catalog refresh exercises the registration without
* reaching the network.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === HOUSTON_CAMERAS_URL) {
return Response.json({
cameras: [
row(),
row({
id: GALLERIA_ID,
location: '610 WEST LOOP @ SAN FELIPE',
lat: '29.749',
lng: '-95.461',
dir: 'West',
}),
],
});
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the Houston lane is wired into the catalog and its loader runs', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_HOUSTON_ENABLED = '1';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(HOUSTON_CAMERAS_URL),
'the catalog refresh invokes the Houston loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'houston').map((s) => s.id),
[`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`],
'Houston cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
for (const value of [undefined, '0']) {
if (value === undefined) delete process.env.CCTV_HOUSTON_ENABLED;
else process.env.CCTV_HOUSTON_ENABLED = value;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(HOUSTON_CAMERAS_URL),
false,
'the pack reaches TranStar only after an explicit opt-in',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'houston'),
[],
);
}
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
});
test('isLikelyHoustonCoordinate spans the metro and rejects the rest', () => {
assert.equal(isLikelyHoustonCoordinate(29.7604, -95.3698), true); // Downtown
assert.equal(isLikelyHoustonCoordinate(29.3013, -94.7977), true); // Galveston
assert.equal(isLikelyHoustonCoordinate(29.9902, -95.3368), true); // IAH
assert.equal(isLikelyHoustonCoordinate(32.7767, -96.797), false); // Dallas
assert.equal(isLikelyHoustonCoordinate(30.2672, -97.7431), false); // Austin
assert.equal(isLikelyHoustonCoordinate(0, 0), false);
assert.equal(isLikelyHoustonCoordinate(NaN, -95.37), false);
});
+2
View File
@@ -34,6 +34,8 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
'ontario 511': 3 * 60 * 1000,
// TxDOT publishes roughly once a minute; 3 min matches the other highway packs.
txdot: 3 * 60 * 1000,
// TranStar says snapshots update about every 3 minutes.
'houston transtar': 3 * 60 * 1000,
// Digitraffic publishes a new weathercam frame on each station's
// collectionInterval, 600 s for every station sampled; polling faster only
// re-fetches the same JPEG.
+1
View File
@@ -639,6 +639,7 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value
assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Ontario 511' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
assert.equal(staticFrameRefreshMs({ provider: 'Houston TranStar' }), 180_000);
// Digitraffic publishes one weathercam frame per 600 s collection interval.
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
assert.equal(
+15
View File
@@ -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 &amp; frames (Houston, when enabled): ' +
'<a href="https://traffic.houstontranstar.org/cctv/transtar/" target="_blank" rel="noopener">Houston TranStar</a>. ' +
"Copying, distributing or framing its images needs TxDOT's written permission under the " +
'<a href="https://traffic.houstontranstar.org/disclaimer.aspx" target="_blank" rel="noopener">TranStar Content Usage terms</a>',
},
{
key: 'hk-cctv',
html:
+12
View File
@@ -129,6 +129,18 @@ test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
assert.match(credit.html, /observe posted signage/);
});
test("the Houston camera credit names TranStar's terms and the permission they require", () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'houston-cctv');
assert.ok(credit, 'Houston TranStar camera frames must be credited');
assert.match(credit.html, /\(Houston, when enabled\)/);
assert.ok(
credit.html.includes(
'href="https://traffic.houstontranstar.org/disclaimer.aspx"',
),
);
assert.match(credit.html, /TxDOT's written permission/);
});
test('every Street Level imagery source is in the attribution lightbox', () => {
for (const key of [
'mapillary',
+292
View File
@@ -0,0 +1,292 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { droneAirspaceProxy } from '../../server/providers/droneAirspace.js';
const feature = (id = 310, type = 'P') => ({
properties: {
NAME: `${type}-${id}`,
TYPE_CODE: type,
OBJECTID: id,
UPPER_DESC: 'TNI',
},
geometry: {
type: 'Polygon',
coordinates: [
[
[-77, 38],
[-76, 38],
[-76, 39],
[-77, 38],
],
],
},
});
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = droneAirspaceProxy(options);
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/drone-airspace');
handler = callback;
},
},
});
return async (url = '/', method = 'GET', peer = 'local', headers = {}) => {
const res = {
headers: {},
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
this.status ??= this.statusCode;
this.body = JSON.parse(body);
},
};
await handler(
{ url, method, headers, socket: { remoteAddress: peer } },
res,
);
return res;
};
}
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: paging follows offsets and normalizes the combined snapshot`, async () => {
const calls = [];
const request = install(
{
now: () => 1234,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.redirect, 'error');
return Response.json({
features: [feature(calls.length)],
exceededTransferLimit: calls.length < 3,
});
},
},
hook,
);
const res = await request('/?url=https://invalid.example&resultOffset=999');
assert.equal(res.status, 200);
assert.equal(res.body.fetchedAt, 1234);
assert.equal(res.body.rows.length, 3);
assert.equal(calls.length, 3);
assert.equal(calls[0].searchParams.get('where'), "TYPE_CODE IN ('P','R')");
assert.equal(calls[0].searchParams.get('f'), 'geojson');
assert.equal(calls[0].searchParams.has('resultOffset'), false);
assert.equal(calls[1].searchParams.get('resultOffset'), '1');
});
}
/**
* Serve one good snapshot, then let the upstream answer `bad` after the TTL:
* the bad answer must not replace it, so the client gets the last good rows
* marked stale, and a cold proxy answers 502 rather than caching the bad one.
*/
async function assertRejectedSnapshot(bad) {
let now = 1000;
let broken = false;
let calls = 0;
const request = install({
now: () => now,
fetchImpl: async () => {
calls += 1;
return broken ? bad(calls) : Response.json({ features: [feature(7)] });
},
});
const first = await request();
assert.equal(first.status, 200);
broken = true;
now += 3_600_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
assert.deepEqual(
stale.body.rows.map((row) => row.stableId),
['7'],
);
calls = 0;
const cold = install({ fetchImpl: async () => bad(++calls) });
const res = await cold();
assert.equal(res.status, 502);
assert.equal(res.body.error, 'drone_airspace_unavailable');
}
test('an empty upstream snapshot keeps the last good one', () =>
assertRejectedSnapshot(() => Response.json({ features: [] })));
test('a page whose every feature is invalid keeps the last good one', () =>
assertRejectedSnapshot(() =>
Response.json({
features: [
{
...feature(8),
properties: { ...feature(8).properties, TYPE_CODE: 'W' },
},
{ ...feature(9), geometry: null },
],
}),
));
test('paging that exhausts its page limit still flagged keeps the last good one', () =>
assertRejectedSnapshot((call) =>
Response.json({
features: [feature(100 + call)],
exceededTransferLimit: true,
}),
));
test('a flagged page that adds no features keeps the last good one', () =>
assertRejectedSnapshot((call) =>
Response.json(
call % 2
? { features: [feature(100 + call)], exceededTransferLimit: true }
: { features: [], properties: { exceededTransferLimit: true } },
),
));
test('a cached snapshot is reused until TTL, then refreshed', async () => {
let now = 1000;
let fetches = 0;
const request = install({
now: () => now,
fetchImpl: async () => {
fetches += 1;
return Response.json({ features: [feature(fetches)] });
},
});
const first = await request();
now += 3_599_000;
const cached = await request();
now += 2000;
const refreshed = await request();
assert.equal(first.body.rows[0].stableId, '1');
assert.equal(cached.body.rows[0].stableId, '1');
assert.equal(refreshed.body.rows[0].stableId, '2');
assert.equal(fetches, 2);
});
test('a failed refresh serves the last good snapshot as stale', async () => {
let now = 1000;
let fail = false;
const request = install({
now: () => now,
fetchImpl: async () => {
if (fail) return new Response('nope', { status: 503 });
return Response.json({ features: [feature(7)] });
},
});
const first = await request();
fail = true;
now += 3_600_001;
const stale = await request();
assert.equal(first.status, 200);
assert.equal(stale.status, 200);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.rows[0].stableId, '7');
});
test('method, unknown routes and rate limits are rejected', async () => {
const request = install({
fetchImpl: async () => Response.json({ features: [feature()] }),
});
assert.equal((await request('/', 'POST')).status, 405);
assert.equal((await request('/nope')).status, 404);
const limited = install({
fetchImpl: async () => Response.json({ features: [feature()] }),
});
let last;
for (let i = 0; i < 61; i++) last = await limited('/', 'GET', '10.0.0.9');
assert.equal(last.status, 429);
});
test('a cold upstream failure is 502, not an empty snapshot', async () => {
const request = install({
fetchImpl: async () => new Response('nope', { status: 503 }),
});
const res = await request();
assert.equal(res.status, 502);
assert.equal(res.body.error, 'drone_airspace_unavailable');
});
test('a stale snapshot is served for at most a day, then refused', async () => {
let now = 1_000_000_000;
let up = true;
const request = install({
now: () => now,
fetchImpl: async () => {
if (!up) throw new Error('FAA unreachable');
return Response.json({ features: [feature(7)] });
},
});
const fresh = await request();
assert.equal(fresh.status, 200);
up = false;
now += 23 * 60 * 60 * 1000;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
now += 2 * 60 * 60 * 1000;
assert.equal((await request()).status, 502, 'a day-old snapshot is refused');
});
test('a cross-site page is refused before it spends quota or reaches FAA', async () => {
let fetches = 0;
const request = install({
fetchImpl: async () => {
fetches += 1;
return Response.json({ features: [feature()] });
},
});
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' },
{ host, origin: 'https://evil.example' },
{ host, origin: 'https://evil.example', 'sec-fetch-site': 'cross-site' },
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
];
// More refusals than the 60/min per-client quota, all from one peer.
for (let i = 0; i < 64; i++) {
const headers = refused[i % refused.length];
const res = await request('/', 'GET', '10.0.0.5', headers);
assert.equal(res.status, 403, JSON.stringify(headers));
}
assert.equal(fetches, 0, 'refusals cost no upstream call');
const app = await request('/', 'GET', '10.0.0.5', {
host,
origin: `http://${host}`,
'sec-fetch-site': 'same-origin',
});
assert.equal(app.status, 200, 'the app keeps its full quota');
assert.equal(fetches, 1);
});
test('the app served behind a reverse proxy still gets the zones', async () => {
const request = install({
fetchImpl: async () => Response.json({ features: [feature()] }),
});
const host = 'gev.example.com';
const res = await request('/', 'GET', '127.0.0.1', {
host,
origin: `https://${host}`,
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.9',
'x-forwarded-proto': 'https',
});
assert.equal(res.status, 200);
assert.equal(res.body.rows.length, 1);
});
+22 -6
View File
@@ -45,7 +45,7 @@ test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () =
assert.equal(card.details.length, 1);
assert.equal(card.details[0], 'high · 2h · N20');
assert.equal(card.selected, false);
assert.equal(card.accent, accentForSeverity('red'), 'FRP 1520 is red-hot');
assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot');
assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched');
});
@@ -69,11 +69,27 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
assert.equal(selected.title, 'FIRE · 10 MW');
});
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => {
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
assert.match(card.details[0], /SNPP$/);
assert.equal(card.title, '▲ 0.8 MW');
assert.notEqual(card.accent, accentForSeverity('red'));
assert.equal(card.accent, accentForSeverity('ember'));
});
test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => {
const accentAt = (frp) =>
buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent;
assert.equal(accentAt(4.9), accentForSeverity('ember'));
assert.equal(accentAt(5), accentForSeverity('red'));
assert.equal(accentAt(20), accentForSeverity('flame'));
assert.equal(accentAt(50), accentForSeverity('orange'));
assert.equal(accentAt(100), accentForSeverity('gold'));
assert.equal(accentAt(300), accentForSeverity('white-hot'));
// Confidence never moves the color: a low-confidence 864 MW fire stays red.
assert.equal(
buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent,
accentForSeverity('white-hot'),
);
});
test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => {
@@ -93,12 +109,12 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
const candidate = {
cell: { count: 14, maxFrp: 210.4, newestAcqMs: NOW - 3 * H, night: 2 },
position: { x: 0, y: 0, z: 0 },
accent: accentForSeverity('orange'),
accent: accentForSeverity('gold'),
};
const card = buildCellCard(candidate, NOW);
assert.equal(card.title, '14 FIRES');
assert.equal(card.details[0], 'max 210 MW · new 3h');
assert.equal(card.accent, accentForSeverity('orange'));
assert.equal(card.accent, accentForSeverity('gold'));
});
test('buildCellCard: the count picks the plural form of the language', () => {
@@ -150,7 +166,7 @@ test('buildCellCard: singular noun and missing-age omission', () => {
const card = buildCellCard(candidate, NOW);
assert.equal(card.title, '1 FIRE');
assert.equal(card.details[0], 'max 9.9 MW');
assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow');
assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop');
});
test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => {
+209
View File
@@ -0,0 +1,209 @@
/**
* Snap Map–style heat raster for the Active Fires far-zoom bands: detections
* are linearly binned on an equirectangular grid (row 0 = north, column
* 0 = 180°W), blurred, normalized on a log scale and mapped through
* HEAT_PALETTE. Pure: no Cesium, no DOM, so the whole pipeline is unit-tested
* in node.
*/
/** Below this normalized heat nothing is painted: empty land stays empty. */
export const HEAT_FLOOR = 0.03;
/**
* The FRP marker ramp as a continuous glow, from a red edge through orange
* and gold to a pale white-hot core. The edge is a bright red, not the
* markers' dark ember, and starts more than half opaque: most of the world
* sits low on a scale set by the hottest regions, and a dark translucent red
* vanishes into forest and savanna imagery.
*/
export const HEAT_PALETTE = Object.freeze([
{ at: 0.0, rgb: [232, 58, 30], alpha: 0 },
{ at: 0.08, rgb: [232, 58, 30], alpha: 0.58 },
{ at: 0.25, rgb: [242, 86, 34], alpha: 0.7 },
{ at: 0.45, rgb: [248, 122, 40], alpha: 0.76 },
{ at: 0.65, rgb: [252, 166, 52], alpha: 0.82 },
{ at: 0.82, rgb: [254, 208, 92], alpha: 0.87 },
{ at: 1.0, rgb: [255, 245, 190], alpha: 0.92 },
]);
export function heatColorAt(t) {
if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0];
const v = Math.min(1, t);
let i = 1;
while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1;
const a = HEAT_PALETTE[i - 1];
const b = HEAT_PALETTE[i];
const k = (v - a.at) / (b.at - a.at || 1);
const mix = (p, q) => Math.round(p + (q - p) * k);
return [
mix(a.rgb[0], b.rgb[0]),
mix(a.rgb[1], b.rgb[1]),
mix(a.rgb[2], b.rgb[2]),
Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255),
];
}
/** Weight of a detection with no or zero FRP, in megawatts. */
export const HEAT_MIN_WEIGHT_MW = 0.5;
/**
* Heat is radiated power, not a head count: each detection adds its FRP in
* megawatts. Weak fires carry little power, so raising the minimum-FRP slider
* removes most detections without dimming the areas where strong fires burn
* (a count-based heat collapsed there and the hot spots faded).
*/
const defaultWeight = (fire) =>
Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0);
/**
* Linear binning (Wand 1994): each detection's weight is shared between the
* four pixel centres around it in proportion to closeness, instead of
* dropping whole into one pixel. Spots then sit at the true position rather
* than snapping by up to half a pixel (~10 km), and a lone weak fire blurs
* into a round spot instead of a square or a plus sign. Longitude wraps;
* rows beyond the poles fold back onto the edge row.
*/
export function binFires(fires, { width, height, weight = defaultWeight }) {
const grid = new Float32Array(width * height);
const row = (y) => Math.min(height - 1, Math.max(0, y)) * width;
const col = (x) => ((x % width) + width) % width;
for (const fire of fires) {
if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue;
const fx = ((fire.lon + 180) / 360) * width - 0.5;
const fy = ((90 - fire.lat) / 180) * height - 0.5;
const x0 = Math.floor(fx);
const y0 = Math.floor(fy);
const tx = fx - x0;
const ty = fy - y0;
const w = weight(fire);
const top = row(y0);
const bottom = row(y0 + 1);
const left = col(x0);
const right = col(x0 + 1);
grid[top + left] += w * (1 - tx) * (1 - ty);
grid[top + right] += w * tx * (1 - ty);
grid[bottom + left] += w * (1 - tx) * ty;
grid[bottom + right] += w * tx * ty;
}
return grid;
}
function kernel(sigma) {
const radius = Math.max(1, Math.ceil(sigma * 3));
const k = new Float32Array(radius * 2 + 1);
let sum = 0;
for (let i = -radius; i <= radius; i++) {
k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma));
sum += k[i + radius];
}
for (let i = 0; i < k.length; i++) k[i] /= sum;
return { k, radius };
}
export function blurGaussian(grid, width, height, sigma) {
const { k, radius } = kernel(sigma);
const tmp = new Float32Array(grid.length);
const out = new Float32Array(grid.length);
for (let y = 0; y < height; y++) {
const row = y * width;
for (let x = 0; x < width; x++) {
const v = grid[row + x];
if (v === 0) continue;
for (let i = -radius; i <= radius; i++) {
const xx = (((x + i) % width) + width) % width; // longitude wraps
tmp[row + xx] += v * k[i + radius];
}
}
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const v = tmp[y * width + x];
if (v === 0) continue;
for (let i = -radius; i <= radius; i++) {
const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp
out[yy * width + x] += v * k[i + radius];
}
}
}
return out;
}
/** Bins of the log-density histogram used to find the reference quantile. */
const QUANTILE_BINS = 4096;
/**
* The p-quantile of the non-zero values, from a histogram of log1p(value):
* one linear pass instead of sorting ~350k burning pixels (a sort cost
* 200 ms+ and dominated the build). Bin width on the log axis is
* max/4096, far finer than the 256-step palette it feeds.
*/
function percentileOfNonZero(values, p) {
let count = 0;
let max = 0;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v > 0) {
count += 1;
if (v > max) max = v;
}
}
if (!count) return 0;
const logMax = Math.log1p(max);
const bins = new Uint32Array(QUANTILE_BINS);
const toBin = (QUANTILE_BINS - 1) / logMax;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1;
}
const target = Math.floor(p * (count - 1));
let seen = 0;
for (let b = 0; b < QUANTILE_BINS; b++) {
seen += bins[b];
if (seen > target) return Math.expm1((b + 1) / toBin);
}
return max;
}
/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */
const LUT_STEPS = 256;
let paletteLut = null;
function heatLut() {
if (!paletteLut) {
paletteLut = new Uint8ClampedArray(LUT_STEPS * 4);
for (let i = 0; i < LUT_STEPS; i++) {
paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4);
}
}
return paletteLut;
}
export function buildHeatRaster(
fires,
{ width, height, sigmaPx, percentile = 0.995 },
) {
const density = blurGaussian(
binFires(fires, { width, height }),
width,
height,
sigmaPx,
);
const reference = percentileOfNonZero(density, percentile);
const rgba = new Uint8ClampedArray(width * height * 4);
if (reference > 0) {
const lut = heatLut();
const scale = (LUT_STEPS - 1) / Math.log1p(reference);
for (let i = 0; i < density.length; i++) {
const d = density[i];
if (d <= 0) continue;
const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale));
const l = step * 4;
if (lut[l + 3] === 0) continue;
const o = i * 4;
rgba[o] = lut[l];
rgba[o + 1] = lut[l + 1];
rgba[o + 2] = lut[l + 2];
rgba[o + 3] = lut[l + 3];
}
}
return { width, height, rgba, reference };
}
+118
View File
@@ -0,0 +1,118 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
HEAT_MIN_WEIGHT_MW,
HEAT_PALETTE,
heatColorAt,
binFires,
blurGaussian,
buildHeatRaster,
} from './firmsHeatRaster.js';
const fire = (lat, lon, frp = 10) => ({ lat, lon, frp });
test('palette runs ember to white-hot and starts fully transparent', () => {
assert.equal(HEAT_PALETTE[0].at, 0);
assert.equal(HEAT_PALETTE.at(-1).at, 1);
assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]);
assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted');
const low = heatColorAt(0.15);
const high = heatColorAt(1);
assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is red');
assert.ok(low[3] >= 0.5 * 255, 'low heat stays visible on any imagery');
assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler');
assert.ok(high[3] > low[3], 'hotter is more opaque');
for (let i = 1; i <= 100; i++) {
const [r, , b] = heatColorAt(i / 100);
assert.ok(r >= b, `no cold hue at ${i}%`);
}
});
test('binFires puts a detection at a pixel centre wholly in that pixel', () => {
// 4×2 grid: pixel centres at lon −135/−45/45/135, lat ±45.
const grid = binFires([fire(45, -135, 1), fire(-45, 135, 1)], {
width: 4,
height: 2,
});
assert.equal(grid[0], 1, 'north-west pixel');
assert.equal(grid[7], 1, 'south-east pixel');
assert.equal(
grid.reduce((a, b) => a + b, 0),
2,
);
});
test('binFires shares a detection between the pixels around it', () => {
const between = binFires([fire(45, 0, 1)], { width: 4, height: 2 });
assert.equal(between[1], 0.5, 'half to the pixel west of it');
assert.equal(between[2], 0.5, 'half to the pixel east of it');
const quarter = binFires([fire(45, -112.5, 1)], { width: 4, height: 2 });
assert.equal(quarter[0], 0.75, 'closer centre takes more');
assert.equal(quarter[1], 0.25);
const wrapped = binFires([fire(45, 180, 1)], { width: 4, height: 2 });
assert.equal(wrapped[3], 0.5, 'the antimeridian wraps');
assert.equal(wrapped[0], 0.5);
const polar = binFires([fire(90, -135, 1)], { width: 4, height: 2 });
assert.equal(polar[0], 1, 'beyond the last row folds back onto it');
});
test('binFires weights each detection by its radiative power', () => {
const grid = binFires(
[fire(45, 45, 250), fire(45, 45, 0), fire(45, 45, Number.NaN)],
{ width: 4, height: 2 },
);
assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW);
});
test('filtering out weak fires keeps a strong fire hot', () => {
const strong = fire(0.1, 0.1, 300);
const weak = Array.from({ length: 40 }, () => fire(0.1, 0.1, 2));
const reference = Array.from({ length: 10 }, () => fire(20.1, 20.1, 100));
const options = { width: 360, height: 180, sigmaPx: 2 };
const pixel = ((90 - 0.1) | 0) * 360 + ((0.1 + 180) | 0);
const red = (r) => r.rgba[pixel * 4];
const alpha = (r) => r.rgba[pixel * 4 + 3];
const unfiltered = buildHeatRaster([strong, ...weak, ...reference], options);
const filtered = buildHeatRaster([strong, ...reference], options);
assert.ok(alpha(filtered) > 0);
assert.ok(
alpha(filtered) >= alpha(unfiltered) * 0.9,
`alpha ${alpha(unfiltered)} -> ${alpha(filtered)}`,
);
assert.ok(
red(filtered) >= red(unfiltered) * 0.9,
`red ${red(unfiltered)} -> ${red(filtered)}`,
);
});
test('blurGaussian keeps mass and wraps across the antimeridian', () => {
const w = 16;
const h = 8;
const grid = new Float32Array(w * h);
grid[3 * w + 0] = 1;
const out = blurGaussian(grid, w, h, 1.5);
const sum = out.reduce((a, b) => a + b, 0);
assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`);
assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge');
});
test('buildHeatRaster is transparent where there are no fires', () => {
const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], {
width: 360,
height: 180,
sigmaPx: 2,
});
assert.equal(r.rgba.length, 360 * 180 * 4);
const alphaAt = (lat, lon) => {
const x = Math.floor(((lon + 180) / 360) * 360);
const y = Math.floor(((90 - lat) / 180) * 180);
return r.rgba[(y * 360 + x) * 4 + 3];
};
assert.ok(alphaAt(40, -120) > 0, 'blob over the fires');
assert.equal(alphaAt(-30, 60), 0, 'empty far away');
});
test('buildHeatRaster with no fires is fully transparent', () => {
const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 });
assert.ok(r.rgba.every((v) => v === 0));
});
+212
View File
@@ -0,0 +1,212 @@
/**
* @module firmsIntensity
* @description Fire intensity policy shared by the Active Fires layer and the
* agent fire tool: the six-stop ember → white-hot FRP color ramp, the log-scale
* slider that doubles as its legend, and the one filter every consumer
* (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs
* before anything is rendered or selected. Pure — no Cesium, no DOM.
*
* The ramp (`FRP_RAMP`, stepped, not interpolated) runs like a fire cooling
* down: a dim ember for weak fires through red, orange and gold to a pale
* white-hot core, lightness rising with power, so the brightest marks are the
* strongest fires. No cold hues: ~90% of detections are under 20 MW and a
* blue start painted most of the map blue. The thresholds are GEV's own, set
* on a real week's percentiles so every color appears: Portolan's
* 0/10/50/200/500/1000 MW leaves one hottest fire a week (HAL-675).
*/
/**
* Stepped FRP ramp, weakest first. A detection takes the LAST stop whose
* `minFrp` it reaches. Names key the glow-sprite cache and the card accents.
*/
export const FRP_RAMP = Object.freeze([
Object.freeze({ name: 'ember', minFrp: 0, hex: '#9e2a1b' }),
Object.freeze({ name: 'red', minFrp: 5, hex: '#d2401f' }),
Object.freeze({ name: 'flame', minFrp: 20, hex: '#f06a28' }),
Object.freeze({ name: 'orange', minFrp: 50, hex: '#fb9a2e' }),
Object.freeze({ name: 'gold', minFrp: 100, hex: '#fdc84a' }),
Object.freeze({ name: 'white-hot', minFrp: 300, hex: '#fff3b0' }),
]);
/** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */
export const FRP_SLIDER_TICKS = Object.freeze(
FRP_RAMP.map((stop) => stop.minFrp),
);
/** Minimum-FRP values the share codec accepts, in whole megawatts. */
export const MIN_FRP_MW_MIN = 0;
export const MIN_FRP_MW_MAX = 10_000;
/** Confidence switch positions; VIIRS reports only low / nominal / high. */
export const FIRMS_CONFIDENCE_MODES = Object.freeze(['all', 'nominal', 'high']);
/**
* Normalized-confidence floor for a switch position. The thresholds match
* `confidenceBucket` in the layer model (0.3 / 0.6 / 0.9 after firmsAdapt).
*/
const CONFIDENCE_FLOOR = Object.freeze({ all: 0, nominal: 0.45, high: 0.75 });
export const DEFAULT_FIRE_FILTER = Object.freeze({
minFrp: 0,
confidence: 'all',
});
/**
* The ramp stop for a fire radiative power.
* @param {number} frp Megawatts; non-finite or negative reads as 0.
* @returns {{name: string, minFrp: number, hex: string}}
*/
export function frpRampStop(frp) {
const value = Number.isFinite(frp) ? Math.max(0, frp) : 0;
let stop = FRP_RAMP[0];
for (const candidate of FRP_RAMP) {
if (value >= candidate.minFrp) stop = candidate;
else break;
}
return stop;
}
/** `#rrggbb` → `[r, g, b]` in 0..255. */
export function hexToRgb(hex) {
const text = String(hex).replace('#', '');
return [0, 2, 4].map((offset) =>
Number.parseInt(text.slice(offset, offset + 2), 16),
);
}
/**
* Log-scale slider axis: position 0 means "no minimum" (0 MW), and the rest
* of the track runs 1 MW → SLIDER_MAX_MW logarithmically. The axis floor is 1
* MW because the median detection is ~6 MW and a sub-megawatt floor hides
* nothing worth a tick.
*/
export const SLIDER_MAX_MW = 1000;
const LOG_MAX = Math.log10(SLIDER_MAX_MW);
/**
* Track position in 0..1 for a minimum FRP.
* @param {number} minFrp Megawatts.
* @returns {number}
*/
export function sliderPositionForFrp(minFrp) {
if (!Number.isFinite(minFrp) || minFrp < 1) return 0;
return Math.min(1, Math.log10(minFrp) / LOG_MAX);
}
/**
* Minimum FRP for a track position, rounded to whole megawatts so a slider
* value and its share-link form are the same number.
* @param {number} position 0..1.
* @returns {number}
*/
export function frpForSliderPosition(position) {
const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0;
if (p <= 0) return 0;
const frp = Math.pow(10, p * LOG_MAX);
return Math.max(1, Math.round(frp));
}
/**
* CSS `linear-gradient` painting the stepped ramp along the log axis, so the
* slider track IS the legend: the color under the thumb is the color of the
* weakest fire still shown.
* @returns {string}
*/
export function rampTrackGradient() {
const parts = [];
FRP_RAMP.forEach((stop, index) => {
const start = sliderPositionForFrp(stop.minFrp) * 100;
const next = FRP_RAMP[index + 1];
const end = next ? sliderPositionForFrp(next.minFrp) * 100 : 100;
parts.push(`${stop.hex} ${start.toFixed(2)}% ${end.toFixed(2)}%`);
});
return `linear-gradient(90deg, ${parts.join(', ')})`;
}
/**
* Minimum FRP → whole megawatts inside the codec bounds, or null.
* @param {*} value
* @returns {?number}
*/
export function normalizeMinFrp(value) {
const number =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== ''
? Number(value)
: NaN;
if (!Number.isFinite(number)) return null;
const rounded = Math.round(number);
return rounded >= MIN_FRP_MW_MIN && rounded <= MIN_FRP_MW_MAX
? rounded
: null;
}
/**
* Confidence mode → one of FIRMS_CONFIDENCE_MODES, or null.
* @param {*} value
* @returns {?string}
*/
export function normalizeConfidenceMode(value) {
const text = String(value ?? '')
.trim()
.toLowerCase();
return FIRMS_CONFIDENCE_MODES.includes(text) ? text : null;
}
/**
* Sanitize a partial filter against the current one; unknown or invalid
* fields keep their current value.
* @param {Object} [params]
* @param {{minFrp: number, confidence: string}} [current]
* @returns {{minFrp: number, confidence: string}}
*/
export function normalizeFireFilter(
params = {},
current = DEFAULT_FIRE_FILTER,
) {
const minFrp = normalizeMinFrp(params?.minFrp);
const confidence = normalizeConfidenceMode(params?.confidence);
return {
minFrp: minFrp === null ? current.minFrp : minFrp,
confidence: confidence === null ? current.confidence : confidence,
};
}
/**
* Whether one fire passes the filter. `confidence` is the normalized 0..1
* value firmsAdapt produces; `frp` is megawatts.
* @param {{frp: number, confidence: number}} fire
* @param {{minFrp: number, confidence: string}} filter
* @returns {boolean}
*/
export function firePassesFilter(fire, filter = DEFAULT_FIRE_FILTER) {
const frp = Number.isFinite(fire?.frp) ? fire.frp : 0;
if (frp < (filter.minFrp || 0)) return false;
const floor = CONFIDENCE_FLOOR[filter.confidence] ?? 0;
if (floor <= 0) return true;
const confidence = Number.isFinite(fire?.confidence) ? fire.confidence : 0;
return confidence >= floor;
}
/**
* The fires that pass the filter, in input order. Returns the SAME array when
* the filter is the default, so callers can tell "nothing hidden" cheaply.
* @template T
* @param {Array<T>} fires Internal records (normalized confidence).
* @param {{minFrp: number, confidence: string}} filter
* @returns {Array<T>}
*/
export function filterFires(fires, filter = DEFAULT_FIRE_FILTER) {
if (!Array.isArray(fires)) return [];
if (isDefaultFireFilter(filter)) return fires;
return fires.filter((fire) => firePassesFilter(fire, filter));
}
/** True when the filter hides nothing. */
export function isDefaultFireFilter(filter) {
return (
!(filter?.minFrp > 0) &&
(filter?.confidence === 'all' || filter?.confidence == null)
);
}
+221
View File
@@ -0,0 +1,221 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_FIRE_FILTER,
FIRMS_CONFIDENCE_MODES,
FRP_RAMP,
FRP_SLIDER_TICKS,
filterFires,
firePassesFilter,
frpForSliderPosition,
frpRampStop,
hexToRgb,
isDefaultFireFilter,
normalizeConfidenceMode,
normalizeFireFilter,
normalizeMinFrp,
rampTrackGradient,
sliderPositionForFrp,
} from './firmsIntensity.js';
test('the ramp has six ember → white-hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW', () => {
assert.deepEqual(
FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]),
[
['ember', 0, '#9e2a1b'],
['red', 5, '#d2401f'],
['flame', 20, '#f06a28'],
['orange', 50, '#fb9a2e'],
['gold', 100, '#fdc84a'],
['white-hot', 300, '#fff3b0'],
],
);
assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]);
assert.ok(Object.isFrozen(FRP_RAMP));
});
test('every stop is warm and each is lighter than the one below', () => {
const luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
let previous = -1;
for (const stop of FRP_RAMP) {
const rgb = hexToRgb(stop.hex);
assert.ok(rgb[0] >= rgb[2], `${stop.name} has no cold hue`);
assert.ok(
luma(rgb) > previous,
`${stop.name} is lighter than the stop below`,
);
previous = luma(rgb);
}
});
test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => {
assert.equal(frpRampStop(0).name, 'ember');
assert.equal(frpRampStop(4.99).name, 'ember');
assert.equal(frpRampStop(5).name, 'red');
assert.equal(frpRampStop(19.9).name, 'red');
assert.equal(frpRampStop(20).name, 'flame');
assert.equal(frpRampStop(50).name, 'orange');
assert.equal(frpRampStop(100).name, 'gold');
assert.equal(frpRampStop(299.9).name, 'gold');
assert.equal(frpRampStop(300).name, 'white-hot');
assert.equal(frpRampStop(1246).name, 'white-hot');
assert.equal(frpRampStop(NaN).name, 'ember');
assert.equal(frpRampStop(-3).name, 'ember');
assert.equal(frpRampStop(undefined).name, 'ember');
});
test('hex stops convert to the "r, g, b" accents the cards paint', () => {
assert.deepEqual(hexToRgb('#9e2a1b'), [158, 42, 27]);
assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]);
});
test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round-trip', () => {
assert.equal(sliderPositionForFrp(0), 0);
assert.equal(sliderPositionForFrp(0.5), 0);
assert.equal(sliderPositionForFrp(1), 0);
assert.ok(Math.abs(sliderPositionForFrp(10) - 1 / 3) < 1e-9);
assert.ok(Math.abs(sliderPositionForFrp(100) - 2 / 3) < 1e-9);
assert.equal(sliderPositionForFrp(1000), 1);
assert.equal(sliderPositionForFrp(5000), 1, 'clamped past the axis end');
assert.equal(frpForSliderPosition(0), 0);
assert.equal(frpForSliderPosition(1), 1000);
assert.equal(frpForSliderPosition(-1), 0);
assert.equal(frpForSliderPosition(2), 1000);
for (const frp of FRP_SLIDER_TICKS) {
assert.equal(
frpForSliderPosition(sliderPositionForFrp(frp)),
frp,
`tick ${frp} MW round-trips through the axis`,
);
}
// Ticks spread along the track instead of bunching at one end.
const positions = FRP_SLIDER_TICKS.map(sliderPositionForFrp);
for (let i = 1; i < positions.length; i += 1) {
assert.ok(positions[i] - positions[i - 1] >= 0.09, `tick gap ${i}`);
}
});
test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => {
const gradient = rampTrackGradient();
assert.match(gradient, /^linear-gradient\(90deg, /);
assert.match(gradient, /#9e2a1b 0\.00% 23\.3\d%/);
assert.match(gradient, /#d2401f 23\.3\d% 43\.3\d%/);
assert.match(gradient, /#fff3b0 82\.5\d% 100\.00%\)$/);
assert.equal(gradient.match(/#/g).length, FRP_RAMP.length);
});
test('minimum FRP normalizes to whole megawatts inside the codec bounds', () => {
assert.equal(normalizeMinFrp(0), 0);
assert.equal(normalizeMinFrp(19.6), 20);
assert.equal(normalizeMinFrp('100'), 100);
assert.equal(normalizeMinFrp(10000), 10000);
assert.equal(normalizeMinFrp(10001), null);
assert.equal(normalizeMinFrp(-1), null);
assert.equal(normalizeMinFrp('abc'), null);
assert.equal(normalizeMinFrp(''), null);
assert.equal(normalizeMinFrp(null), null);
assert.equal(normalizeMinFrp(NaN), null);
});
test('confidence modes are exactly all / nominal / high', () => {
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
assert.equal(normalizeConfidenceMode(' High '), 'high');
assert.equal(normalizeConfidenceMode('nominal'), 'nominal');
assert.equal(normalizeConfidenceMode('low'), null);
assert.equal(normalizeConfidenceMode(0.9), null);
assert.equal(normalizeConfidenceMode(undefined), null);
});
test('a partial filter patches the current one and invalid fields keep their value', () => {
assert.deepEqual(normalizeFireFilter(), DEFAULT_FIRE_FILTER);
assert.deepEqual(normalizeFireFilter({ minFrp: 20 }), {
minFrp: 20,
confidence: 'all',
});
assert.deepEqual(
normalizeFireFilter(
{ confidence: 'high' },
{ minFrp: 50, confidence: 'all' },
),
{ minFrp: 50, confidence: 'high' },
);
assert.deepEqual(
normalizeFireFilter(
{ minFrp: 'nope', confidence: 'medium' },
{ minFrp: 50, confidence: 'nominal' },
),
{ minFrp: 50, confidence: 'nominal' },
);
assert.equal(isDefaultFireFilter(DEFAULT_FIRE_FILTER), true);
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'all' }), true);
assert.equal(isDefaultFireFilter({ minFrp: 1, confidence: 'all' }), false);
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'high' }), false);
});
test('the filter keeps a fire at or above the floor and at or above the confidence mode', () => {
const low = { frp: 864, confidence: 0.3 };
const nominal = { frp: 20, confidence: 0.6 };
const high = { frp: 2, confidence: 0.9 };
assert.equal(firePassesFilter(low), true, 'default shows everything');
assert.equal(
firePassesFilter(nominal, { minFrp: 20, confidence: 'all' }),
true,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 21, confidence: 'all' }),
false,
);
assert.equal(
firePassesFilter(low, { minFrp: 0, confidence: 'nominal' }),
false,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 0, confidence: 'nominal' }),
true,
);
assert.equal(
firePassesFilter(high, { minFrp: 0, confidence: 'nominal' }),
true,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 0, confidence: 'high' }),
false,
);
assert.equal(firePassesFilter(high, { minFrp: 0, confidence: 'high' }), true);
assert.equal(
firePassesFilter(high, { minFrp: 5, confidence: 'high' }),
false,
);
// Missing numbers read as 0, never as a pass-through.
assert.equal(firePassesFilter({}, { minFrp: 1, confidence: 'all' }), false);
assert.equal(
firePassesFilter({ frp: 10 }, { minFrp: 0, confidence: 'nominal' }),
false,
);
});
test('filterFires returns the same array for the default filter and preserves order otherwise', () => {
const fires = [
{ frp: 864, confidence: 0.3 },
{ frp: 20, confidence: 0.6 },
{ frp: 2, confidence: 0.9 },
{ frp: 120, confidence: 0.9 },
];
assert.equal(filterFires(fires), fires);
assert.equal(filterFires(fires, { minFrp: 0, confidence: 'all' }), fires);
assert.deepEqual(
filterFires(fires, { minFrp: 20, confidence: 'all' }).map((f) => f.frp),
[864, 20, 120],
);
assert.deepEqual(
filterFires(fires, { minFrp: 0, confidence: 'high' }).map((f) => f.frp),
[2, 120],
);
assert.deepEqual(
filterFires(fires, { minFrp: 100, confidence: 'nominal' }).map(
(f) => f.frp,
),
[120],
);
assert.deepEqual(filterFires(null, { minFrp: 1, confidence: 'all' }), []);
});
+151
View File
@@ -15,6 +15,7 @@ import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { HEAT_REBUILD_MS } from '../layers/firms/policy.js';
const LAYER_SOURCE = readLayerSource(
path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'),
@@ -392,3 +393,153 @@ test('disable cancels a pending source and a late response cannot repopulate the
assert.equal(h.layer.getStats().loading, false);
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
});
test('the heatmap is preloaded under the markers before the first zoom-out', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const priorDocument = globalThis.document;
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }),
putImageData() {},
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
}),
};
const h = harness({
withDataSource: true,
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000),
});
h.viewer.camera = { positionCartographic: { height: 1_000_000 } };
h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true };
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
const added = [];
try {
h.layer.attachShellServices({
imageryHost: () => ({
kind: 'globe',
collection: {
addImageryProvider: (provider) => {
const layer = { provider, show: true, alpha: 1 };
added.push(layer);
return layer;
},
remove() {},
raiseToTop() {},
},
}),
});
h.layer._renderLodForTest('close');
assert.equal(added.length, 0, 'the markers paint first; the build trails');
t.mock.timers.tick(HEAT_REBUILD_MS);
assert.equal(added.length, 1, 'then the heatmap is draped');
assert.equal(added[0].alpha, 0, 'invisible until the hand-off');
assert.equal(added[0].show, true, 'but mapped onto the globe tiles');
} finally {
h.layer.destroy(h.viewer);
h.cleanup();
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
}
});
test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', () => {
// A canvas stub for the heat drape and the detection glow sprites.
const priorDocument = globalThis.document;
const restoreDocument = () => {
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
};
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, hh) => ({
data: new Uint8ClampedArray(w * hh * 4),
}),
putImageData() {},
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
}),
};
const h = harness({
fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })],
withDataSource: true,
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000),
});
const added = [];
const removed = [];
let markers = null;
// The fade reads the camera height; the cull reads scene.camera.
const setHeight = (height) => {
h.viewer.camera = { positionCartographic: { height } };
};
setHeight(12_000_000);
h.viewer.scene.primitives = {
add(primitive) {
markers = primitive;
},
remove() {},
raiseToTop() {},
contains: () => true,
};
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
try {
const collection = {
addImageryProvider: (provider) => {
const layer = { provider, show: true, alpha: 1 };
added.push(layer);
return layer;
},
remove: (layer, destroy) => removed.push({ layer, destroy }),
raiseToTop() {},
};
h.layer.attachShellServices({
imageryHost: () => ({ kind: 'globe', collection }),
});
h.layer._renderLodForTest('global');
assert.equal(added.length, 1, 'one heat imagery layer');
assert.equal(added[0].show, true);
assert.equal(added[0].alpha, 1);
assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities');
assert.ok(h.published.length > 0, 'the far band still paints cards');
assert.equal(markers.length, 2, 'the markers are built under the heatmap');
assert.equal(markers.show, false, 'and hidden while the heatmap is whole');
h.layer._renderLodForTest('regional');
assert.equal(added.length, 1, 'switching far bands reuses the raster');
setHeight(1_000_000);
h.layer._renderLodForTest('close');
assert.equal(added.length, 1, 'the markers band keeps the same heat layer');
assert.equal(added[0].show, true, 'still mapped onto the globe tiles');
assert.equal(added[0].alpha, 0, 'faded out under the markers');
assert.equal(markers.show, true);
assert.equal(markers.get(0).color.alpha, 1);
// Mid hand-off both are drawn, half each, in either band.
setHeight(4_500_000);
h.layer._applyHandoffFadeForTest();
assert.equal(added[0].alpha, 0.5);
assert.equal(markers.show, true);
assert.equal(markers.get(0).color.alpha, 0.5);
h.layer._renderLodForTest('regional');
assert.equal(added[0].alpha, 0.5);
assert.equal(markers.get(1).color.alpha, 0.5, 'rebuilt markers get the fade');
h.layer.disable();
assert.equal(added[0].show, false, 'disable hides the heatmap');
h.layer.destroy(h.viewer);
assert.deepEqual(
removed.map((entry) => [entry.layer, entry.destroy]),
[[added[0], true]],
);
} finally {
h.cleanup();
restoreDocument();
}
});
+14 -9
View File
@@ -4,6 +4,8 @@
* dedicated card canvas moved into the shared world-overlay host.
*/
import { FRP_RAMP, hexToRgb } from './firmsIntensity.js';
/** Shipped ambient FIRMS card ceiling. Selected fires bypass this cohort. */
export const FIRMS_AMBIENT_COHORT_LIMIT = 18;
export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
@@ -23,20 +25,23 @@ export function fireDetectionKey(fire) {
return `firms:${lat}:${lon}:${acq}:${source}`;
}
/** Severity accent palette — matches the FIRMS glow-sprite color stops. */
const ACCENT_RGB = Object.freeze({
red: '224, 82, 82',
orange: '240, 178, 62',
yellow: '244, 227, 108',
});
/**
* Severity accent palette — the FIRMS glow-sprite color stops (the six-stop
* FRP ramp in firmsIntensity.js) as "r, g, b" strings for card accents.
*/
const ACCENT_RGB = Object.freeze(
Object.fromEntries(
FRP_RAMP.map((stop) => [stop.name, hexToRgb(stop.hex).join(', ')]),
),
);
/**
* Severity stop name → "r, g, b" accent string (defaults to yellow).
* @param {string} stopName - 'red' | 'orange' | 'yellow'.
* Severity stop name → "r, g, b" accent string (defaults to the weakest stop).
* @param {string} stopName - A FRP_RAMP stop name ('ember' … 'white-hot').
* @returns {string}
*/
export function accentForSeverity(stopName) {
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
return ACCENT_RGB[stopName] || ACCENT_RGB[FRP_RAMP[0].name];
}
/**
+10 -5
View File
@@ -9,11 +9,16 @@ import {
formatFrpMw,
} from './firmsLabels.js';
test('FIRMS formatting helpers retain the shipped severity palette', () => {
assert.equal(accentForSeverity('red'), '224, 82, 82');
assert.equal(accentForSeverity('orange'), '240, 178, 62');
assert.equal(accentForSeverity('yellow'), '244, 227, 108');
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
test('FIRMS card accents are the six FRP ramp stops, weakest as the fallback', () => {
// Ember #9e2a1b … white-hot #fff3b0, lightness rising with power.
assert.equal(accentForSeverity('ember'), '158, 42, 27');
assert.equal(accentForSeverity('red'), '210, 64, 31');
assert.equal(accentForSeverity('flame'), '240, 106, 40');
assert.equal(accentForSeverity('orange'), '251, 154, 46');
assert.equal(accentForSeverity('gold'), '253, 200, 74');
assert.equal(accentForSeverity('white-hot'), '255, 243, 176');
assert.equal(accentForSeverity('blue'), accentForSeverity('ember'));
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('ember'));
});
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
+25 -1
View File
@@ -298,6 +298,20 @@ function satelliteFilterOption() {
}
const OPTION_GROUPS = Object.freeze({
// Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on
// the codec side; the panel's log slider rounds to it. Both default to
// "show everything": many of the strongest fires are rated low confidence.
// The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in
// firmsIntensity.js (pinned by layerState.test); spelled out here because
// this file runs in isolation in the token-ledger tests.
'local-firms': Object.freeze([
boundedIntegerOption('minFrp', 'f', 0, { min: 0, max: 10000 }),
enumOption('confidence', 'c', 'all', ['all', 'nominal', 'high'], {
all: 'a',
nominal: 'n',
high: 'h',
}),
]),
traffic: Object.freeze([
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
tomtom: 't',
@@ -598,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',
@@ -621,7 +640,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
token: 'd',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }),
Object.freeze({
id: 'local-firms',
token: 'w',
disposition: 'enabled+options',
optionOwner: 'local-firms',
}),
Object.freeze({
id: 'military',
token: 'm',
+111 -14
View File
@@ -29,6 +29,7 @@ import radioLayer from './radio.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js';
function deferred() {
let resolve;
@@ -202,8 +203,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 34);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 34);
assert.equal(REGISTERED_LAYER_IDS.length, 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, {
@@ -242,18 +243,18 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '8');
assert.equal(nextLayerStateToken(), '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(
@@ -305,8 +306,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '8',
next: '9',
future: '9',
next: '00',
}),
true,
);
@@ -316,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'];
@@ -339,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' }),
@@ -2694,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, [
@@ -2991,9 +3008,89 @@ test('Clear All cancels a saved restore still waiting to start', async () => {
gate.resolve();
const results = await restore;
for (const id of ['earthquakes', 'flights']) {
assert.equal(results.find((result) => result.layerId === id).succeeded, false);
assert.equal(
results.find((result) => result.layerId === id).succeeded,
false,
);
}
assert.deepEqual([...manager.getEnabledLayerIds()], []);
assert.equal(enabledLayerParam(sink), '');
coordinator.destroy();
});
/** Active Fires options decoded from a v2 link with these `lo` assignments. */
function firmsOptions(lo) {
const params = new URLSearchParams([
['v', '2'],
['l', 'w'],
]);
if (lo) params.set('lo', lo);
return decodeLayerStateParams(params).options['local-firms'];
}
test('Active Fires: the intensity filter rides share token w and round-trips', () => {
const state = createDefaultLayerState();
assert.deepEqual(state.options['local-firms'], {
minFrp: 0,
confidence: 'all',
});
state.enabledLayerIds = ['local-firms'];
state.options['local-firms'] = { minFrp: 20, confidence: 'high' };
const encoded = encode(state);
assert.equal(new URLSearchParams(encoded).get('l'), 'w');
// Other owners write their own explicit defaults into `lo` too (flights 3D,
// wind overlay); the fire assignments ride beside them.
const assignments = new URLSearchParams(encoded).get('lo').split('_');
assert.ok(assignments.includes('w.f.20'), assignments.join(' '));
assert.ok(assignments.includes('w.c.h'), assignments.join(' '));
const decoded = decodeLayerStateParams(new URLSearchParams(encoded));
assert.deepEqual(decoded.options['local-firms'], {
minFrp: 20,
confidence: 'high',
});
assert.deepEqual(
parseStoredLayerState(serializeStoredLayerState(state)).options[
'local-firms'
],
state.options['local-firms'],
);
// Defaults are omitted from the link: a plain `l=w` is "show everything".
const plain = createDefaultLayerState();
plain.enabledLayerIds = ['local-firms'];
assert.equal(
new URLSearchParams(encode(plain))
.get('lo')
.split('_')
.filter((assignment) => assignment.startsWith('w.')).length,
0,
);
assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' });
});
test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => {
// The codec spells its bounds out (it runs in isolation in the ledger
// tests); they must stay the layer's.
assert.equal(MIN_FRP_MW_MAX, 10000);
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
assert.equal(firmsOptions('w.f.300').minFrp, 300);
assert.equal(firmsOptions('w.f.10000').minFrp, 10000);
assert.equal(firmsOptions('w.f.10001').minFrp, 0, 'out of bounds → default');
assert.equal(firmsOptions('w.f.-5').minFrp, 0);
assert.equal(
firmsOptions('w.f.2.5').minFrp,
0,
'a third segment is rejected',
);
assert.equal(firmsOptions('w.c.a').confidence, 'all');
assert.equal(firmsOptions('w.c.n').confidence, 'nominal');
assert.equal(firmsOptions('w.c.h').confidence, 'high');
assert.equal(
firmsOptions('w.c.x').confidence,
'all',
'unknown code → default',
);
assert.deepEqual(firmsOptions('w.c.n_w.f.5'), {
minFrp: 5,
confidence: 'nominal',
});
});
+29 -28
View File
@@ -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"],
+149 -1
View File
@@ -90,6 +90,8 @@ async function createRealLocalLayerHarness({
globalThis.window = { dispatchEvent() {} };
let sampleHeightImpl = sampleHeight;
const sampleCalls = { count: 0 };
// The camera frustum, reduced to the one answer ground sampling asks of it.
let baseVisibility = Cesium.Intersect.INSIDE;
const overlayHost = {
setVisible: (...args) => hostCalls.push(['visible', ...args]),
setEntries: (...args) => hostCalls.push(['entries', ...args]),
@@ -107,7 +109,19 @@ async function createRealLocalLayerHarness({
},
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000),
frustum: { fov: Math.PI / 3 },
directionWC: new Cesium.Cartesian3(),
upWC: new Cesium.Cartesian3(),
frustum: {
fov: Math.PI / 3,
// No planes: everything is in view. One plane no point satisfies:
// nothing is.
computeCullingVolume: () => ({
planes:
baseVisibility === Cesium.Intersect.OUTSIDE
? [new Cesium.Cartesian4(0, 0, 0, -1)]
: [],
}),
},
moveEnd,
flyTo() {},
},
@@ -151,6 +165,10 @@ async function createRealLocalLayerHarness({
sampleCalls,
/** Swap scene.sampleHeight mid-test (e.g. tiles finally arrive). */
setSampleHeight(next) { sampleHeightImpl = next; },
/** Put the feature's base inside (true) or outside the camera frustum. */
setBaseInView(inView) {
baseVisibility = inView ? Cesium.Intersect.INSIDE : Cesium.Intersect.OUTSIDE;
},
cleanup() {
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
@@ -736,6 +754,136 @@ test('ground sampling waits for visible globe tiles before caching a height', as
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
});
test('an unseen base is not sampled and arms no retry until it is in view', async (t) => {
// Out of view the scene has no surface under the base, so the depth sample
// would pass through the Earth (about -6,344 km) and fail on every retry.
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
});
_resetRenderGovernorForTest();
const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
installRenderGovernor({ scene: governorScene });
governorScene.renders = 0; // discard the governor's own install settling frame
t.mock.timers.enable({ apis: ['setTimeout'] });
const clock = installFakeClock(t);
t.after(() => {
env.layer.destroy(env.viewer);
env.cleanup();
_resetRenderGovernorForTest();
});
env.viewer.scene.globe = { show: true, tilesLoaded: true, getHeight: () => 117 };
setCameraAltitude(env, 20_000);
env.setBaseInView(false);
runArmedRetryChain(env, t, clock, 5);
assert.equal(env.sampleCalls.count, 0, 'an off-screen base must not be sampled');
assert.equal(governorScene.renders, 0, 'an off-screen base must not keep a parked camera rendering');
assert.ok(Math.abs(baseHeightM(env)) < 0.01);
// In the frustum but over the horizon: 50 m up, about 67 km away.
env.setBaseInView(true);
env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.0, 30.205, 50);
runArmedRetryChain(env, t, clock, 5);
assert.equal(env.sampleCalls.count, 0, 'a base over the horizon must not be sampled');
assert.equal(governorScene.renders, 0);
setCameraAltitude(env, 20_000);
env.preRender.raise();
assert.equal(env.sampleCalls.count, 1, 'the base is sampled on the first walk that sees it');
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
});
/**
* Give the harness a real Cesium camera `southM` metres south of the base at
* `cameraM` metres above the ellipsoid, looking at the base's ground at
* `groundM` (or directly away from it).
*/
function aimRealCamera(env, { groundM, cameraM, southM, away = false, fov = Math.PI / 3 }) {
const entity = env.dataSources[0].entities.values[0];
const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian);
const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, groundM);
const position = Cesium.Cartesian3.fromRadians(
base.longitude,
base.latitude - southM / 6_371_000,
cameraM,
);
const direction = Cesium.Cartesian3.normalize(
away
? Cesium.Cartesian3.subtract(position, ground, new Cesium.Cartesian3())
: Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
const right = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.cross(
direction,
Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position),
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3());
const frustum = new Cesium.PerspectiveFrustum({
fov,
aspectRatio: 800 / 600,
near: 1,
far: 1e8,
});
Object.assign(env.viewer.camera, {
positionWC: position,
directionWC: direction,
upWC: up,
frustum,
});
return frustum
.computeCullingVolume(position, direction, up)
.computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1));
}
const loadedTerrain = (height) => ({ show: true, tilesLoaded: true, getHeight: () => height });
const hiddenGlobe = { show: false, tilesLoaded: true, getHeight: () => undefined };
for (const { label, globe, groundM, cameraM, southM, fov, baseOffScreen } of [
// The Dead Sea (about -430 m) under a hidden globe, seen steeply down
// through a narrow view: both 0 m and 9,000 m above it are off screen.
{ label: 'hidden-globe depression', globe: hiddenGlobe, groundM: -430, cameraM: 20, southM: 180, fov: Math.PI / 12, baseOffScreen: true },
// 2,500 m terrain from 3 km away at a shallow angle: the ellipsoid-height
// base is far below the bottom of the screen.
{ label: 'high ground with loaded terrain', globe: loadedTerrain(2_500), groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true },
{ label: 'high ground under a hidden globe', globe: hiddenGlobe, groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true },
// A low-geoid coast (about -63 m near Mumbai) from a camera 50 m above the
// ellipsoid: the ellipsoidal horizon would hide the ground itself.
{ label: 'ground below the ellipsoid', globe: loadedTerrain(-63), groundM: -63, cameraM: 50, southM: 2_000, baseOffScreen: false },
]) {
test(`${label} in view is sampled`, async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => groundM,
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
env.viewer.scene.globe = globe;
const baseVisibility = aimRealCamera(env, { groundM, cameraM, southM, fov });
if (baseOffScreen)
assert.equal(baseVisibility, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen');
clock.advance(2_100);
env.preRender.raise();
assert.equal(env.sampleCalls.count, 1, 'the ground in view is sampled');
assert.ok(Math.abs(baseHeightM(env) - groundM) < 0.01);
});
}
test('under a hidden globe, ground behind the camera is not sampled', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 2_500,
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
env.viewer.scene.globe = hiddenGlobe;
aimRealCamera(env, { groundM: 2_500, cameraM: 3_500, southM: 3_000, away: true });
clock.advance(2_100);
env.preRender.raise();
assert.equal(env.sampleCalls.count, 0);
});
test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
+83 -4
View File
@@ -1000,9 +1000,9 @@ export function createLocalGeoJsonLayer(
// governor the retry's preRender never arrives on a parked camera
// and the stem stays at ellipsoid height (buried/floating) until
// the user happens to move. Schedule the frame the retry needs.
// Gated on a sampleable scene and in-range records only, so a far
// camera (or a keyless scene) stays fully idle; the distance is
// only computed for still-unsampled stems.
// Gated on a sampleable scene and in-range, in-view records only,
// so a far camera (or a keyless scene) stays fully idle; the
// distance is only computed for still-unsampled stems.
if (
canSampleGround &&
!record.groundSampled &&
@@ -1010,7 +1010,8 @@ export function createLocalGeoJsonLayer(
Cesium.Cartesian3.distance(
viewer.camera.positionWC,
record.base,
) < GROUND_SAMPLE_MAX_DISTANCE_M
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
groundSampleInView(viewer, record)
) {
groundRetryPending = true;
}
@@ -1130,9 +1131,87 @@ function refreshLocalTerrainFloor(viewer, record) {
return true;
}
const groundSampleOccluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
);
const groundSampleCarto = new Cesium.Cartographic();
const groundSampleLow = new Cesium.Cartesian3();
const groundSampleHigh = new Cesium.Cartesian3();
/** The record's vertical at `height` metres above the ellipsoid. */
function groundSamplePoint(record, height, result) {
groundSampleCarto.longitude = record.carto.longitude;
groundSampleCarto.latitude = record.carto.latitude;
groundSampleCarto.height = height;
return Cesium.Cartographic.toCartesian(
groundSampleCarto,
Cesium.Ellipsoid.WGS84,
result,
);
}
/**
* Whether the camera can see the ground under the record: inside the view
* frustum and above the horizon. scene.sampleHeight reads only surface
* rendered for the current view, so a sample of unseen ground passes through
* the Earth (about -6,344 km near Ashburn on Esri) and fails on every 2 s
* retry, each one a full offscreen render; 110 unseen data centres there held
* rest frames at 58-589 ms. Unsampled bases sit on the ellipsoid, so the test
* uses loaded globe terrain when there is some. Otherwise (a hidden globe) it
* tests the vertical across +/-GROUND_SAMPLE_MAX_ABS_HEIGHT_M, every height a
* sample can accept, so high or below-ellipsoid ground in view is never
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
* sees it.
*/
function groundSampleInView(viewer, record) {
const camera = viewer.camera;
const globe = viewer.scene.globe;
const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined;
const known =
Number.isFinite(terrain) &&
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
const low = groundSamplePoint(
record,
known ? terrain : -GROUND_SAMPLE_MAX_ABS_HEIGHT_M,
groundSampleLow,
);
const high = known
? low
: groundSamplePoint(
record,
GROUND_SAMPLE_MAX_ABS_HEIGHT_M,
groundSampleHigh,
);
// The vertical is unseen only when both ends are outside the same plane.
const { planes } = camera.frustum.computeCullingVolume(
camera.positionWC,
camera.directionWC,
camera.upWC,
);
for (const plane of planes) {
if (
Cesium.Cartesian3.dot(plane, low) + plane.w < 0 &&
Cesium.Cartesian3.dot(plane, high) + plane.w < 0
)
return false;
}
// The ellipsoidal occluder hides every point below the ellipsoid from a
// camera near it, so ground below 0 m (low-geoid coasts, the Dead Sea) is
// tested at 0 m; unknown ground at the top of the vertical.
const horizonHeight = known
? Math.max(terrain, 0)
: GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
groundSampleOccluder.cameraPosition = camera.positionWC;
return groundSampleOccluder.isPointVisible(
groundSamplePoint(record, horizonHeight, groundSampleHigh),
);
}
function sampleLocalGroundHeight(viewer, record, now) {
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
if (!groundSampleInView(viewer, record)) return false;
record.lastGroundSampleMs = now;
const globe = viewer.scene.globe;
if (globe?.show && globe.tilesLoaded === false) return false;
+50
View File
@@ -98,6 +98,7 @@ function installAwarenessRuntime({
// status its module never emits guards nothing. See the settled-but-fetching
// tests below, which use militaryInstallations' genuine first-fetch readout.
layerStats = {},
subscribe = undefined,
} = {}) {
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
@@ -215,6 +216,7 @@ function installAwarenessRuntime({
setEnabled,
getLayerLifecycleState,
layers,
...(subscribe ? { subscribe } : {}),
});
militaryAwarenessLayer.setParams({ passive: true });
militaryAwarenessLayer.init(viewer);
@@ -383,6 +385,54 @@ test('Contacts does not claim a dependency already enabling for the user', async
}
});
test('a dependency the user adopts stays on when Contacts turns off', async () => {
// A direct selection, or the Cockpit riding one of its aircraft, promotes a
// layer Contacts turned on into the user's own (adoptLayerVisibility). The
// coordinator must give it up, or its OFF release tears the layer down —
// and the track or the Cockpit with it — before Context restores it.
const calls = [];
const subscribers = new Set();
const runtime = installAwarenessRuntime({
isEnabled: () => false,
isEffectivelyEnabled: () => false,
setEnabled: (layerId, enabled) => {
calls.push({ layerId, enabled });
return Promise.resolve(true);
},
subscribe: (listener) => {
subscribers.add(listener);
return () => subscribers.delete(listener);
},
});
try {
militaryAwarenessLayer.setParams({ passive: false });
await nextTurn();
for (const listener of subscribers)
listener({
type: 'visibility',
layerId: 'flights',
enabled: true,
origin: 'user',
adopted: true,
adoptedFromSelection: true,
});
await militaryAwarenessLayer.disable();
const released = calls
.filter(({ enabled }) => !enabled)
.map(({ layerId }) => layerId)
.sort();
assert.deepEqual(
released,
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
'the adopted layer is the user\'s now; the rest are still released',
);
} finally {
runtime.restore();
}
assert.equal(subscribers.size, 0, 'destroy drops the manager subscription');
});
test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => {
let enabled = false;
let releaseAircraft;
+505
View File
@@ -0,0 +1,505 @@
/**
* Rio de Janeiro route ids to the line a rider would say.
*
* SMTR's realtime feeds send internal keys (`O0731AAN0A` is SN731,
* `20000501110` is BRT line 50). They look decodable but are not:
* `O0014AAA0A` is LECD135, not 14. The table is copied from `routes.txt` in
* SMTR's static GTFS (https://dados.mobilidade.rio/gtfs/schedule, CC BY 4.0),
* valid to 2026-12-31. An id missing here renders as its raw key.
*/
export const RIO_ROUTE_NAMES = Object.freeze({
20000101130: '10',
20000111110: '11',
20000121130: '12',
20000131130: '13',
20000141130: '14',
20000151130: '15',
20000161130: '16',
20000171110: '17',
20000181130: '18',
20000191130: '19',
20000201130: '20',
20000221110: '22',
20000251130: '25',
20000281130: '28',
20000311140: '31',
20000351110: '35',
20000401130: '40',
20000411130: '41',
20000421110: '42',
20000431130: '43',
20000461130: '46',
20000501110: '50',
20000511110: '51',
20000521110: '52',
20000531130: '53',
20000601110: '60',
20000611130: '61',
20000671130: '67',
20000681130: '68',
20000701110: '70',
20000731130: '73',
20000801110: '80',
20000901110: '90',
'20000EXEC1110': 'ESP01',
E2110AAA0A: '2110',
E2111AAA0A: '2111',
E2112AAA0A: '2112',
E2114AAA0A: '2114',
E2303AAA0A: '2303',
E2305AAA0A: '2305',
E2307AAA0A: '2307',
E2308AAA0A: '2308',
E2309AAA0A: '2309',
E2310AAA0A: '2310',
E2334AAA0A: '2334',
E2335AAA0A: '2335',
E2336AAA0A: '2336',
E2337AAA0A: '2337',
E2338AAA0A: '2338',
E2339AAA0A: '2339',
E2342AAA0A: '2342',
E2343AAA0A: '2343',
E2344AAA0A: '2344',
E2345AAA0A: '2345',
E2381AAA0A: '2381',
E2383AAA0A: '2383',
E2613AAA0A: '2613',
E2614AAA0A: '2614',
E2802AAA0A: '2802',
E2803AAA0A: '2803',
E2804AAA0A: '2804',
O0006AAA0A: '006',
O0007AAA0A: '007',
O0007AAN0A: 'SN007',
O0010AAA0A: '010',
O0014AAA0A: 'LECD135',
O0100AAA0A: '100',
O0104AAA0A: '104',
O0104AAN0A: 'SN104',
O0107AAA0A: '107',
O0109AAA0A: 'LECD131',
O0111AAA0A: 'LECD134',
O0117AAA0A: '117',
O0133AAA0A: '133',
O0133AAN0A: 'SN133',
O0157AAA0A: 'LECD127',
O0160AAA0A: '160',
O0161AAA0A: '161',
O0162AAA0A: '162',
O0164AAA0A: 'LECD136',
O0165AAA0A: '165',
O0167AAA0A: '167',
O0169AAA0A: '169',
O0201AAA0A: '201',
O0202AAA0A: '202',
O0209AAA0A: '209',
O0209AAN0A: 'SN209',
O0210AAA0A: '210',
O0217AAA0A: '217',
O0220AAA0A: '220',
O0229AAA0A: '229',
O0232AAA0A: '232',
O0232AAN0A: 'SN232',
O0238AAA0A: '238',
O0238AAN0A: 'SN238',
O0239AAA0A: '239',
O0247AAA0A: '247',
O0249AAA0A: '249',
O0254AAA0A: '254',
O0265AAA0A: '265',
O0265AAN0A: 'SN265',
O0265AAP0A: 'SP265',
O0275AAA0A: '275',
O0277AAA0A: '277',
O0292AAA0A: '292',
O0292AAN0A: 'SN292',
O0296AAA0A: '296',
O0298AAA0A: '298',
O0298AAN0A: 'SN298',
O0300AAA0A: '300',
O0300AAR0A: 'SR300',
O0301AAA0A: '301',
O0302AAA0A: '302',
O0302AAN0A: 'SN302',
O0306AAA0A: '306',
O0311AAA0A: '311',
O0312AAA0A: '312',
O0312AAN0A: 'SN312',
O0313AAA0A: '313',
O0315AAA0A: '315',
O0319AAA0A: '319',
O0319AAN0A: 'SN319',
O0319AAV0A: 'SV319',
O0321AAA0A: '321',
O0323AAA0A: '323',
O0324AAA0A: '324',
O0324AAN0A: 'SN324',
O0324AAP0A: 'SP324',
O0325AAA0A: '325',
O0326AAA0A: '326',
O0326AAPBA: 'SPB326',
O0327AAA0A: '327',
O0328AAA0A: '328',
O0328AAN0A: 'SN328',
O0328AAP0A: 'SP328',
O0328AAR0A: 'SR328',
O0329AAA0A: '329',
O0329AAP0A: 'SP329',
O0335AAA0A: '335',
O0338AAA0A: '338',
O0341AAA0A: '341',
O0342AAA0A: '342',
O0342AAR0A: 'SR342',
O0343AAA0A: '343',
O0343AAP0A: 'SP343',
O0348AAA0A: '348',
O0349AAA0A: '349',
O0350AAA0A: '350',
O0352AAA0A: '352',
O0353AAA0A: '353',
O0353AAN0A: 'SN353',
O0355AAA0A: '355',
O0355AAN0A: 'SN355',
O0355AAR0A: 'SR355',
O0361AAA0A: '361',
O0362AAA0A: '362',
O0362AAR0A: 'SR362',
O0363AAA0A: '363',
O0363AAN0A: 'SN363',
O0363AAV0A: 'SV363',
O0368AAA0A: '368',
O0368AAN0A: 'SN368',
O0369AAA0A: '369',
O0371AAA0A: '371',
O0378AAA0A: '378',
O0378AAR0A: 'SR378',
O0380AAA0A: '380',
O0383AAA0A: '383',
O0383AAN0A: 'SN383',
O0384AAA0A: '384',
O0384AAR0A: 'SR384',
O0385AAA0A: '385',
O0386AAA0A: '386',
O0386AAR0A: 'SR386',
O0388AAA0A: '388',
O0388AAN0A: 'SN388',
O0388AAR0A: 'SR388',
O0389AAP0A: 'SP389',
O0390AAA0A: '390',
O0391AAV0A: 'SV391',
O0393AAA0A: '393',
O0393AAN0A: 'SN393',
O0393AAR0A: 'SR393',
O0397AAA0A: '397',
O0397AAN0A: 'SN397',
O0397AAR0A: 'SR397',
O0399AAA0A: '399',
O0399AAN0A: 'SN399',
O0399AAR0A: 'SR399',
O0404AAP0A: 'SP404',
O0409AAA0A: '409',
O0410AAA0A: '410',
O0410AAN0A: 'LECD139',
O0415AAA0A: '415',
O0415AAN0A: 'SN415',
O0416AAA0A: '416',
O0422AAA0A: '422',
O0422AAN0A: 'SN422',
O0426AAA0A: '426',
O0433AAA0A: 'LECD150',
O0435AAA0A: 'LECD151',
O0448AAA0A: '448',
O0455AAA0A: '455',
O0456AAA0A: 'LECD132',
O0457AAA0A: '457',
O0457AAN0A: 'SN457',
O0461AAA0A: '461',
O0472AAA0A: '472',
O0473AAA0A: '473',
O0474AAA0A: '474',
O0474AAN0A: 'SN474',
O0474AAV0A: 'SV474',
O0475AAA0A: 'LECD133',
O0476AAA0A: '476',
O0483AAA0A: '483',
O0483AAN0A: 'SN483',
O0483AAPAA: 'SPA483',
O0483AAPBA: 'SPB483',
O0483AAV0A: 'SV483',
O0484AAA0A: '484',
O0485AAA0A: '485',
O0485AAP0A: 'SP485',
O0486AAA0A: '486',
O0492AAA0A: '492',
O0497AAA0A: '497',
O0497AAN0A: 'SN497',
O0498AAA0A: '498',
O0507AAA0A: '507',
O0513AAA0A: '513',
O0518AAA0A: '518',
O0519AAA0A: '519',
O0536AAA0A: 'LECD138',
O0539AAA0A: '539',
O0550AAA0A: '550',
O0550AAN0A: 'SN550',
O0550AAPAA: 'SPA550',
O0550AAPBA: 'SPB550',
O0552AAA0A: '552',
O0552AAN0A: 'LECD149',
O0553AAA0A: '553',
O0554AAA0A: '554',
O0554AAN0A: 'SN554',
O0555AAA0A: '555',
O0557AAA0A: '557',
O0565AAA0A: '565',
O0565AAN0A: 'SN565',
O0583AAA0A: '583',
O0584AAA0A: '584',
O0585AAA0A: '585',
O0600AAA0A: '600',
O0600AAN0A: 'SN600',
O0601AAA0A: '601',
O0603AAA0A: '603',
O0606AAA0A: '606',
O0606AAV0A: 'SV606',
O0607AAA0A: '607',
O0608AAA0A: '608',
O0610AAA0A: '610',
O0611AAA0A: '611',
O0613AAA0A: '613',
O0613AAPAA: 'SPA613',
O0614AAA0A: '614',
O0616AAA0A: '616',
O0621AAA0A: '621',
O0622AAA0A: '622',
O0623AAA0A: '623',
O0624AAA0A: '624',
O0624AAN0A: 'SN624',
O0624AAV0A: 'SV624',
O0625AAA0A: '625',
O0626AAA0A: '626',
O0627AAA0A: '627',
O0628AAA0A: '628',
O0629AAA0A: '629',
O0629AAN0A: 'SN629',
O0630AAA0A: '630',
O0634AAA0A: '634',
O0635AAA0A: '635',
O0635AAP0A: 'SP635',
O0636AAA0A: '636',
O0636AAN0A: 'SN636',
O0638AAA0A: '638',
O0639AAA0A: '639',
O0639AAN0A: 'SN639',
O0645AAA0A: '645',
O0649AAA0A: 'LECD126',
O0650AAA0A: '650',
O0651AAA0A: '651',
O0652AAA0A: '652',
O0653AAA0A: '653',
O0663AAA0A: '663',
O0665AAA0A: '665',
O0665AAVAA: 'LECD147',
O0665AAVBA: 'SVB665',
O0669AAA0A: '669',
O0669AAV0A: 'SV669',
O0678AAA0A: '678',
O0679AAA0A: '679',
O0685AAA0A: 'LECD140',
O0685AAVAA: 'LECD148',
O0685AAVBA: 'LECD141',
O0687AAA0A: '687',
O0688AAA0A: '688',
O0691AAA0A: '691',
O0692AAA0A: '692',
O0692AAV0A: 'SV692',
O0693AAA0A: '693',
O0696AAA0A: '696',
O0696AAN0A: 'SN696',
O0711AAA0A: '711',
O0712AAA0A: '712',
O0721AAA0A: '721',
O0731AAA0A: '731',
O0731AAN0A: 'SN731',
O0737AAA0A: '737',
O0741AAA0A: '741',
O0743AAA0A: '743',
O0745AAA0A: '745',
O0746AAA0A: '746',
O0753AAA0A: '753',
O0754AAA0A: '754',
O0756AAA0A: '756',
O0757AAA0A: '757',
O0759AAA0A: '759',
O0759AAP0A: 'SP759',
O0764AAA0A: '764',
O0765AAA0A: '765',
O0766AAA0A: '766',
O0766AAP0A: 'SP766',
O0770AAA0A: '770',
O0771AAA0A: '771',
O0774AAA0A: '774',
O0774AAV0A: 'LECD142',
O0775AAA0A: '775',
O0777AAV0A: 'SV777',
O0778AAA0A: '778',
O0779AAA0A: '779',
O0779AAN0A: 'SN779',
O0780AAA0A: '780',
O0783AAA0A: '783',
O0785AAA0A: '785',
O0786AAA0A: '786',
O0788AAA0A: '788',
O0790AAA0A: '790',
O0790AAN0A: 'SN790',
O0790AAV0A: 'SV790',
O0791AAA0A: '791',
O0793AAA0A: '793',
O0794AAA0A: '794',
O0794AAN0A: 'SN794',
O0795AAP0A: 'SP795',
O0796AAA0A: '796',
O0798AAA0A: '798',
O0799AAA0A: '799',
O0801AAA0A: '801',
O0801AAP0A: 'SP801',
O0802AAA0A: '802',
O0803AAA0A: '803',
O0803AAN0A: 'SN803',
O0803AAP0A: 'SP803',
O0804AAA0A: '804',
O0805AAA0A: '805',
O0805AAN0A: 'SN805',
O0805AAP0A: 'SP805',
O0807AAA0A: '807',
O0808AAA0A: '808',
O0809AAA0A: '809',
O0810AAA0A: '810',
O0810AAN0A: 'SN810',
O0810AAP0A: 'SP810',
O0812AAA0A: '812',
O0817AAA0A: '817',
O0819AAA0A: '819',
O0820AAA0A: '820',
O0821AAA0A: '821',
O0821AAN0A: 'SN821',
O0822AAA0A: '822',
O0825AAA0A: '825',
O0825AAN0A: 'SN825',
O0826AAA0A: '826',
O0829AAA0A: '829',
O0830AAA0A: '830',
O0831AAA0A: '831',
O0831AAV0A: 'SV831',
O0833AAA0A: '833',
O0834AAA0A: '834',
O0835AAA0A: '835',
O0836AAA0A: '836',
O0837AAA0A: '837',
O0838AAA0A: '838',
O0840AAA0A: '840',
O0840AAN0A: 'SN840',
O0841AAA0A: '841',
O0842AAA0A: '842',
O0843AAA0A: '843',
O0844AAA0A: '844',
O0845AAA0A: '845',
O0846AAA0A: '846',
O0846AAN0A: 'SN846',
O0847AAA0A: '847',
O0848AAA0A: '848',
O0848AAN0A: 'SN848',
O0849AAA0A: '849',
O0850AAA0A: '850',
O0851AAA0A: '851',
O0852AAA0A: '852',
O0853AAA0A: '853',
O0853AAN0A: 'SN853',
O0853AAV0A: 'SV853',
O0854AAAAA: '854',
O0855AAA0A: '855',
O0856AAA0A: '856',
O0857AAA0A: '857',
O0857AAN0A: 'SN857',
O0859AAA0A: '859',
O0861AAA0A: '861',
O0861AAN0A: 'SN861',
O0862AAA0A: '862',
O0863AAA0A: '863',
O0863AAV0A: 'SV863',
O0864AAA0A: '864',
O0864AAP0A: 'SP864',
O0865AAA0A: '865',
O0866AAA0A: '866',
O0866AAV0A: 'SV866',
O0867AAA0A: '867',
O0867AAN0A: 'SN867',
O0868AAA0A: '868',
O0869AAA0A: '869',
O0870AAA0A: '870',
O0871AAA0A: '871',
O0872AAA0A: '872',
O0878AAA0A: '878',
O0878AAN0A: 'SN878',
O0878AAV0A: 'SV878',
O0880AAA0A: '880',
O0880AAN0A: 'SN880',
O0881AAA0A: '881',
O0883AAA0A: '883',
O0884AAA0A: '884',
O0884AAN0A: 'SN884',
O0885AAA0A: '885',
O0885AAN0A: 'SN885',
O0892AAA0A: '892',
O0893AAA0A: '893',
O0893AAN0A: 'SN893',
O0894AAA0A: '894',
O0895AAA0A: '895',
O0895AAN0A: 'SN895',
O0897AAA0A: '897',
O0897AAN0A: 'SN897',
O0898AAA0A: '898',
O0898AAN0A: 'SN898',
O0899AAA0A: '899',
O0899AAV0A: 'SV899',
O0900AAA0A: '900',
O0901AAA0A: '901',
O0901AAVBA: 'SVB901',
O0905AAA0A: '905',
O0906AAA0A: '906',
O0908AAA0A: '908',
O0908AAN0A: 'SN908',
O0908AAV0A: 'SV908',
O0910AAA0A: '910',
O0913AAA0A: '913',
O0915AAA0A: '915',
O0917AAA0A: '917',
O0917AAV0A: 'SV917',
O0918AAA0A: '918',
O0918AAP0A: 'SP918',
O0919AAA0A: '919',
O0919AAN0A: 'SN919',
O0920AAA0A: '920',
O0921AAA0A: '921',
O0921AAV0A: 'SV921',
O0922AAA0A: '922',
O0922AAV0A: 'SV922',
O0925AAA0A: '925',
O0926AAA0A: '926',
O0928AAA0A: '928',
O0932AAA0A: '932',
O0940AAA0A: '940',
O0944AAA0A: 'LECD143',
O0946AAA0A: '946',
O0950AAA0A: 'LECD144',
O0951AAA0A: 'LECD145',
O0954AAA0A: '954',
O0954AAN0A: 'SN954',
O0959AAA0A: 'LECD146',
O0961AAA0A: '961',
O0975AAA0A: '975',
O0979AAA0A: '979',
O0987AAA0A: '987',
O0990AAA0A: '990',
});
+59
View File
@@ -1,4 +1,5 @@
import { greatCircleKm } from '../geoDistance.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
/**
* @module transitFeeds
@@ -376,6 +377,18 @@ function burlingtonRouteLabel(routeId) {
return BURLINGTON_ROUTE_NAMES[routeId] || routeId;
}
/**
* Rio route key to the line a rider would say. One SMTR `routes.txt` covers
* both feeds (see rioRouteNames.js).
* @param {string|null} routeId
* @returns {string|null}
*/
function rioRouteLabel(routeId) {
return Object.hasOwn(RIO_ROUTE_NAMES, routeId)
? RIO_ROUTE_NAMES[routeId]
: routeId;
}
/**
* Registry of feeds. Order is presentation order in the stats/credit text.
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
@@ -729,6 +742,52 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus',
routeLabel: burlingtonRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-onibus',
name: 'Rio buses (SPPO)',
operator: 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro',
region: 'Rio de Janeiro, RJ',
// Every vehicle in a 2026-10-29 snapshot, from Santa Cruz in the west to
// the Zona Sul, sat within 31 km of this point.
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/onibus/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f',
attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro — buses (SPPO)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds are m/s, as the spec requires: checked against vehicle movement on 2026-11-05, though a 2026-10-29 snapshot read as km/h. Line names come from SMTR’s GTFS `routes.txt`, under the same CC BY 4.0 terms.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-brt',
name: 'Mobi-Rio BRT',
operator:
'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro, for the Mobi-Rio BRT fleet',
region: 'Rio de Janeiro, RJ',
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/brt/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4',
attribution:
'SMTR / Prefeitura da Cidade do Rio de Janeiro — BRT (Mobi-Rio)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are m/s too. Line names come from the same `routes.txt`.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
]);
/**
+70
View File
@@ -15,6 +15,7 @@ import {
transitModeResolved,
transitRouteLabel,
} from './transitFeeds.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
const ids = new Set();
@@ -482,3 +483,72 @@ test('Burlington is live and carries its terms link, which its licence requires'
assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/);
assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all');
});
test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São Paulo', () => {
const ids = (lat, lon) =>
transitFeedsInRange(lat, lon).map((feed) => feed.id);
for (const [place, lat, lon] of [
['Centro', -22.9068, -43.1729],
['Barra da Tijuca', -23.0004, -43.3659],
['Santa Cruz', -22.9194, -43.6847],
]) {
assert.ok(
ids(lat, lon).includes('smtr-rio-onibus'),
`buses reach ${place}`,
);
assert.ok(ids(lat, lon).includes('smtr-rio-brt'), `BRT reaches ${place}`);
}
assert.deepEqual(
ids(-23.5505, -46.6333).filter((id) => id.startsWith('smtr-rio')),
[],
'São Paulo, 360 km away, never polls Rio',
);
});
test('Rio route keys read as the line on the front of the bus', () => {
// Some keys look parseable and are not (`O0014AAA0A` is LECD135).
const buses = getTransitFeed('smtr-rio-onibus');
assert.equal(transitRouteLabel(buses, 'O0868AAA0A'), '868');
assert.equal(transitRouteLabel(buses, 'O0731AAN0A'), 'SN731');
assert.equal(transitRouteLabel(buses, 'O0759AAP0A'), 'SP759');
assert.equal(transitRouteLabel(buses, 'O0014AAA0A'), 'LECD135');
assert.equal(transitRouteLabel(buses, 'E2303AAA0A'), '2303');
const brt = getTransitFeed('smtr-rio-brt');
assert.equal(transitRouteLabel(brt, '20000501110'), '50');
assert.equal(transitRouteLabel(brt, '20000EXEC1110'), 'ESP01');
// Unknown ids, prototype names included, keep their raw key.
assert.equal(transitRouteLabel(buses, 'O9999AAA0A'), 'O9999AAA0A');
assert.equal(transitRouteLabel(buses, 'constructor'), 'constructor');
assert.equal(transitRouteLabel(brt, null), null);
});
test('the Rio route table is a clean join of published keys to names', () => {
const entries = Object.entries(RIO_ROUTE_NAMES);
assert.ok(entries.length > 400, 'the whole published table is carried');
for (const [id, name] of entries) {
assert.match(id, /^(?:[OE]\d{4}[A-Z0-9]{5}|2\d{4}[A-Z0-9]{4,8})$/, id);
assert.ok(name && name === name.trim(), `${id} has a name`);
assert.notEqual(name, id, `${id} is translated`);
}
});
test('Rio feeds are CC BY 4.0 and live', () => {
for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) {
const feed = getTransitFeed(id);
assert.equal(feed.defaultEnabled, true, `${id} is live`);
assert.equal(feed.license, 'CC BY 4.0');
assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//);
assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/);
assert.equal(transitModeFor(feed, null), 'bus');
}
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources
.split('\n')
.some((line) => line.startsWith('| **SMTR Rio de Janeiro')),
'Rio has a DATA_SOURCES row',
);
});
+10
View File
@@ -137,6 +137,7 @@ function encodeFeed({
const position = new PbfWriter();
position.writeFloatField(1, entity.lat);
position.writeFloatField(2, entity.lon);
if (entity.speed != null) position.writeFloatField(5, entity.speed);
vehicle.writeBytesField(2, position.finish());
if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp);
const descriptor = new PbfWriter();
@@ -284,6 +285,15 @@ test('the snapshot builder fills timestamps from the header it decoded', () => {
assert.equal(snapshot.feedTimestamp, 1_700_000_000);
});
test('Rio speeds pass through as the m/s they are published in', () => {
// Checked against vehicle movement on 2026-11-05.
const bytes = encodeFeed({
entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }],
});
const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1);
assert.equal(rio.vehicles[0].speedMps, 10);
});
test('conditional-request validators and gzip ride along with feed identification', () => {
const entur = getTransitFeed('entur-norway');
const plain = transitUpstreamHeaders(entur);
+3 -2
View File
@@ -1039,10 +1039,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
// Pistes extend the generated layer enums and alias hint; voice geometry
// adds the analyst area scopes and the osm-places layer.
assert.equal(block.length, 31318, 'serialized tool schema length drifted');
// control_cockpit states the own-layer entry rule (Contacts not required).
assert.equal(block.length, 31372, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc',
'7f593e78c232db972b04441f830b6a60f93916a6e5bc5a68eca7422cd1035508',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+162
View File
@@ -0,0 +1,162 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PassThrough } from 'node:stream';
import { EventEmitter } from 'node:events';
import { createGeminiChatHandler } from '../server/providers/gemini/chat.js';
const contents = [{ role: 'user', parts: [{ text: 'Hello' }] }];
async function request(input, overrides = {}) {
const calls = [];
const handler = createGeminiChatHandler({
resolveApiKey: () => 'fixture-key',
resolveRateLimit: () => '0',
resolveHost: () => '',
resolveAllowedHosts: () => '',
tools: [
{ name: 'fly_to', description: 'Fly', parameters: { type: 'object' } },
],
fetchImpl: async (url, options) => {
calls.push({ url, body: JSON.parse(options.body) });
return Response.json({
candidates: [
{
content: { role: 'model', parts: [{ text: 'Hello' }] },
finishReason: 'STOP',
},
],
});
},
...overrides,
});
const req = new PassThrough();
req.method = 'POST';
req.headers = {
host: 'localhost:4190',
origin: 'http://localhost:4190',
'content-type': 'application/json',
};
req.socket = { remoteAddress: '127.0.0.1' };
const res = new EventEmitter();
res.setHeader = () => {};
res.end = (body) => {
res.body = JSON.parse(body);
res.writableEnded = true;
};
const done = handler(req, res);
req.end(JSON.stringify(input));
await done;
return { calls, res };
}
test('Gemini chat pins text output, server tools and model without Live settings', async () => {
const { calls, res } = await request({ contents });
assert.equal(res.statusCode, 200);
assert.match(calls[0].url, /gemini-3.5-flash-lite:generateContent$/);
assert.deepEqual(calls[0].body.generationConfig.responseModalities, ['TEXT']);
assert.equal(
calls[0].body.tools[0].functionDeclarations[0].behavior,
undefined,
);
assert.equal(calls[0].body.outputAudioTranscription, undefined);
});
test('chat rejects caller configuration, media and undeclared tool history before upstream', async () => {
for (const input of [
{ contents, model: 'other' },
{ contents: [{ role: 'user', parts: [{ inlineData: { data: 'AA==' } }] }] },
{
contents: [
{
role: 'model',
parts: [{ functionCall: { name: 'not_allowed', args: {} } }],
},
],
},
]) {
const { calls, res } = await request(input);
assert.equal(res.statusCode, 400);
assert.equal(calls.length, 0);
}
});
test('chat refuses audio models and hides upstream errors', async () => {
const bad = await request(
{ contents },
{ resolveChatModel: () => 'gemini-3.8-live' },
);
assert.equal(bad.res.statusCode, 503);
assert.equal(bad.calls.length, 0);
const failure = await request(
{ contents },
{
fetchImpl: async () =>
new Response('sensitive upstream detail', { status: 500 }),
},
);
assert.equal(failure.res.statusCode, 502);
assert.ok(!JSON.stringify(failure.res.body).includes('sensitive'));
});
test('chat history round-trips signatures and function results while rejecting user signatures', async () => {
const exchange = [
...contents,
{
role: 'model',
parts: [
{
thoughtSignature: 'opaque',
functionCall: { name: 'fly_to', args: {}, id: 'call' },
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'fly_to',
id: 'call',
response: { ok: true },
},
},
],
},
];
const good = await request({ contents: exchange });
assert.equal(good.res.statusCode, 200);
assert.deepEqual(good.calls[0].body.contents, exchange);
const bad = await request({
contents: [
{ role: 'user', parts: [{ text: 'Hello', thoughtSignature: 'opaque' }] },
],
});
assert.equal(bad.res.statusCode, 400);
});
test('foreign and missing origins cannot spend the server key', async () => {
for (const origin of [undefined, 'https://elsewhere.example']) {
let calls = 0;
const handler = createGeminiChatHandler({
resolveHost: () => '',
resolveAllowedHosts: () => '',
fetchImpl: () => {
calls++;
throw Error('unexpected');
},
});
const req = new PassThrough();
req.method = 'POST';
req.headers = {
host: 'localhost:4190',
...(origin ? { origin } : {}),
'content-type': 'application/json',
};
req.socket = { remoteAddress: '127.0.0.1' };
const res = new EventEmitter();
res.setHeader = () => {};
res.end = () => {
res.writableEnded = true;
};
const done = handler(req, res);
req.end('{}');
await done;
assert.equal(res.statusCode, 403);
assert.equal(calls, 0);
}
});
+1 -2
View File
@@ -61,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;
},
},
});
+2 -3
View File
@@ -107,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'));
}
});
+102 -1
View File
@@ -2187,6 +2187,9 @@
"{value} KM/H": "{value} كم/س",
"{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "مصدر البيانات العسكرية متوقف · صورة جزئية",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "مصدر الرحلات متوقف · صورة جزئية",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "قائمة الطائرات القريبة غير مكتملة · لا يعني أن الوضع آمن",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
@@ -3214,6 +3217,19 @@
"Download the timing log of the last play": "تنزيل سجل توقيت آخر تشغيل",
"Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة",
"Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد",
"fire confidence filter|All": "الكل",
"fire confidence filter|Nominal+": "متوسطة فأعلى",
"fire confidence filter|High": "عالية",
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
"Fire intensity (FRP)": "الشدة (FRP)",
"≥ {value} MW": "{value} MW على الأقل",
"Confidence": "الثقة",
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
"fire intensity filter|All": "الكل",
"Detection confidence": "ثقة الرصد",
"{shown} of {total} shown": "المعروضة: {shown} من {total}",
"Name of {name}": "اسم {name}",
"Change the colour of {name}": "تغيير لون {name}",
"Colour of {name}": "لون {name}",
@@ -3306,5 +3322,90 @@
"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": "المجال الجوي المحظور والمقيّد"
}
+102 -1
View File
@@ -2087,6 +2087,9 @@
"{value} KM/H": "{value} KM/H",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "MILITÄR-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUG-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLUGZEUGE IN DER NÄHE UNVOLLSTÄNDIG · KEINE ENTWARNUNG",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
@@ -2907,6 +2910,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",
@@ -2934,6 +2949,19 @@
"Right panels": "Rechte Panels",
"CONFIRMED": "BESTÄTIGT",
"PAD / RTLS": "STARTPLATZ / RTLS",
"fire confidence filter|All": "Alle",
"fire confidence filter|Nominal+": "Mittel+",
"fire confidence filter|High": "Hoch",
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz",
"Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz",
"High confidence detections only": "Nur Erkennungen mit hoher Konfidenz",
"Fire intensity (FRP)": "Intensität (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Konfidenz",
"Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt",
"fire intensity filter|All": "Alle",
"Detection confidence": "Konfidenz der Erkennung",
"{shown} of {total} shown": "{shown} von {total} angezeigt",
"Name of {name}": "Name von {name}",
"Change the colour of {name}": "Farbe von {name} ändern",
"Colour of {name}": "Farbe von {name}",
@@ -3006,5 +3034,78 @@
"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."
}
+102 -1
View File
@@ -1920,6 +1920,9 @@
"N/A": "N/D",
"{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO",
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
"ft": "ft",
"kts": "kn",
@@ -2983,6 +2986,31 @@
"Voice replies appear here.": "Las respuestas de voz aparecen aquí.",
"Preview is off.": "La vista previa está desactivada.",
"Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.",
"fire confidence filter|All": "Todas",
"fire confidence filter|Nominal+": "Nominal+",
"fire confidence filter|High": "Alta",
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja",
"Nominal and high confidence detections": "Detecciones de confianza nominal y alta",
"High confidence detections only": "Solo detecciones de confianza alta",
"Fire intensity (FRP)": "Intensidad (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Confianza",
"Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios",
"fire intensity filter|All": "Todo",
"Detection confidence": "Confianza de la detección",
"{shown} of {total} shown": "{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",
@@ -3081,5 +3109,78 @@
"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."
}
+102 -1
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "ACTUS RÉGIONALES",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT",
"CONTEXT STANDBY": "CONTEXTE EN VEILLE",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT",
@@ -2977,6 +2980,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",
@@ -3004,6 +3019,19 @@
"Right panels": "Panneaux de droite",
"CONFIRMED": "CONFIRMÉE",
"PAD / RTLS": "PAS DE TIR / RTLS",
"fire confidence filter|All": "Toutes",
"fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Élevée",
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible",
"Nominal and high confidence detections": "Détections de confiance nominale et élevée",
"High confidence detections only": "Détections de confiance élevée uniquement",
"Fire intensity (FRP)": "Intensité (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Confiance",
"Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts",
"fire intensity filter|All": "Toutes",
"Detection confidence": "Confiance de la détection",
"{shown} of {total} shown": "{shown} sur {total} affichées",
"Name of {name}": "Nom de {name}",
"Change the colour of {name}": "Changer la couleur de {name}",
"Colour of {name}": "Couleur de {name}",
@@ -3081,5 +3109,78 @@
"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."
}
+102 -1
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "NOTIZIE REGIONALI",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA",
"CONTEXT STANDBY": "CONTESTO IN ATTESA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA",
@@ -2977,6 +2980,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",
@@ -3004,6 +3019,19 @@
"Right panels": "Pannelli a destra",
"CONFIRMED": "CONFERMATA",
"PAD / RTLS": "SITO DI LANCIO / RTLS",
"fire confidence filter|All": "Tutti",
"fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Alta",
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti — molti degli incendi più intensi hanno confidenza bassa",
"Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta",
"High confidence detections only": "Solo rilevamenti con confidenza alta",
"Fire intensity (FRP)": "Intensità (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Confidenza",
"Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt",
"fire intensity filter|All": "Tutti",
"Detection confidence": "Confidenza del rilevamento",
"{shown} of {total} shown": "{shown} di {total} mostrati",
"Name of {name}": "Nome di {name}",
"Change the colour of {name}": "Cambia il colore di {name}",
"Colour of {name}": "Colore di {name}",
@@ -3081,5 +3109,78 @@
"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."
}
+103 -2
View File
@@ -1612,6 +1612,9 @@
"REGIONAL NEWS": "地域ニュース",
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません",
"CONTEXT STANDBY": "コンテキスト待機",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません",
@@ -1684,7 +1687,7 @@
"Restore real aircraft": "実際の航空機に戻す",
"Reclassify as TR-3B": "TR-3Bに再分類",
"TRACK ACQUIRED": "追跡開始",
"{label} · COURSE {heading}°": "{label} · 針路 {heading}°",
"{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°",
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
"No selected data layers": "選択中のデータレイヤーはありません",
"{count} data layers could not be cleared": {
@@ -2837,6 +2840,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": "シーンを作成・編集",
@@ -2864,6 +2879,19 @@
"Right panels": "右パネル",
"CONFIRMED": "確認済み",
"PAD / RTLS": "射場 / RTLS",
"fire confidence filter|All": "すべて",
"fire confidence filter|Nominal+": "中以上",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています",
"Nominal and high confidence detections": "信頼度が中・高の検知",
"High confidence detections only": "信頼度が高の検知のみ",
"Fire intensity (FRP)": "強度(FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "信頼度",
"Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)",
"fire intensity filter|All": "すべて",
"Detection confidence": "検知の信頼度",
"{shown} of {total} shown": "{total}件中{shown}件を表示",
"Name of {name}": "{name}の名前",
"Change the colour of {name}": "{name}の色を変更",
"Colour of {name}": "{name}の色",
@@ -2931,5 +2959,78 @@
"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プロバイダーを選んでください。"
}
+103 -2
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA",
"CONTEXT STANDBY": "CONTEXTO EM ESPERA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA",
@@ -1736,7 +1739,7 @@
"Restore real aircraft": "Restaurar a aeronave real",
"Reclassify as TR-3B": "Reclassificar como TR-3B",
"TRACK ACQUIRED": "RASTRO ADQUIRIDO",
"{label} · COURSE {heading}°": "{label} · CURSO {heading}°",
"{label} · COURSE {heading}°": "{label} · RUMO {heading}°",
"Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente",
"No selected data layers": "Nenhuma camada de dados selecionada",
"{count} data layers could not be cleared": {
@@ -2977,6 +2980,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",
@@ -3004,6 +3019,19 @@
"Right panels": "Painéis à direita",
"CONFIRMED": "CONFIRMADA",
"PAD / RTLS": "PLATAFORMA / RTLS",
"fire confidence filter|All": "Todas",
"fire confidence filter|Nominal+": "Nominal+",
"fire confidence filter|High": "Alta",
"Every detection — many of the strongest fires are rated low confidence": "Todas as detecções — muitos dos incêndios mais intensos têm confiança baixa",
"Nominal and high confidence detections": "Detecções de confiança nominal e alta",
"High confidence detections only": "Somente detecções de confiança alta",
"Fire intensity (FRP)": "Intensidade (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "Confiança",
"Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts",
"fire intensity filter|All": "Todas",
"Detection confidence": "Confiança da detecção",
"{shown} of {total} shown": "{shown} de {total} exibidas",
"Name of {name}": "Nome de {name}",
"Change the colour of {name}": "Mudar a cor de {name}",
"Colour of {name}": "Cor de {name}",
@@ -3081,5 +3109,78 @@
"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\"."
}
+103 -2
View File
@@ -458,7 +458,7 @@
"SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН",
"Select flight {title}": "Выбрать рейс {title}",
"TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА",
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
"{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°",
"Restore real aircraft": "Вернуть реальный самолёт",
"Reclassify as TR-3B": "Переклассифицировать как TR-3B",
"No selected data layers": "Нет выбранных слоёв данных",
@@ -1953,6 +1953,9 @@
"N/A": "Н/Д",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
"ft": "фт",
"kts": "уз",
@@ -3053,6 +3056,31 @@
"Voice replies appear here.": "Здесь появляются голосовые ответы.",
"Preview is off.": "Предпросмотр отключён.",
"Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.",
"fire confidence filter|All": "Все",
"fire confidence filter|Nominal+": "Средняя+",
"fire confidence filter|High": "Высокая",
"Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая",
"Nominal and high confidence detections": "Детекции средней и высокой достоверности",
"High confidence detections only": "Только детекции высокой достоверности",
"Fire intensity (FRP)": "Интенсивность (FRP)",
"≥ {value} MW": "≥ {value} МВт",
"Confidence": "Достоверность",
"Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты",
"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": "РЕДАКТОР",
@@ -3156,5 +3184,78 @@
"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.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках."
}
+103 -2
View File
@@ -1683,6 +1683,9 @@
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
"CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
@@ -1761,7 +1764,7 @@
"Restore real aircraft": "Повернути справжній літак",
"Reclassify as TR-3B": "Перекласифікувати як TR-3B",
"TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО",
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
"{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°",
"Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз",
"No selected data layers": "Шари даних не вибрано",
"{count} data layers could not be cleared": {
@@ -3045,6 +3048,31 @@
"Don't show this again": "Більше не показувати",
"ESC to dismiss": "ESC — приховати",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.",
"fire confidence filter|All": "Усі",
"fire confidence filter|Nominal+": "Середня+",
"fire confidence filter|High": "Висока",
"Every detection — many of the strongest fires are rated low confidence": "Усі виявлення — багато найсильніших пожеж мають низьку достовірність",
"Nominal and high confidence detections": "Виявлення середньої та високої достовірності",
"High confidence detections only": "Лише виявлення високої достовірності",
"Fire intensity (FRP)": "Інтенсивність (FRP)",
"≥ {value} MW": "≥ {value} МВт",
"Confidence": "Достовірність",
"Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати",
"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": "СТАРТ",
@@ -3156,5 +3184,78 @@
"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.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях."
}
+103 -2
View File
@@ -1588,6 +1588,9 @@
"REGIONAL NEWS": "地区新闻",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞",
"CONTEXT STANDBY": "态势待机",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞",
@@ -1660,7 +1663,7 @@
"Restore real aircraft": "恢复真实飞机",
"Reclassify as TR-3B": "重新归类为 TR-3B",
"TRACK ACQUIRED": "已获取航迹",
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
"{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°",
"Context could not restore every layer; try again": "态势未能恢复所有图层,请重试",
"No selected data layers": "没有已选数据图层",
"{count} data layers could not be cleared": {
@@ -2843,6 +2846,31 @@
"other": "{count} 个地标"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
"fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "标称+",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低",
"Nominal and high confidence detections": "标称和高置信度的检测",
"High confidence detections only": "仅高置信度的检测",
"Fire intensity (FRP)": "火势强度 (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)",
"fire intensity filter|All": "全部",
"Detection confidence": "检测置信度",
"{shown} of {total} shown": "显示 {shown} / {total}",
"{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": "创作",
@@ -2931,5 +2959,78 @@
"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 服务商。"
}
+103 -2
View File
@@ -1578,6 +1578,9 @@
"REGIONAL NEWS": "地區新聞",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞",
"CONTEXT STANDBY": "態勢待機",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞",
@@ -1650,7 +1653,7 @@
"Restore real aircraft": "恢復真實飛機",
"Reclassify as TR-3B": "重新歸類為 TR-3B",
"TRACK ACQUIRED": "已獲取航跡",
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
"{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°",
"Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試",
"No selected data layers": "沒有已選數據圖層",
"{count} data layers could not be cleared": {
@@ -2845,6 +2848,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": "場景",
@@ -2870,6 +2885,19 @@
"Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包",
"Left panels": "左側面板",
"Right panels": "右側面板",
"fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "標稱+",
"fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低",
"Nominal and high confidence detections": "標稱和高置信度的檢測",
"High confidence detections only": "僅高置信度的檢測",
"Fire intensity (FRP)": "火勢強度 (FRP)",
"≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
"fire intensity filter|All": "全部",
"Detection confidence": "檢測置信度",
"{shown} of {total} shown": "顯示 {shown} / {total}",
"UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
@@ -2931,5 +2959,78 @@
"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 服務商。"
}
+23 -2
View File
@@ -51,9 +51,11 @@
"Blues",
"Bus",
"CCTV",
"CHAT",
"COCKPIT",
"CRS {value}°",
"CRT",
"Chat",
"Country",
"Cyan",
"Cyber",
@@ -199,6 +201,7 @@
"{value} KM/S",
"ΔN",
"Ⅱ PAUSE",
"≥ {value} MW",
"▲ {frp} MW"
],
"es": [
@@ -219,8 +222,10 @@
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CHAT",
"CRS {value}°",
"CRT",
"Chat",
"Color: {units}",
"Country",
"DEST ---°",
@@ -303,6 +308,7 @@
"WX",
"audio {state}",
"dB",
"fire confidence filter|Nominal+",
"fire confidence|nominal",
"ft",
"hPa",
@@ -325,6 +331,7 @@
"{value} KM",
"{value} KM/H",
"{value} KM/S",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
@@ -357,6 +364,7 @@
"CCTV",
"CCTV OFF",
"CCTV ON",
"CHAT",
"COCKPIT",
"COMMERCIAL",
"CONTACT",
@@ -364,6 +372,7 @@
"CONTACTS · 250 KM",
"CRS {value}°",
"CRT",
"Conversation",
"Country",
"Cyan",
"Cyber",
@@ -466,6 +475,7 @@
"Station",
"Station {id}",
"Style",
"Suggestions",
"TAGS · {tags}",
"TEMP",
"TYPE",
@@ -508,6 +518,7 @@
"ΔE",
"ΔN",
"Ⅱ PAUSE",
"≥ {value} MW",
"▲ {frp} MW"
],
"it": [
@@ -540,9 +551,11 @@
"CCTV OFF",
"CCTV ON",
"CH {index} / {total}",
"CHAT",
"COCKPIT",
"CRS {value}°",
"CRT",
"Chat",
"Country",
"Cyber",
"DEST ---°",
@@ -678,6 +691,7 @@
"{value} KM/H",
"{value} KM/S",
"{value} conf",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
@@ -729,6 +743,7 @@
"{type} SPD: {speed} HDG: {heading}",
"ΔE",
"ΔN",
"≥ {value} MW",
"▲ {frp} MW"
],
"pt": [
@@ -752,8 +767,10 @@
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CHAT",
"CRS {value}°",
"CRT",
"Chat",
"Country",
"DEST ---°",
"DEST {bearing}",
@@ -840,6 +857,7 @@
"WHOT/BHOT",
"WX",
"dB",
"fire confidence filter|Nominal+",
"fire confidence|nominal",
"ft",
"hPa",
@@ -858,7 +876,8 @@
"{value} KM/H",
"{value} KM/S",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"≥ {value} MW"
],
"ru": [
"AIS: --",
@@ -917,9 +936,9 @@
"MM",
"NVG",
"REC",
"SRC {value}",
"S2 GhostMaps",
"S2 · {name}",
"SRC {value}",
"Sentinel-2",
"Techno",
"Trance",
@@ -944,6 +963,7 @@
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/S",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
@@ -1001,6 +1021,7 @@
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/S",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
+1
View File
@@ -23,6 +23,7 @@ export function createControls({ state: layerState, services, parts, source }) {
attachDataManager(dataManager) {
layerState.dataManager = dataManager;
parts.dependencies.watchAdoptions(dataManager);
},
setParams(params = {}) {
+20
View File
@@ -79,6 +79,25 @@ export function createDependencies({
});
}
/**
* Give up a dependency the user adopts. A direct selection, or the Cockpit
* riding one of its aircraft, promotes a layer Contacts turned on into the
* user's own (`DataLayerManager.adoptLayerVisibility`). Releasing it when
* Contacts goes off would tear down that track or Cockpit before the
* Context restore could put the layer back.
*/
function relinquishAdoptedDependency(change) {
if (change?.type === 'visibility' && change.adopted && change.enabled)
layerState.ownedDependencies.delete(change.layerId);
}
/** Follow one manager's adoptions; null stops following. */
function watchAdoptions(dataManager) {
layerState.unwatchAdoptions?.();
layerState.unwatchAdoptions =
dataManager?.subscribe?.(relinquishAdoptedDependency) || null;
}
function activateOperationalContext() {
const activationId = ++layerState.activationId;
layerState.autoFocusRetryPending = false;
@@ -113,6 +132,7 @@ export function createDependencies({
refreshAfterDeferredDependency,
enableDependencies,
releaseOwnedDependencies,
watchAdoptions,
activateOperationalContext,
};
}
+1
View File
@@ -340,6 +340,7 @@ export function createLifecycle({
layerState.clearListener = null;
layerState.subjectClearListener = null;
layerState.viewer = null;
parts.dependencies.watchAdoptions(null);
layerState.dataManager = null;
},
};
+1
View File
@@ -196,6 +196,7 @@ export function createModel({ state: layerState, services, parts, source }) {
id: cohort.id,
label: cohort.label,
source: cohort.source,
sourceStatus: cohort.sourceStatus || null,
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
+2 -1
View File
@@ -8,6 +8,7 @@ import {
t,
translateKnown,
} from '../../i18n/index.js';
import { sourceStatusLabel } from '../../ui/layerNames.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
import { distanceParts } from '../../data/distanceFormat.js';
@@ -150,7 +151,7 @@ export function createPanel({ state: layerState, parts }) {
relationship: summary.relationship.toLowerCase(),
label: cohort.label,
count: `${presentAwarenessCount(summary)}${pageLabel}`,
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
detail: `${sourceStatusLabel(cohort.source, cohort.sourceStatus)}${coverage} · ${presentAwarenessReason(summary)}`,
nearest,
};
}
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPanel } from './panel.js';
test('a Contacts row names its provider as the Layers panel does, with its feed state', () => {
const cohort = (id, source, sourceStatus) => ({
id,
label: id,
source,
sourceStatus,
summary: { relationship: 'OBSERVED', count: 1, nearest: [], reason: 'observed' },
});
const state = {
enabled: true,
results: {
subject: { label: 'AFR38JF' },
cohorts: [
cohort('flights', 'adsb.lol', 'fallback'),
cohort('military', 'adsb.lol', null),
cohort('ais-live-vessels', 'Datalastic', 'stale'),
],
},
cohortPages: new Map(),
panelListeners: new Set(),
panelSignature: '',
navigationIndex: 0,
};
const parts = {
navigation: { canNavigateNext: () => false },
rendering: { hideDirectionOverlay() {} },
};
createPanel({ state, parts }).renderResults();
assert.deepEqual(
state.panelView.rows.map((row) => row.detail),
['adsb.lol · FALLBACK · observed', 'adsb.lol · observed', 'Datalastic · STALE · observed'],
);
state.offPanelLocale?.();
});
+1
View File
@@ -91,6 +91,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
stats.count ?? null,
stats.status || null,
stats.error || null,
stats.source || null,
];
})
.map((parts) => parts.join(':'))
+26
View File
@@ -81,3 +81,29 @@ test('Contacts excludes square corners and measures from the current moving subj
assert.equal(moved.reason, 'mapped matches within 100 km of the subject');
assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 });
});
test('Contacts cohorts carry the provider and whether it answered as a fallback or stale', () => {
const services = { installations: { getNearby: () => [], getStats: () => ({}) },
flights: { getNearby: () => [] }, military: { getNearby: () => [] }, vessels: { getNearby: () => [] } };
const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source) } };
parts.queries = createQueries({ state: {}, services, parts });
const subjectOwner = createSubject({ state: {}, services, parts });
const states = {
// The OpenSky proxy answers from adsb.lol when OpenSky fails.
flights: { available: true, stats: { source: 'adsb.lol', coverage: '250nm regional fallback', count: 4, lastUpdate: 1 } },
military: { available: true, stats: { source: 'adsb.lol', fallback: false, count: 2, lastUpdate: 1 } },
'ais-live-vessels': { available: true, stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1 } },
'military-installations': { available: true, stats: { coverage: { kind: 'subject', radiusM: 100000 } } },
};
const { cohorts } = subjectOwner.evaluateSubject({
id: 'subject', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(0, 0, 10000),
}, states);
const byId = Object.fromEntries(cohorts.map((cohort) => [cohort.id, cohort]));
assert.equal(byId.flights.source, 'adsb.lol');
assert.equal(byId.flights.sourceStatus, 'fallback');
assert.equal(byId.military.sourceStatus, null);
assert.equal(byId['ais-live-vessels'].source, 'Datalastic');
assert.equal(byId['ais-live-vessels'].sourceStatus, 'stale');
assert.equal(byId['military-installations'].sourceStatus, null);
});
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createQueries } from './queries.js';
import { createSubject } from './subject.js';
import { createModel } from './model.js';
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
import { layerFeedState } from '../../data/feedState.js';
import aisLiveVesselsLayer, {
_setVesselStateForTest,
_beginAisSessionForTest,
_applyAisFeedSnapshotForTest,
_setAisRuntimeForTest,
} from '../../data/aisLiveVessels.js';
test('a poll that returns the same stale AIS snapshot restores STALE on Contacts cohorts', () => {
globalThis.document ??= { body: null };
const services = {
installations: { getNearby: () => [], getStats: () => ({}) },
flights: { getNearby: () => [] },
military: { getNearby: () => [] },
vessels: { getNearby: () => [] },
};
const layerState = {
enabled: true,
passive: true,
sourceRevision: '',
cohortPages: new Map(),
subject: {
id: 'site-1',
layerId: 'military-installations',
label: 'Site',
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
};
let states;
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source),
isFlightLayer: (id) => id === 'flights' || id === 'military',
},
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
panel: { renderResults() {} },
};
const realQueries = createQueries({ state: layerState, services, parts });
parts.queries = { ...realQueries, collectSourceStates: () => states };
parts.model = createModel({ state: layerState, services, parts });
const subject = createSubject({ state: layerState, services, parts });
const flights = (lastUpdate) => ({
available: true, enabled: true, stale: false,
stats: { source: 'OpenSky Network', count: 4, lastUpdate },
});
const vessels = (loading) => ({
available: true, enabled: true, stale: true,
stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1000, loading },
});
const rest = {
military: { available: true, enabled: true, stale: false, stats: { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 1 } },
'military-installations': { available: true, enabled: true, stale: false, stats: { coverage: { kind: 'subject', radiusM: 100000 } } },
};
const ais = () => layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels');
// 1. Stale AIS cache, nothing loading.
states = { flights: flights(1), 'ais-live-vessels': vessels(false), ...rest };
subject.refreshSelectedSubject(true);
assert.equal(ais().sourceStatus, 'stale');
// 2. AIS re-poll in flight while the flights poll lands (revision changes).
states = { flights: flights(2), 'ais-live-vessels': vessels(true), ...rest };
subject.refreshSelectedSubject();
assert.equal(ais().sourceStatus, 'stale', 'a poll in flight keeps the snapshot stale');
// 3. AIS poll returns the same stale snapshot; only loading flips back.
states = { flights: flights(2), 'ais-live-vessels': vessels(false), ...rest };
subject.refreshSelectedSubject();
subject.refreshSelectedSubject();
assert.equal(ais().sourceStatus, 'stale', 'Layers reads STALE again; Contacts/Cockpit must too');
});
function fakeRuntime() {
return {
now: () => 1000,
setTimeout: () => ({}),
clearTimeout() {},
};
}
test('first AIS connect (no snapshot yet) carries no STALE on the Contacts cohort', () => {
globalThis.document ??= { body: null };
_setAisRuntimeForTest(fakeRuntime());
_setVesselStateForTest({ viewer: {}, records: [] });
try {
_beginAisSessionForTest();
// What server/providers/vessels/ais-live.js answers while its socket connects.
_applyAisFeedSnapshotForTest({}, {
rows: [], source: 'AISStream', status: 'connecting', refreshing: true,
});
const vesselStats = aisLiveVesselsLayer.getStats();
assert.equal(vesselStats.loading, true);
assert.equal(vesselStats.stale, true);
assert.equal(vesselStats.lastUpdate, null);
assert.equal(layerFeedState(vesselStats), 'loading', 'Layers chip reads LOADING');
const flightsStats = { source: 'OpenSky Network', count: 2, lastUpdate: 5 };
const militaryStats = { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 5 };
const installStats = { coverage: { kind: 'subject', radiusM: 100000 } };
const statsById = {
flights: flightsStats,
military: militaryStats,
'ais-live-vessels': vesselStats,
'military-installations': installStats,
};
const services = {
installations: { getNearby: () => [], getStats: () => installStats },
flights: { getNearby: () => [] },
military: { getNearby: () => [] },
vessels: aisLiveVesselsLayer,
};
const layerState = {
enabled: true,
passive: true,
sourceRevision: '',
cohortPages: new Map(),
dataManager: {
isEnabled: () => true,
getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled' }),
layers: new Map(Object.entries(statsById).map(([id, s]) => [id, { module: { getStats: () => s } }])),
},
subject: {
id: 'site-1',
layerId: 'military-installations',
label: 'Site',
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
};
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) => summarizeAwarenessCohort(items, source),
isFlightLayer: (id) => id === 'flights' || id === 'military',
},
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
panel: { renderResults() {} },
};
parts.queries = createQueries({ state: layerState, services, parts });
parts.model = createModel({ state: layerState, services, parts });
const subject = createSubject({ state: layerState, services, parts });
const vesselsSource = parts.queries.collectSourceStates()['ais-live-vessels'];
assert.equal(vesselsSource.available, false, 'never-answered source is unavailable');
subject.refreshSelectedSubject(true);
const ais = layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels');
assert.equal(ais.sourceStatus, null, 'no snapshot yet: no STALE label, Layers says LOADING');
} finally {
_setVesselStateForTest({ enabled: false });
_setAisRuntimeForTest();
}
});
+2
View File
@@ -26,6 +26,8 @@ export function createState({ services }) {
// layers activate only after Contacts is explicitly selected.
passive: true,
ownedDependencies: new Set(),
/** Stops following the manager's adoptions (see watchAdoptions). */
unwatchAdoptions: null,
subject: null,
results: null,
visual: null,
+17
View File
@@ -3,6 +3,7 @@ import {
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import { layerFeedState } from '../../data/feedState.js';
import { t } from '../../i18n/index.js';
import {
AWARENESS_QUERY_LIMIT,
@@ -52,6 +53,18 @@ export function createSubject({ state: layerState, services, parts, source }) {
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
const evaluatedAt = Date.now();
// A poll in flight keeps FALLBACK or STALE: the revision key ignores loading.
// Before the first answer there is no snapshot, so loading stays.
const sourceStatus = (sourceState) => {
const feedState = layerFeedState(
sourceState.available
? { ...sourceState.stats, loading: false }
: sourceState.stats,
);
return feedState === 'fallback' || feedState === 'stale'
? feedState
: null;
};
const retainedProvenance = (id, label, sourceState, fallbackSource) => {
const retainedSource = sourceState.stats.source || fallbackSource;
return {
@@ -94,6 +107,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'flights',
label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
sourceStatus: sourceStatus(flightsState),
provenance: retainedProvenance(
'flights',
'Flights',
@@ -113,6 +127,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'military',
label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military,
sourceStatus: sourceStatus(militaryState),
provenance: retainedProvenance(
'military',
'Military flights',
@@ -132,6 +147,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'ais-live-vessels',
label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
sourceStatus: sourceStatus(vesselsState),
provenance: retainedProvenance(
'ais-live-vessels',
'AIS vessels',
@@ -149,6 +165,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
sourceStatus: sourceStatus(installationsState),
provenance: retainedProvenance(
'military-installations',
'Mapped installations',
+75 -6
View File
@@ -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);
+89
View File
@@ -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([
+96
View File
@@ -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',
};
}
+74
View File
@@ -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'));
});
+37
View File
@@ -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/);
});
+366
View File
@@ -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;
}
+218
View File
@@ -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);
});
+135
View File
@@ -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;
}
+164
View File
@@ -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);
});
+38
View File
@@ -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,
};
}
+119
View File
@@ -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);
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Drapes the Active Fires heat raster over the globe as one full-extent
* imagery tile on the active imagery host (globe or 3D-tiles), like the wind
* layer's scalar field. Owns the canvas and the imagery layer; nothing else
* in the fires layer touches imagery.
*
* `show` is only for the layer being on or off. The zoom hand-off fades
* `alpha` instead: Cesium unmaps a hidden imagery layer from the globe
* tiles and re-maps it when shown, so a show toggle at the band edge left
* a beat with neither markers nor heat. At alpha 0 the layer stays mapped
* and costs no draw.
*/
const defaultCanvas = (width, height) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
};
export function createHeatImagery({
cesium,
getHost,
createCanvas = defaultCanvas,
}) {
let raster = null;
let layer = null;
let collection = null;
let show = true;
let opacity = 1;
function detach() {
if (layer && collection) collection.remove(layer, true);
layer = null;
collection = null;
}
function attach() {
detach();
if (!raster) return;
const host = getHost?.() ?? { collection: null, kind: 'none' };
if (!host.collection) return;
const canvas = createCanvas(raster.width, raster.height);
const ctx = canvas.getContext('2d');
const image = ctx.createImageData(raster.width, raster.height);
image.data.set(raster.rgba);
ctx.putImageData(image, 0, 0);
const provider = new cesium.SingleTileImageryProvider({
url: canvas.toDataURL('image/png'),
tileWidth: raster.width,
tileHeight: raster.height,
rectangle: cesium.Rectangle.MAX_VALUE,
});
collection = host.collection;
layer = collection.addImageryProvider(provider);
layer.show = show;
layer.alpha = opacity;
collection.raiseToTop?.(layer);
}
return {
setRaster(next) {
raster = next;
attach();
},
/** True once a raster was handed over (painted or waiting for a host). */
hasRaster() {
return raster !== null;
},
setShow(value) {
show = Boolean(value);
if (layer) layer.show = show;
},
/** Hand-off fade, 0..1; the layer stays mapped at 0. */
setOpacity(value) {
opacity = Math.min(1, Math.max(0, Number(value) || 0));
if (layer) layer.alpha = opacity;
},
rehome() {
const host = getHost?.();
if ((host?.collection ?? null) !== collection) attach();
},
destroy() {
detach();
raster = null;
},
};
}
+128
View File
@@ -0,0 +1,128 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHeatImagery } from './heatImagery.js';
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
import { HEAT_RASTER } from './policy.js';
function harness() {
const layers = [];
const removed = [];
const collection = {
addImageryProvider(provider) {
const layer = { provider, show: true, alpha: 1 };
layers.push(layer);
return layer;
},
remove(layer, destroy) {
layers.splice(layers.indexOf(layer), 1);
removed.push({ layer, destroy });
},
raiseToTop() {},
};
let host = { collection, kind: 'globe' };
const cesium = {
Rectangle: { MAX_VALUE: { max: true } },
SingleTileImageryProvider: class {
constructor(options) {
this.options = options;
}
},
};
const createCanvas = (w, h) => ({
width: w,
height: h,
getContext: () => ({
createImageData: (cw, ch) => ({
data: new Uint8ClampedArray(cw * ch * 4),
}),
putImageData() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
});
const imagery = createHeatImagery({
cesium,
getHost: () => host,
createCanvas,
});
return { imagery, layers, removed, collection, setHost: (h) => (host = h) };
}
const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) };
test('setRaster drapes one full-globe tile and replaces it on the next build', () => {
const h = harness();
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 1);
assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true });
assert.equal(h.layers[0].provider.options.tileWidth, 4);
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 1, 'never two heat layers');
assert.equal(h.removed[0].destroy, true);
});
test('setShow toggles without rebuilding', () => {
const h = harness();
h.imagery.setRaster(raster);
const layer = h.layers[0];
h.imagery.setShow(false);
assert.equal(layer.show, false);
h.imagery.setShow(true);
assert.equal(h.layers[0], layer);
assert.equal(layer.show, true);
});
test('setOpacity fades the same layer and survives a rebuild', () => {
const h = harness();
h.imagery.setRaster(raster);
const layer = h.layers[0];
h.imagery.setOpacity(0);
assert.equal(layer.alpha, 0);
assert.equal(layer.show, true, 'a faded layer stays mapped');
h.imagery.setOpacity(0.25);
assert.equal(h.layers[0], layer);
assert.equal(layer.alpha, 0.25);
h.imagery.setRaster(raster);
assert.equal(h.layers[0].alpha, 0.25, 'a rebuilt layer keeps the fade');
h.imagery.setOpacity(7);
assert.equal(h.layers[0].alpha, 1);
});
test('no imagery host keeps nothing draped; rehome brings it back', () => {
const h = harness();
h.setHost({ collection: null, kind: 'none' });
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 0);
h.setHost({ collection: h.collection, kind: 'globe' });
h.imagery.rehome();
assert.equal(h.layers.length, 1);
});
test('destroy removes the layer', () => {
const h = harness();
h.imagery.setRaster(raster);
h.imagery.destroy();
assert.equal(h.layers.length, 0);
});
test('the shipped raster paints a lone weak fire as a distinct spot', () => {
// A hot cluster elsewhere sets the scale, as Africa does on real data.
const fires = [{ lat: 0, lon: 0, frp: 5 }];
for (let i = 0; i < 40; i++) {
fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 });
}
const r = buildHeatRaster(fires, HEAT_RASTER);
const y = Math.floor((90 / 180) * r.height);
const painted = [];
for (let x = 0; x < r.width; x++) {
const lon = ((x + 0.5) / r.width) * 360 - 180;
if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) {
painted.push(x);
}
}
const kmPerPx = 40075 / r.width;
assert.ok(painted.length > 0, 'the weak fire still shows');
assert.ok(
painted.length * kmPerPx <= 100,
`spot ${Math.round(painted.length * kmPerPx)} km wide`,
);
});
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLifecycle } from './lifecycle.js';
test('reenabling an empty filtered layer never reveals the retained pre-filter heat raster', async () => {
let shown = false;
const noop = () => {};
const layerState = {
_enabled: false,
_viewer: {},
_dataSource: {},
_fires: [],
_loading: true,
_pickIndexById: new Map(),
_heatImagery: {
setShow(v) {
shown = v;
},
},
};
const lifecycle = createLifecycle({
layerState,
services: {
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
sprites: { restoreSpriteOrderOnEnable: noop },
context: { clearSelectedEntityContextForLayer: noop },
},
config: { id: 'local-firms', overlayHost: { setVisible: noop } },
components: {
rendering: { applyHandoffFade: noop },
viewport: { installLodWatcher: noop, installMoveEndWatcher: noop },
selection: { installClickHandler: noop },
},
});
await lifecycle.methods.enable(layerState._viewer);
assert.equal(
shown,
false,
'empty current set must keep the old raster hidden while refresh is pending',
);
layerState._fires = [{ frp: null, confidence: null }];
await lifecycle.methods.enable(layerState._viewer);
assert.equal(shown, true, 'nonempty sets still resume their raster');
});
+28 -5
View File
@@ -1,5 +1,6 @@
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { filterFires } from '../../data/firmsIntensity.js';
import { msg } from '../../i18n/index.js';
export function createIngestion({
@@ -40,12 +41,13 @@ export function createIngestion({
layerState._stale = Boolean(payload?.stale);
const previousSelection = layerState._selectedFire;
layerState._selectedFire = null;
layerState._fires = adaptFirmsRecords(payload?.fires);
layerState._cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells
layerState._firesByFrp = [...layerState._fires].sort(
layerState._allFires = adaptFirmsRecords(payload?.fires);
// Sorted once per payload; applyFilter only filters, so a slider drag
// over 200k detections never re-sorts.
layerState._allFiresByFrp = [...layerState._allFires].sort(
(a, b) => b.frp - a.frp,
);
layerState._count = layerState._fires.length;
applyFilter();
// Data age, not response age: a stale proxy payload truthfully reads old.
layerState._lastUpdate = Number.isFinite(payload?.fetchedAt)
? payload.fetchedAt
@@ -65,6 +67,7 @@ export function createIngestion({
}
components.rendering.renderCurrentLod(true);
if (reselected) components.selection.selectFire(reselected, false);
layerState._rowControlsListener?.();
} catch (error) {
if (
request.signal.aborted ||
@@ -83,6 +86,26 @@ export function createIngestion({
}
}
/**
* Run the intensity filter over the loaded payload. This is the ONE place
* the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards,
* the heatmap, the context store, the top-fires query and the analyst
* records, so none of them can disagree about which fires exist. Cells and
* the heat raster are built per shown set, so their caches go with it.
*/
function applyFilter() {
layerState._fires = filterFires(layerState._allFires, layerState._filter);
layerState._cellCacheByGrid.clear();
layerState._heatRaster = null;
layerState._firesByFrp = filterFires(
layerState._allFiresByFrp,
layerState._filter,
);
layerState._count = layerState._fires.length;
layerState._totalCount = layerState._allFires.length;
}
/**
* Find the record in the freshly-loaded set matching a previous selection.
* Identity is the detection itself (lat/lon/acquisition time) — indices
@@ -109,5 +132,5 @@ export function createIngestion({
},
};
return { loadHeatmap, findMatchingFire, methods };
return { loadHeatmap, applyFilter, findMatchingFire, methods };
}
+30
View File
@@ -14,6 +14,20 @@ export function createLifecycle({
const { id, overlayHost } = config;
const methods = {
/**
* Shell services: `imageryHost()` resolves where draped imagery goes on
* the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The
* heatmap moves with it; without a host it drapes on viewer.imageryLayers.
* @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services
*/
attachShellServices(services) {
layerState._imageryHost =
typeof services?.imageryHost === 'function'
? services.imageryHost
: null;
layerState._heatImagery?.rehome();
},
init(viewer) {
if (layerState._destroyed) return;
layerState._viewer = viewer;
@@ -40,6 +54,10 @@ export function createLifecycle({
layerState._billboards.show = true;
}
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
// A filter can leave an old raster cached while the current set is empty.
layerState._heatImagery?.setShow(layerState._fires.length > 0);
// The camera can have crossed the hand-off while the layer was off.
components.rendering.applyHandoffFade(true);
components.viewport.installLodWatcher();
components.viewport.installMoveEndWatcher();
components.selection.installClickHandler();
@@ -64,6 +82,7 @@ export function createLifecycle({
components.selection.clearFireSelection();
if (layerState._dataSource) layerState._dataSource.show = false;
if (layerState._billboards) layerState._billboards.show = false;
layerState._heatImagery?.setShow(false);
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
clearSelectedEntityContextForLayer(id);
@@ -91,10 +110,21 @@ export function createLifecycle({
components.rendering.removeDetectionCollections(viewer);
components.selection.clearContextRegistrations();
clearSelectedEntityContextForLayer(id);
layerState._allFires = [];
layerState._allFiresByFrp = [];
layerState._fires = [];
layerState._firesByFrp = [];
layerState._cellCacheByGrid.clear();
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = null;
layerState._heatImagery?.destroy();
layerState._heatImagery = null;
layerState._heatRaster = null;
layerState._handoffHeat = -1;
layerState._imageryHost = null;
layerState._count = 0;
layerState._totalCount = 0;
layerState._rowControlsListener = null;
layerState._cellCount = 0;
layerState._lastUpdate = null;
layerState._keyRequired = false;
+43 -27
View File
@@ -6,6 +6,7 @@ import {
accentForSeverity,
formatFrpMw,
} from '../../data/firmsLabels.js';
import { FRP_RAMP, frpRampStop } from '../../data/firmsIntensity.js';
import {
VIEW_PADDING,
DETECTION_COLOR_STOPS,
@@ -119,27 +120,30 @@ export function createModel({
);
}
function heatColor(value, alpha) {
if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha);
if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha);
return Cesium.Color.YELLOW.withAlpha(alpha);
/**
* The ramp stop for a fire radiative power (stepped, weakest first).
* Intensity alone drives the color: confidence feeds the card text and the
* switch, brightness is unreliable (saturates at 367 K) — see HAL-675.
* @param {number} frp - Megawatts.
* @returns {{name: string, minFrp: number, color: Cesium.Color}}
*/
function frpColorStop(frp) {
const name = frpRampStop(frp).name;
return (
DETECTION_COLOR_STOPS.find((stop) => stop.name === name) ||
DETECTION_COLOR_STOPS[0]
);
}
/**
* Pick a sprite color stop from FRP + confidence, reusing the same
* yellow → orange → red thresholds as the aggregated heat cells.
* Pick a sprite color stop for one detection from its FRP on the shared ramp.
* @param {Object} fire - Detection record.
* @returns {{name: string, color: Cesium.Color}}
*/
function detectionColorStop(fire) {
const heat = Math.min(
1,
Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15,
);
if (heat > 0.72) return DETECTION_COLOR_STOPS[0];
if (heat > 0.42) return DETECTION_COLOR_STOPS[1];
return DETECTION_COLOR_STOPS[2];
return frpColorStop(fire?.frp);
}
/** FRP → core marker pixel size, clamped to 8..28px. */
@@ -227,6 +231,19 @@ export function createModel({
return fire.position;
}
/**
* The anchor already built for a detection, without the floor lookup;
* for the far marker band, where floors are never warmed and the height
* difference is invisible, so tens of thousands of markers skip a cache
* read each per rebuild. Builds one through {@link firePosition} once.
* @param {Object} fire - Detection record.
* @returns {Cesium.Cartesian3}
*/
function fireBuiltPosition(fire) {
return fire.position ?? firePosition(fire);
}
/**
* Occlusion-test anchor for one detection. Returns the render position
* outright for anchors comfortably above the ellipsoid (the common case) and
@@ -234,11 +251,11 @@ export function createModel({
* NEVER feeds rendering: the datum-correct anchor from {@link firePosition}
* is what the sprite and the card are drawn at.
* @param {Object} fire - Detection record.
* @param {Cesium.Cartesian3} [position] Its render anchor, if at hand.
* @returns {Cesium.Cartesian3}
*/
function fireCullPosition(fire) {
const position = firePosition(fire);
function fireCullPosition(fire, position = firePosition(fire)) {
if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position;
if (!fire.cullPosition) {
fire.cullPosition = Cesium.Cartesian3.fromDegrees(
@@ -398,8 +415,8 @@ export function createModel({
/**
* Card model for an aggregated heat cell, e.g. title "14 FIRES", detail
* "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized
* score is only known at renderCells time). Exported for unit tests.
* "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets
* it with {@link cellCardAccent}). Exported for unit tests.
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
* @param {number} nowMs - Current epoch milliseconds.
* @returns {Object} firmsLabels entry.
@@ -417,7 +434,7 @@ export function createModel({
position: candidate.position,
cullPosition: candidate.cullPosition || candidate.position,
gapPx: 10,
accent: candidate.accent || accentForSeverity('yellow'),
accent: candidate.accent || accentForSeverity(FRP_RAMP[0].name),
// The count picks the plural form of the language ("حريقان (2)",
// "14 حريقًا", "14 ПОЖАРОВ"); the card shows it in capitals like the
// other card titles.
@@ -464,16 +481,14 @@ export function createModel({
}
/**
* Heat-normalized cell score → severity accent, mirroring heatColor's
* red/orange/yellow thresholds.
* @param {number} normalized - 0..1 normalized heat score.
* Cell card accent: the cell's strongest fire on the FRP ramp, the same
* color its markers take once the camera is close enough to show them.
* @param {{maxFrp?: number}} cell - Aggregated cell.
* @returns {string} "r, g, b" accent string.
*/
function cellAccent(normalized) {
if (normalized > 0.72) return accentForSeverity('red');
if (normalized > 0.42) return accentForSeverity('orange');
return accentForSeverity('yellow');
function cellCardAccent(cell) {
return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name);
}
/** Coordinate line like "30.512°N 75.831°E". */
@@ -579,12 +594,13 @@ export function createModel({
boundsContainPoint,
cellIntersectsBounds,
heatScore,
heatColor,
frpColorStop,
detectionColorStop,
frpPixelSize,
sizeBucket,
glowSprite,
firePosition,
fireBuiltPosition,
fireCullPosition,
cellCullPosition,
applyHorizonCull,
@@ -593,7 +609,7 @@ export function createModel({
buildFireCard,
buildCellCard,
applyFirmsOverlayPolicy,
cellAccent,
cellCardAccent,
formatLatLon,
formatFrp,
formatAge,
+57 -13
View File
@@ -1,4 +1,5 @@
import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js';
import { FRP_RAMP } from '../../data/firmsIntensity.js';
import * as Cesium from 'cesium';
/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */
@@ -6,9 +7,10 @@ import * as Cesium from 'cesium';
export const REFRESH_INTERVAL_MS = 600_000;
/**
* LOD bands keyed by camera height. `cells` bands render aggregated
* ground-clamped heat rectangles; `detections` bands (< ~750km plus the
* local band) render individual fire detections as glow-sprite billboards.
* LOD bands keyed by camera height. `heat` bands (above ~4,500 km) drape a
* Snap Map–style heatmap over the globe (firmsHeatRaster.js); their
* `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections`
* bands render individual fire detections as glow-sprite billboards.
* Labels in every band go through the shared screen-space greedy declutter
* (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs.
*/
@@ -17,24 +19,28 @@ export const LOD_LEVELS = [
{
id: 'global',
minHeight: 9000000,
mode: 'cells',
mode: 'heat',
gridDegrees: 2.0,
maxCells: 1800,
labelDistance: 12000000,
},
{
id: 'regional',
minHeight: 3000000,
mode: 'cells',
minHeight: 4500000,
mode: 'heat',
gridDegrees: 1.0,
maxCells: 3600,
labelDistance: 8500000,
},
{
// Up to 40,000 markers: at the 3,600 km end of the hand-off the padded
// view over Africa, the densest region, holds ~40,000 detections, and a
// tighter cap left the heatmap's weak spots dissolving into nothing.
// A rebuild of that many costs ~60 ms, only there.
id: 'local',
minHeight: 750000,
mode: 'detections',
maxDetections: 2500,
maxDetections: 40000,
labelDistance: 4500000,
},
{
@@ -46,6 +52,38 @@ export const LOD_LEVELS = [
},
];
/**
* Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a
* one-pixel Gaussian σ, so a lone fire is a spot about the size of its
* marker near the hand-off and the two cross-fade without a jump;
* neighbours merge only when they nearly touch (σ 3 on a 2048 grid smeared
* each fire over ~200 km). Built once per data refresh or filter change,
* never per camera move.
*/
export const HEAT_RASTER = Object.freeze({
width: 4096,
height: 2048,
sigmaPx: 1,
});
/**
* Camera heights, in metres, over which the heatmap and the markers
* cross-fade around the 4,500 km band edge: markers alone below `from`,
* heatmap alone above `to`. Wider than the ±10 % band hysteresis, so
* whichever band is active inside it, both layers are already built.
*/
export const HANDOFF_FADE = Object.freeze({ from: 3_600_000, to: 5_400_000 });
/**
* Trailing delay before a stale heatmap is rebuilt while one is already on
* screen: a slider drag fires many filter changes, and a build plus PNG
* encode costs a few hundred milliseconds. The first paint is immediate.
*/
export const HEAT_REBUILD_MS = 150;
export const LOD_CHECK_MS = 650;
/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */
@@ -88,10 +126,16 @@ export const CULL_LIFT_THRESHOLD_M = 10;
export const CULL_LIFT_M = 12;
/** Color stops shared by cell heat fills and detection glow sprites. */
/**
* Color stops for detection glow sprites and card accents: the
* six-stop ember → white-hot FRP ramp (firmsIntensity.js), weakest first, as Cesium
* colors. Stop names key the glow-sprite cache and the card accents.
*/
export const DETECTION_COLOR_STOPS = [
{ name: 'red', color: Cesium.Color.RED },
{ name: 'orange', color: Cesium.Color.ORANGE },
{ name: 'yellow', color: Cesium.Color.YELLOW },
];
export const DETECTION_COLOR_STOPS = FRP_RAMP.map((stop) =>
Object.freeze({
name: stop.name,
minFrp: stop.minFrp,
color: Cesium.Color.fromCssColorString(stop.hex),
}),
);
+161 -2
View File
@@ -1,6 +1,18 @@
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { t } from '../../i18n/index.js';
import { REFRESH_INTERVAL_MS } from './policy.js';
import {
FIRMS_CONFIDENCE_MODES,
FRP_SLIDER_TICKS,
firePassesFilter,
frpForSliderPosition,
normalizeFireFilter,
rampTrackGradient,
sliderPositionForFrp,
} from '../../data/firmsIntensity.js';
import { formatNumber, t } from '../../i18n/index.js';
import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js';
/** Slider resolution: whole-percent positions along the log axis. */
const SLIDER_STEPS = 1000;
export function createQueries({
layerState,
@@ -11,6 +23,25 @@ export function createQueries({
}) {
const { id, name, icon, source } = config;
/** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */
function frpLabel(frp) {
return formatNumber(frp, { maximumFractionDigits: 0 });
}
const confidenceLabels = () => ({
all: t('All', { context: 'fire confidence filter' }),
nominal: t('Nominal+', { context: 'fire confidence filter' }),
high: t('High', { context: 'fire confidence filter' }),
});
const confidenceTitles = () => ({
all: t(
'Every detection — many of the strongest fires are rated low confidence',
),
nominal: t('Nominal and high confidence detections'),
high: t('High confidence detections only'),
});
const methods = {
/** Return the selected detection as a plain record for application consumers. */
getSelectedInfo() {
@@ -51,6 +82,110 @@ export function createQueries({
// not this interval — is what protects the upstream FIRMS quota).
updateInterval: REFRESH_INTERVAL_MS,
/**
* The intensity filter as layer options (share token `w`, owner
* `local-firms` in layerState.js).
* @returns {{minFrp: number, confidence: string}}
*/
getParams() {
return { ...layerState._filter };
},
/**
* Apply a (partial) intensity filter. Invalid fields keep their current
* value. The filter runs in one place (ingestion.applyFilter) and the LOD
* rebuild re-derives markers, cells, cards and context registrations from
* the shown set; a selected fire that no longer passes is deselected.
* @param {{minFrp?: number, confidence?: string}} params
* @returns {boolean} True — the request is always accepted.
*/
setParams(params = {}) {
const next = normalizeFireFilter(params, layerState._filter);
if (
next.minFrp === layerState._filter.minFrp &&
next.confidence === layerState._filter.confidence
)
return true;
layerState._filter = next;
if (!layerState._allFires.length) return true;
components.ingestion.applyFilter();
if (
layerState._selectedFire &&
!firePassesFilter(layerState._selectedFire, next)
) {
components.selection.clearFireSelection();
}
if (layerState._enabled) components.rendering.renderCurrentLod(true);
layerState._rowControlsListener?.();
return true;
},
/**
* Row controls under the Active Fires row: the log-scale minimum-FRP
* slider whose track is painted with the intensity ramp (so it doubles as
* the legend), the All / Nominal+ / High confidence switch, and the
* "N of M shown" counter.
*/
getRowControls() {
const { minFrp, confidence } = layerState._filter;
const labels = confidenceLabels();
const titles = confidenceTitles();
const shown = layerState._count;
const total = layerState._totalCount;
return {
slider: {
id: 'min-frp',
label: t('Fire intensity (FRP)'),
ariaLabel: t('Minimum fire radiative power, megawatts'),
valueText:
minFrp > 0
? t('≥ {value} MW', { value: frpLabel(minFrp) })
: t('All', { context: 'fire intensity filter' }),
min: 0,
max: SLIDER_STEPS,
step: 1,
value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS),
track: rampTrackGradient(),
ticks: FRP_SLIDER_TICKS.map((frp) => ({
at: sliderPositionForFrp(frp),
label: frpLabel(frp),
})),
params: (value) => ({
minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS),
}),
},
segmented: {
id: 'confidence',
label: t('Confidence'),
ariaLabel: t('Detection confidence'),
options: FIRMS_CONFIDENCE_MODES.map((mode) => ({
id: `confidence-${mode}`,
label: labels[mode],
title: titles[mode],
active: mode === confidence,
params: { confidence: mode },
})),
},
info: total
? t('{shown} of {total} shown', {
shown: formatNumber(shown),
total: formatNumber(total),
})
: '',
};
},
/**
* Install the panel's "row controls changed" callback: a load or a filter
* change moves the counter, and nothing else repaints the row before the
* next scheduled refresh.
* @param {(() => void)|null} listener
*/
setRowControlsListener(listener) {
layerState._rowControlsListener =
typeof listener === 'function' ? listener : null;
},
/**
* Layer stats for the data panel. Degraded feed states surface through
* `error` (established qa-failstate pattern: a dead feed must never look
@@ -84,7 +219,9 @@ export function createQueries({
});
}
return {
// Shown (filtered) detections; `total` is the loaded payload.
count: layerState._count,
total: layerState._totalCount,
cells: layerState._cellCount,
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
@@ -172,12 +309,34 @@ export function createQueries({
return result;
},
/** Test seam: render one named LOD band against a near-global view. */
_renderLodForTest(lodId) {
components.rendering.renderBand(
LOD_LEVELS.find((level) => level.id === lodId),
null,
);
},
/** Test seam: one frame of the hand-off fade at the current camera. */
_applyHandoffFadeForTest() {
components.rendering.applyHandoffFade();
},
/** Test seam: entities drawn by the layer's data source. */
_entityCountForTest() {
return layerState._dataSource?.entities?.values?.length ?? 0;
},
/** Test seam that binds the production click path and indexes. */
_bindInteractionForTest(viewer, fires = []) {
layerState._viewer = viewer;
layerState._enabled = true;
layerState._allFires = fires;
layerState._allFiresByFrp = [...fires];
layerState._fires = fires;
layerState._firesByFrp = [...fires];
layerState._count = fires.length;
layerState._totalCount = fires.length;
layerState._pickIndexById.clear();
layerState._labelLodDistance = 1e7;
layerState._labelCandidates = fires.map((fire) => {
+191 -83
View File
@@ -1,7 +1,21 @@
import * as Cesium from 'cesium';
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js';
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
import {
LOD_LEVELS,
CONTEXT_TOP_N,
HANDOFF_FADE,
HEAT_RASTER,
HEAT_REBUILD_MS,
} from './policy.js';
import { createHeatImagery } from './heatImagery.js';
/** The markers drawn under the far bands' heatmap for the hand-off fade. */
const HANDOFF_MARKER_LOD = LOD_LEVELS.find((lod) => lod.mode === 'detections');
/** Fade steps: a moving camera rewrites marker colors at most this often. */
const FADE_STEPS = 32;
export function createRendering({
layerState,
@@ -29,8 +43,11 @@ export function createRendering({
!layerState._dataSource ||
!layerState._fires.length ||
!layerState._viewer
)
) {
// A filter that leaves nothing shown must not leave the old heat behind.
if (!layerState._fires.length) layerState._heatImagery?.setShow(false);
return false;
}
const lodIndex = components.viewport.selectLodIndex(
components.viewport.cameraHeight(),
);
@@ -51,14 +68,54 @@ export function createRendering({
? components.model.paddedDegreeBounds(viewRect)
: null;
if (lod.mode === 'detections') {
renderDetections(lod, bounds);
} else {
renderCells(aggregateFires(lod, bounds), lod, bounds);
}
renderBand(lod, bounds);
return true;
}
/**
* Draw one band. Markers and heatmap stay built in every band; the
* hand-off only fades between them.
* @param {Object} lod - Band descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
*/
function renderBand(lod, bounds) {
prepareHeat(lod.mode === 'heat');
if (lod.mode === 'detections') renderDetections(lod, bounds);
else renderHeat(lod, bounds);
applyHandoffFade(true);
}
const fadeColor = new Cesium.Color();
/**
* Cross-fade between markers and heatmap by camera height (HANDOFF_FADE),
* on every rendered frame, so a fast zoom never outruns the throttled band
* check. Cheap when nothing changed: one height read and a compare.
* @param {boolean} [force] Reapply, e.g. to freshly added markers.
*/
function applyHandoffFade(force = false) {
const height = components.viewport.cameraHeight();
const t = Math.min(
1,
Math.max(
0,
(height - HANDOFF_FADE.from) / (HANDOFF_FADE.to - HANDOFF_FADE.from),
),
);
const heat = Math.round(t * t * (3 - 2 * t) * FADE_STEPS) / FADE_STEPS;
if (!force && heat === layerState._handoffHeat) return;
layerState._handoffHeat = heat;
layerState._heatImagery?.setOpacity(heat);
const billboards = layerState._billboards;
if (!billboards) return;
billboards.show = layerState._enabled && heat < 1;
const color = Cesium.Color.WHITE.withAlpha(1 - heat, fadeColor);
for (let i = 0; i < billboards.length; i += 1)
billboards.get(i).color = color;
}
/**
* Bin all detections into grid cells, then viewport-clip BEFORE the
* top-N cap so fires in view never lose their slot to higher-scoring
@@ -118,65 +175,96 @@ export function createRendering({
return clipped;
}
/** The heat imagery adapter, created on first use. */
function heatImagery() {
if (!layerState._heatImagery) {
layerState._heatImagery = createHeatImagery({
cesium: Cesium,
getHost: () =>
layerState._imageryHost?.() ?? {
collection: layerState._viewer?.imageryLayers ?? null,
kind: 'globe',
},
});
}
return layerState._heatImagery;
}
/** Build the heat raster from the shown set and drape it. */
function rebuildHeatRaster() {
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = null;
if (layerState._destroyed) return;
layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER);
heatImagery().setRaster(layerState._heatRaster);
}
/**
* Render aggregated heat cells (global/regional bands) as ground-clamped
* rectangles. Cell labels do NOT live on the entities anymore — they go
* through the same unclamped, screen-space-decluttered label pipeline as
* detections (see {@link rebuildAmbientLabels}).
* @param {Array<Object>} cells - Aggregated cells, heat-sorted descending.
* Make sure the draped heatmap matches the shown set. The first paint in a
* far band is immediate; other builds (filter drags, refreshes, the
* preload under the markers) trail by HEAT_REBUILD_MS and keep the
* previous heatmap on screen meanwhile.
* @param {boolean} now Build at once when nothing is draped yet.
*/
function ensureHeatRaster(now) {
if (layerState._heatRaster) return;
if (now && !heatImagery().hasRaster()) {
rebuildHeatRaster();
return;
}
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = setTimeout(
rebuildHeatRaster,
HEAT_REBUILD_MS,
);
}
/**
* Keep the heatmap draped on the current map stack and current with the
* shown set in every band; the hand-off fade decides how much of it shows.
* @param {boolean} now The camera is in a far band.
*/
function prepareHeat(now) {
const imagery = heatImagery();
// The map stack may have changed since the last render.
imagery.rehome();
ensureHeatRaster(now);
imagery.setShow(layerState._enabled);
}
/**
* Far bands (global/regional): a Snap Map–style heatmap draped over the
* globe, transparent where nothing burns, plus the decluttered "N FIRES"
* cards from the coarse aggregation. The nearest band's markers are kept
* built underneath for the hand-off fade and hidden once it completes;
* cards go through the same screen-space label pipeline as detections
* (see {@link rebuildAmbientLabels}).
* @param {Object} lod - Active LOD descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
*/
function renderCells(cells, lod, bounds) {
ensureDetectionCollections();
layerState._dataSource.entities.removeAll();
if (layerState._billboards) layerState._billboards.removeAll();
layerState._pickIndexById.clear();
layerState._cullPositions.length = 0;
layerState._cellCount = cells.length;
const maxScore = Math.max(1, ...cells.map(components.model.heatScore));
function renderHeat(lod, bounds) {
paintMarkers(HANDOFF_MARKER_LOD, bounds);
const cells = aggregateFires(lod, bounds);
layerState._cellCount = cells.length;
layerState._labelCandidates = [];
layerState._labelLodDistance = lod.labelDistance;
// Cells arrive heat-sorted, so candidate order doubles as label priority.
for (const cell of cells) {
const score = components.model.heatScore(cell);
const normalized = Math.min(1, Math.sqrt(score / maxScore));
const alpha = 0.16 + normalized * 0.5;
const color = components.model.heatColor(normalized, alpha);
const centerLon = cell.lonCell + lod.gridDegrees / 2;
const centerLat = cell.latCell + lod.gridDegrees / 2;
const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0);
layerState._dataSource.entities.add({
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(
cell.lonCell,
cell.latCell,
cell.lonCell + lod.gridDegrees,
cell.latCell + lod.gridDegrees,
),
material: new Cesium.ColorMaterialProperty(color),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
position,
properties: {
count: cell.count,
intensity: score,
maxFrp: cell.maxFrp,
night: cell.night,
},
});
// Cells arrive heat-sorted, so candidate order doubles as label priority.
// Accent is resolved here because `normalized` only exists in this walk.
layerState._labelCandidates.push({
position,
position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0),
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
fire: null,
cell,
accent: components.model.cellAccent(normalized),
accent: components.model.cellCardAccent(cell),
});
}
@@ -202,9 +290,48 @@ export function createRendering({
*/
function renderDetections(lod, bounds) {
const markers = paintMarkers(lod, bounds);
if (!markers) return;
const candidates = markers.map((marker) => marker.fire);
layerState._cellCount = candidates.length;
// FRP-sorted walk: marker order doubles as label priority.
layerState._labelCandidates = markers;
layerState._labelLodDistance = lod.labelDistance;
components.cards.rebuildAmbientLabels();
components.selection.refreshContextRegistrations(
candidates.slice(0, CONTEXT_TOP_N),
);
if (lod.id === 'close') {
// Batched (chunked ≤200, sequential, session-cached) DEM warm for the
// rendered subset; fires are static so each coarse cell resolves once
// ever. Re-render ONLY when a floor actually landed — a failed resolve
// reports false, so this chain terminates instead of looping against a
// down proxy (the next camera-driven rebuild retries).
warmFireAnchorFloors(candidates).then((warmed) => {
if (!warmed || !layerState._enabled || !layerState._viewer) return;
const currentLod = LOD_LEVELS[layerState._currentLodIndex];
if (!currentLod || currentLod.mode !== 'detections') return;
renderCurrentLod(true);
});
}
}
/**
* The glow-sprite markers for a detections band: the strongest fires in
* view, capped at the band's maxDetections, horizon-culled.
* @param {Object} lod - Detections band descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
* @returns {?Array<Object>} Label candidates for the fires drawn,
* strongest first; null before the sprite collection exists.
*/
function paintMarkers(lod, bounds) {
ensureDetectionCollections();
layerState._dataSource.entities.removeAll();
if (!layerState._billboards) return;
if (!layerState._billboards) return null;
layerState._billboards.removeAll();
layerState._pickIndexById.clear();
layerState._cullPositions.length = 0;
@@ -226,17 +353,20 @@ export function createRendering({
candidates = layerState._firesByFrp.slice(0, lod.maxDetections);
}
layerState._cellCount = candidates.length;
layerState._labelCandidates = [];
layerState._labelLodDistance = lod.labelDistance;
const markers = [];
// Only the close band warms terrain floors (renderDetections).
const anchor =
lod.id === 'close'
? components.model.firePosition
: components.model.fireBuiltPosition;
for (const fire of candidates) {
const coreSize = components.model.frpPixelSize(fire.frp);
const position = components.model.firePosition(fire);
const cullPosition = components.model.fireCullPosition(fire);
const position = anchor(fire);
const cullPosition = components.model.fireCullPosition(fire, position);
const pickId = `firms-${fire.index}`;
layerState._pickIndexById.set(pickId, fire);
layerState._cullPositions.push(cullPosition);
markers.push({ position, cullPosition, fire, cell: null });
layerState._billboards.add({
id: pickId,
position,
@@ -248,37 +378,13 @@ export function createRendering({
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
// FRP-sorted walk: candidate order doubles as label priority.
layerState._labelCandidates.push({
position,
cullPosition,
fire,
cell: null,
});
}
// Freshly-added sprites default to show=true; cull the far side now so a
// rebuild never flashes through-the-planet detections while the camera
// sits still (the moveEnd/preRender hooks only fire on camera motion).
refreshHorizonCulling();
components.cards.rebuildAmbientLabels();
components.selection.refreshContextRegistrations(
candidates.slice(0, CONTEXT_TOP_N),
);
if (lod.id === 'close') {
// Batched (chunked ≤200, sequential, session-cached) DEM warm for the
// rendered subset; fires are static so each coarse cell resolves once
// ever. Re-render ONLY when a floor actually landed — a failed resolve
// reports false, so this chain terminates instead of looping against a
// down proxy (the next camera-driven rebuild retries).
warmFireAnchorFloors(candidates).then((warmed) => {
if (!warmed || !layerState._enabled || !layerState._viewer) return;
const currentLod = LOD_LEVELS[layerState._currentLodIndex];
if (!currentLod || currentLod.mode !== 'detections') return;
renderCurrentLod(true);
});
}
return markers;
}
function ensureDetectionCollections() {
@@ -344,9 +450,11 @@ export function createRendering({
}
return {
renderCurrentLod,
renderBand,
aggregateFires,
renderCells,
renderHeat,
renderDetections,
applyHandoffFade,
ensureDetectionCollections,
removeDetectionCollections,
fireHorizonOccluder,
+51
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js';
export function createFirmsState({ services, config }) {
const layerState = {};
@@ -39,12 +40,39 @@ export function createFirmsState({ services, config }) {
layerState._error = null;
/** Every adapted detection in the current payload, before the filter. */
layerState._allFires = [];
/** `_allFires` sorted by FRP descending, once per payload. */
layerState._allFiresByFrp = [];
/**
* The detections that pass the intensity filter — the ONLY set rendering,
* selection, cells, the top-fires query and the analyst records ever walk.
*/
layerState._fires = [];
layerState._firesByFrp = [];
/** Shown (filtered) detection count. */
layerState._count = 0;
/** Loaded detection count before the filter ("N of M shown"). */
layerState._totalCount = 0;
/** Minimum FRP (MW) and confidence mode; see firmsIntensity.js. */
layerState._filter = { ...DEFAULT_FIRE_FILTER };
/** Panel callback: repaint the row controls (counter) after a load. */
layerState._rowControlsListener = null;
layerState._cellCount = 0;
layerState._lastUpdate = null;
@@ -103,6 +131,29 @@ export function createFirmsState({ services, config }) {
layerState._cellCacheByGrid = new Map();
/**
* Heat raster for the far bands (firmsHeatRaster.js); null = stale, rebuilt
* on the next far render. Cleared with the cell cache on every new shown set.
*/
layerState._heatRaster = null;
/** Pending debounced heat rebuild (see HEAT_REBUILD_MS). */
layerState._heatRebuildTimer = null;
/** Last applied hand-off fade (0 markers … 1 heatmap); -1 forces a reapply. */
layerState._handoffHeat = -1;
/** Imagery adapter (heatImagery.js), created on the first far render. */
layerState._heatImagery = null;
/** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */
layerState._imageryHost = null;
/** Camera idle snapshot so the throttled LOD check is ~free when parked. */
layerState._camSnapValid = false;
+3
View File
@@ -151,6 +151,9 @@ export function createViewport({
// on screen throughout it — so the horizon pass below must keep running
// while a fetch is in flight. Only the rebuild is gated on fresh data.
if (!layerState._enabled) return;
// Every frame, ahead of the throttle: a fast zoom crosses the whole
// hand-off between two band checks.
components.rendering.applyHandoffFade();
const now = performance.now();
if (now - layerState._lastLodCheck < LOD_CHECK_MS) return;
layerState._lastLodCheck = now;
+6 -1
View File
@@ -12,6 +12,11 @@ import {
} from '../../data/transitFeeds.js';
import { transitModeTier } from '../../data/transitPresetStyle.js';
/** Regions, not feeds: one region can carry several feeds (Rio has two). */
const TRANSIT_REGION_COUNT = new Set(
TRANSIT_ENABLED_FEEDS.map((feed) => feed.region),
).size;
/**
* The registry entry behind a vehicle. Live feeds answer first so a feed the
* session disabled stops supplying vocabulary; the registry is the fallback,
@@ -249,7 +254,7 @@ export function createQueries({ state, parts }) {
// English data (the analyst's empty note to the model); the voice
// card shows it through NOTE_PATTERNS (voice/resultDisplay.js).
coverage: state._altitudeGateOpen
? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available`
? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available`
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`,
};
}
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createQueries } from './queries.js';
import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
test('the empty-coverage note counts regions, not feeds', () => {
const rio = TRANSIT_ENABLED_FEEDS.filter(
(feed) => feed.region === 'Rio de Janeiro, RJ',
);
assert.equal(rio.length, 2, 'Rio is one region with two feeds');
const regions = new Set(TRANSIT_ENABLED_FEEDS.map((feed) => feed.region));
assert.ok(regions.size < TRANSIT_ENABLED_FEEDS.length);
const stats = createQueries({
state: {
_activeFeeds: new Map(),
_vehicles: new Map(),
_enabled: true,
_altitudeGateOpen: true,
},
parts: {},
}).methods.getStats();
assert.equal(stats.status, 'zoom-in');
assert.equal(
stats.coverage,
`No feed here yet · ${regions.size} regions available`,
);
});
+12
View File
@@ -409,3 +409,15 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy
assert.ok(layer.getStats().error, JSON.stringify(patch));
}
});
test('vessel stats name the provider the last snapshot reported', async () => {
const { layer } = setup({
label: 'Vessels',
async getSnapshot() {
return snapshot([observation('111')], { source: 'Datalastic' });
},
});
assert.equal(layer.getStats().source, 'Vessels');
await layer.update();
assert.equal(layer.getStats().source, 'Datalastic');
});
+2
View File
@@ -548,6 +548,8 @@ export function createQueries({
// backing off, say how long until the next attempt instead of leaving
// the user to guess whether anything is still happening.
retryInSec: aisRetryInSec(),
// Same field as flights and military, so Contacts names the provider.
source: layer.source,
};
},
};
+21 -1
View File
@@ -11,6 +11,9 @@ const INDEX_HTML = path.join(REPO_ROOT, 'index.html');
/** The glyph written as element text: `<span class="material-symbols-outlined">radar</span>`. */
const SPAN_TEXT =
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*</g;
/** A glyph chosen in a Vue template: `<span class="material-symbols-outlined">{{ a ? 'x' : 'y' }}</span>`. */
const SPAN_MUSTACHE =
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*\{\{([^}]{0,400})\}\}\s*</g;
/** A whole `textContent =` statement, across lines, so a multi-line ternary is read once. */
const TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs;
const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g;
@@ -64,7 +67,24 @@ function referencedGlyphs() {
};
for (const match of source.matchAll(SPAN_TEXT)) add(match[1]);
for (const match of source.matchAll(ICON_PROPERTY)) add(match[1]);
for (const match of source.matchAll(TEXT_ASSIGNMENT)) {
// A template glyph bound to a computed (`{{ entryIcon }}`) is whatever
// that computed returns.
for (const [, expression] of source.matchAll(SPAN_MUSTACHE)) {
const name = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(expression)?.[1];
if (!name) continue;
const body = new RegExp(
`\\b${name}\\s*=\\s*computed\\(\\(\\)\\s*=>\\s*\\{([\\s\\S]{0,600}?)\\n\\}\\);`,
).exec(source)?.[1];
for (const literal of body?.matchAll(
/\breturn\s+['"`]([a-z0-9_]{2,})['"`]/g,
) ?? [])
add(literal[1]);
}
const expressions = [
...source.matchAll(TEXT_ASSIGNMENT),
...source.matchAll(SPAN_MUSTACHE),
];
for (const match of expressions) {
const statement = match[1].replaceAll('?.', '.');
const assigned = statement.includes('?')
? statement.slice(statement.indexOf('?') + 1)
+2
View File
@@ -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 = [
+1
View File
@@ -252,6 +252,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([
* written explicitly.
*/
export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({
'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }),
traffic: Object.freeze({ roadMode: null }),
'weather-lightning': Object.freeze({ opacity: 'strong' }),
'weather-radar': Object.freeze({ opacity: 'strong' }),
+2
View File
@@ -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(),
};
+3
View File
@@ -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);
+699
View File
@@ -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);
});
+1
View File
@@ -15,6 +15,7 @@ test('data providers have both hooks; credential editing stays development-only'
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
assert.ok(plugins.some(({ name }) => name === 'gnss-integrity'));
assert.ok(plugins.some(({ name }) => name === 'ghostmaps'));
assert.ok(plugins.some(({ name }) => name === 'drone-airspace'));
for (const plugin of plugins) {
if (plugin.name === 'gev-key-setup') {
assert.equal(plugin.configurePreviewServer, undefined);
+1 -1
View File
@@ -695,7 +695,7 @@ test('public transit documentation uses courtesy attribution and the approved gr
);
assert.ok(
readme.includes(
'Twenty layers and map sources. **Eighteen have a keyless path.**',
'Twenty-one layers and map sources. **Nineteen have a keyless path.**',
),
);
assert.ok(
+60 -4
View File
@@ -4,6 +4,14 @@ import { suggestView } from '../views.js';
import { defineTool, ToolError } from '../catalog.js';
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
import {
FIRMS_CONFIDENCE_MODES,
MIN_FRP_MW_MAX,
MIN_FRP_MW_MIN,
firePassesFilter,
normalizeFireFilter,
} from '../../data/firmsIntensity.js';
import { normalizeConfidence } from '../../data/firmsAdapt.js';
function acquiredMsUtc(acqDate, acqTime) {
if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate))
@@ -87,10 +95,30 @@ export const getActiveFires = defineTool({
title: 'Active fires',
description:
'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' +
'area, most intense (fire radiative power) first.',
'area, most intense (fire radiative power) first. Optionally limited to a ' +
'minimum fire radiative power in megawatts and a confidence level — the ' +
"same filter as the Active Fires layer's intensity controls.",
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
properties: {
area: AREA_SCHEMA,
min_frp_mw: {
type: 'number',
minimum: MIN_FRP_MW_MIN,
maximum: MIN_FRP_MW_MAX,
description:
'Hide detections weaker than this fire radiative power (MW). ' +
'A typical detection is ~6 MW; 20 MW keeps the top tenth, 100 MW the top percent.',
},
confidence: {
type: 'string',
enum: [...FIRMS_CONFIDENCE_MODES],
description:
'"all" (default), "nominal" (nominal and high), or "high". Many of the ' +
'strongest fires are rated low confidence, so prefer "all" unless asked.',
},
limit: LIMIT_SCHEMA,
},
required: ['area'],
additionalProperties: false,
},
@@ -103,9 +131,31 @@ export const getActiveFires = defineTool({
'unavailable',
'Fire detections need a NASA FIRMS key configured for this server',
);
const filter = normalizeFireFilter({
minFrp: args.min_frp_mw,
confidence: args.confidence,
});
const fires = snapshot.fires
.filter((fire) => areaContains(area, fire))
.filter(
(fire) =>
areaContains(area, fire) &&
firePassesFilter(
{
frp: Number(fire.frp),
confidence: normalizeConfidence(fire.confidence),
},
filter,
),
)
.sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0));
const filterNotes = [
...(filter.minFrp > 0 ? [`≥ ${filter.minFrp} MW`] : []),
...(filter.confidence !== 'all'
? [
`${filter.confidence === 'high' ? 'high' : 'nominal or high'} confidence`,
]
: []),
];
const result = capRows(fires, args.limit);
// The proxy serves its last good snapshot when a refresh fails, and marks
// satellites whose download failed.
@@ -119,10 +169,16 @@ export const getActiveFires = defineTool({
];
return {
summary:
`${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` +
`${countNoun(fires.length, 'fire detection')}` +
(filterNotes.length ? ` (${filterNotes.join(', ')})` : '') +
` in ${area.label} in the last 24 hours` +
(notes.length ? ` (${notes.join('; ')}).` : '.'),
data: {
view: suggestView(services, { area, layers: ['local-firms'] }),
filter: {
min_frp_mw: filter.minFrp,
confidence: filter.confidence,
},
stale,
missing_sources: missing,
fetched_at: isoTime(snapshot.fetchedAt),
+36
View File
@@ -44,3 +44,39 @@ test('fire acquisition timestamps preserve valid leap days and UTC times', async
assert.equal(result.data.rows[0].acquired, expected);
}
});
const WEEK = [
{ lat: 24.1, lon: 121.1, frp: 864, confidence: 'l', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.2, lon: 121.2, frp: 120, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.3, lon: 121.3, frp: 20, confidence: 'n', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.4, lon: 121.4, frp: 2, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 30, lon: 100, frp: 500, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
];
const TAIWAN = { area: { bbox: [120, 23, 122, 25] } };
test('get_active_fires shows every detection by default and reports the filter it used', async () => {
const result = await catalogWith(WEEK).call('get_active_fires', TAIWAN);
assert.deepEqual(result.data.rows.map((row) => row.frp_mw), [864, 120, 20, 2]);
assert.deepEqual(result.data.filter, { min_frp_mw: 0, confidence: 'all' });
assert.match(result.summary, /^4 fire detections in /);
});
test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => {
const catalog = catalogWith(WEEK);
const strong = await catalog.call('get_active_fires', { ...TAIWAN, min_frp_mw: 20 });
assert.deepEqual(strong.data.rows.map((row) => row.frp_mw), [864, 120, 20]);
assert.match(strong.summary, /^3 fire detections \(≥ 20 MW\) in /);
const nominal = await catalog.call('get_active_fires', { ...TAIWAN, confidence: 'nominal' });
assert.deepEqual(nominal.data.rows.map((row) => row.frp_mw), [120, 20, 2], 'the 864 MW low-confidence fire drops');
assert.match(nominal.summary, /\(nominal or high confidence\)/);
const both = await catalog.call('get_active_fires', {
...TAIWAN,
min_frp_mw: 19.6,
confidence: 'high',
});
assert.deepEqual(both.data.rows.map((row) => row.frp_mw), [120]);
assert.deepEqual(both.data.filter, { min_frp_mw: 20, confidence: 'high' });
assert.match(both.summary, /\(≥ 20 MW, high confidence\)/);
});
+91
View File
@@ -0,0 +1,91 @@
// src/ui/cockpitBodyClasses.test.mjs — the classes the Cockpit puts on
// <body> must not also name an element's rule. A bare `.name { … }` written
// for a panel matches <body> the moment <body> carries `name`, and its
// pointer-events, position and overflow then apply to the whole page.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const UI_ROOT = fileURLToPath(new URL('.', import.meta.url));
const SRC_ROOT = path.resolve(UI_ROOT, '..');
const CONTROLLER = path.join(UI_ROOT, 'cockpitTrackingController.js');
/** Every stylesheet the app ships. */
function stylesheets(directory = SRC_ROOT) {
const files = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...stylesheets(absolute));
else if (entry.isFile() && entry.name.endsWith('.css'))
files.push(absolute);
}
return files;
}
/** A selector list split on its top-level commas, not those in `:not(a, b)`. */
function splitSelectorList(list) {
const parts = [];
let depth = 0;
let start = 0;
for (let i = 0; i < list.length; i += 1) {
if (list[i] === '(') depth += 1;
else if (list[i] === ')') depth -= 1;
else if (list[i] === ',' && depth === 0) {
parts.push(list.slice(start, i));
start = i + 1;
}
}
parts.push(list.slice(start));
return parts;
}
/** The classes the tracking controller adds to <body>. */
function cockpitBodyClasses() {
const source = readFileSync(CONTROLLER, 'utf8');
const names = [
...source.matchAll(/body\.classList\.add\(\s*'([a-z0-9-]+)'/g),
].map((match) => match[1]);
return [...new Set(names)];
}
test('the Cockpit adds its subject classes to <body>', () => {
const classes = cockpitBodyClasses();
for (const name of ['cockpit-mode']) {
assert.ok(classes.includes(name), `${name} is not added to <body>`);
}
});
test('no stylesheet rule matches a Cockpit body class on another element', () => {
const offenders = [];
for (const name of cockpitBodyClasses()) {
// `.name` as a whole class token, not as `.name-suffix`.
const token = new RegExp(`\\.${name}(?![\\w-])`);
for (const file of stylesheets()) {
const css = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
for (const [, selectors] of css.matchAll(/([^{}]+)\{/g)) {
for (const selector of splitSelectorList(selectors)) {
// Prettier wraps long `:not(\n .name\n)` arguments; unwrap them.
const compound = selector
.trim()
.replace(/\(\s+/g, '(')
.replace(/\s+\)/g, ')');
if (!token.test(compound)) continue;
// Allowed only on <body> itself: `body.name` or `body:not(.name)`.
const onBody = new RegExp(
`(^|[\\s>+~])body[^\\s>+~]*\\.${name}(?![\\w-])`,
);
if (onBody.test(compound)) continue;
offenders.push(`${path.relative(SRC_ROOT, file)}: ${compound}`);
}
}
}
}
assert.deepEqual(
offenders,
[],
'Selectors that match a Cockpit body class without `body.`: ' +
offenders.join('; '),
);
});
+26 -5
View File
@@ -19,6 +19,14 @@ import {
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
} from './cockpitPresentation.js';
/** The aircraft eye: just ahead of and above the reported position. */
const AIRCRAFT_CAMERA_PROFILE = Object.freeze({
forwardM: COCKPIT_FORWARD_OFFSET_M,
upM: COCKPIT_UP_OFFSET_M,
pitchDeg: COCKPIT_VIEW_PITCH_DEG,
minClearanceM: COCKPIT_MIN_GROUND_CLEARANCE_M,
});
export function update() {
if (this.destroyed) return false;
if (!this.active) {
@@ -75,7 +83,20 @@ export function update() {
COCKPIT_HEADING_SLEW_DPS * dtSec,
);
}
placeCamera.call(this, target, info, nowMs, dtSec, AIRCRAFT_CAMERA_PROFILE);
}
/**
* Place the camera for one update: advance the inertial anchor, clamp it and
* the eye against the rendered surface, and look along the slewed heading.
* @param {Cesium.Cartesian3} target Authoritative subject position.
* @param {object} info Subject info.
* @param {number} nowMs Frame time.
* @param {number} dtSec Seconds since the previous camera update.
* @param {{forwardM: number, upM: number, pitchDeg: number,
* minClearanceM: number}} profile Eye offsets for the subject kind.
*/
function placeCamera(target, info, nowMs, dtSec, profile) {
if (!this.cockpitAnchorValid) {
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
this.cockpitAnchorValid = true;
@@ -89,7 +110,7 @@ export function update() {
// 15/30-second interpolation and per-frame cache without exposing its
// sample-boundary corrections to the camera.
const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
const pitchRad = Cesium.Math.toRadians(profile.pitchDeg);
const speedMps = Number.isFinite(info.velocityMps)
? Math.max(0, info.velocityMps)
: 0;
@@ -248,7 +269,7 @@ export function update() {
const minimumAnchorHeightM = cockpitGroundSafeHeight(
anchorCartographic.height,
cockpitFloorM,
COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M,
profile.minClearanceM - profile.upM,
);
if (minimumAnchorHeightM !== anchorCartographic.height) {
anchorCartographic.height = minimumAnchorHeightM;
@@ -297,7 +318,7 @@ export function update() {
Cesium.Cartesian3.multiplyByScalar(
this.scratchHorizontal,
COCKPIT_FORWARD_OFFSET_M,
profile.forwardM,
this.scratchCamera,
);
Cesium.Cartesian3.add(
@@ -309,7 +330,7 @@ export function update() {
Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
Cesium.Cartesian3.multiplyByScalar(
this.scratchTarget,
COCKPIT_UP_OFFSET_M,
profile.upM,
this.scratchTarget,
);
Cesium.Cartesian3.add(
@@ -338,7 +359,7 @@ export function update() {
const safeHeightM = cockpitGroundSafeHeight(
cameraCartographic.height,
cockpitFloorM,
COCKPIT_MIN_GROUND_CLEARANCE_M,
profile.minClearanceM,
);
if (safeHeightM !== cameraCartographic.height) {
cameraCartographic.height = safeHeightM;
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff. Show more