mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Merge branch 'main' into fix/globe-free-look-orbit
This commit is contained in:
commit
f53342932d
205 files changed
+17659
-465
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
'air-quality': contract(['air-quality'], ['getSnapshot']),
|
||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
|
||||
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 36);
|
||||
assert.equal(actual.layers.length, 37);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
|
||||
ui.indexOf("if (normalized === 'exit') {"),
|
||||
);
|
||||
assert.ok(branch, 'cockpit enter branch is missing');
|
||||
const gateIndex = branch.indexOf('isEntryAllowed?.()');
|
||||
const gateIndex = branch.indexOf('this._cockpitEntryRefusal()');
|
||||
const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
|
||||
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
|
||||
assert.ok(
|
||||
gateIndex >= 0,
|
||||
'entry consults the same gate the manual chip uses',
|
||||
'entry refuses on the gate it can check before tracking',
|
||||
);
|
||||
assert.ok(
|
||||
retargetIndex > gateIndex,
|
||||
@@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
|
||||
ui.indexOf("if (normalized === 'enter') {"),
|
||||
ui.indexOf("if (normalized === 'exit') {"),
|
||||
);
|
||||
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/);
|
||||
assert.match(branch, /Contacts must be active to enter Cockpit/);
|
||||
assert.match(branch, /Contacts is still starting up/);
|
||||
// Contacts is not required: only a Context change in flight or no aircraft
|
||||
// layer to ride shuts the gate before tracking, and the entry itself then
|
||||
// applies the manual chip's gate to the aircraft it rides.
|
||||
assert.match(
|
||||
branch,
|
||||
/const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/,
|
||||
);
|
||||
assert.doesNotMatch(branch, /Contacts must be active/);
|
||||
const refusal = ui.slice(
|
||||
ui.indexOf(' _cockpitEntryRefusal() {'),
|
||||
ui.indexOf(' getCockpitState() {'),
|
||||
);
|
||||
assert.match(refusal, /Context is still changing/);
|
||||
assert.match(refusal, /No aircraft layer is on/);
|
||||
assert.doesNotMatch(refusal, /[Cc]ontacts/);
|
||||
});
|
||||
|
||||
test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
@@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
);
|
||||
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
|
||||
assert.match(state, /'contacts-starting'/);
|
||||
assert.match(state, /'contacts-inactive'/);
|
||||
assert.match(state, /'subject-layer-off'/);
|
||||
assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required');
|
||||
assert.match(state, /'no-tracked-aircraft'/);
|
||||
});
|
||||
|
||||
|
||||
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
|
||||
],
|
||||
});
|
||||
|
||||
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
|
||||
const f = fixture(t);
|
||||
const crowded = {
|
||||
...snapshot(),
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
count: 6,
|
||||
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
|
||||
id: `n${n}`,
|
||||
label: `N${n}`,
|
||||
distanceM: n * 1000,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
updateCockpitSignals.call(f.owner, crowded, 0, {
|
||||
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
|
||||
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(f.owner.signalItems.length, 5);
|
||||
assert.equal(f.owner.signalItems[0].key, 'feed-status');
|
||||
assert.equal(f.owner.signalItems[0].tone, 'warning');
|
||||
assert.equal(f.owner.signalItems[0].target, null);
|
||||
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
|
||||
updateCockpitSignals.call(f.owner, crowded, 0);
|
||||
assert.equal(
|
||||
f.owner.signalItems.some((item) => item.key === 'feed-status'),
|
||||
false,
|
||||
'with the feed back, the row goes',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows render the signals the controller presents', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [
|
||||
|
||||
+23
-16
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
|
||||
return restored;
|
||||
}
|
||||
|
||||
/** The layers an aircraft Cockpit subject can come from. */
|
||||
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
|
||||
'flights',
|
||||
'military',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Cockpit entry belongs to the operational Contacts context bundle. A tracked
|
||||
* aircraft alone is not sufficient: both observed-flight feeds must still be
|
||||
* active so the cockpit's surrounding-contact picture is not presented as a
|
||||
* complete context view when one source is disabled.
|
||||
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
|
||||
* (Live Flights for a civilian, Military Flights for a military aircraft) must
|
||||
* be on. Contacts and the other feeds are not required. When a source the
|
||||
* surrounding picture uses is off, the Cockpit's Contact card and Live
|
||||
* Signals say the picture is partial instead of blocking entry, so a partial
|
||||
* picture is never presented as complete. A Context mode change in flight
|
||||
* still owns the camera, so entry waits it out.
|
||||
*
|
||||
* @param {object} input Current context and dependency visibility.
|
||||
* @param {string|null} input.contextMode Active Context mode.
|
||||
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
|
||||
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
|
||||
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled.
|
||||
* @param {object} input Current subject and Context state.
|
||||
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
|
||||
* ('flights' or 'military'), or null with none tracked.
|
||||
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
|
||||
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
|
||||
* @returns {boolean} Whether cockpit entry may be offered or activated.
|
||||
*/
|
||||
export function cockpitEntryAllowed({
|
||||
contextMode,
|
||||
subjectLayerId,
|
||||
subjectLayerEnabled,
|
||||
contextModeChanging,
|
||||
flightsEnabled,
|
||||
militaryEnabled,
|
||||
}) {
|
||||
return (
|
||||
contextMode === 'flights' &&
|
||||
!contextModeChanging &&
|
||||
Boolean(flightsEnabled) &&
|
||||
Boolean(militaryEnabled)
|
||||
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
|
||||
Boolean(subjectLayerEnabled) &&
|
||||
!contextModeChanging
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued
|
||||
}
|
||||
});
|
||||
|
||||
test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => {
|
||||
test("cockpit entry needs only the tracked aircraft's own layer", () => {
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: 'flights',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), true);
|
||||
}), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: 'military',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
}), true, 'a military aircraft needs Military Flights alone');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
subjectLayerId: 'flights',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: true,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), false, 'Cockpit remains unavailable until Contacts activation settles');
|
||||
}), false, 'Cockpit remains unavailable until a Context mode change settles');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: null,
|
||||
subjectLayerId: 'military',
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), false, 'ordinary layer selection is not an operational Contacts context');
|
||||
}), false, "the ridden aircraft's own layer is required");
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: null,
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: false,
|
||||
}), false, 'the military contact feed is required');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: false,
|
||||
militaryEnabled: true,
|
||||
}), false, 'the civilian contact feed is required');
|
||||
}), false, 'there is no aircraft to ride');
|
||||
});
|
||||
|
||||
test('context teardown guard restores coordination state after success and failure', async () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
houstonCameraName,
|
||||
houstonCameraToSource,
|
||||
loadHoustonSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_HOUSTON_MAX_SOURCES,
|
||||
HOUSTON_CAMERAS_URL,
|
||||
HOUSTON_GROUND_ELEVATION_M,
|
||||
HOUSTON_IMAGE_ORIGIN,
|
||||
HOUSTON_IMAGE_URL,
|
||||
HOUSTON_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
isLikelyHoustonCoordinate,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
const DOWNTOWN_ID = '1002';
|
||||
const GALLERIA_ID = '2001';
|
||||
|
||||
/** One TranStar camera row, shaped like the live `cctvSnapshots_json.js` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
location: '10 EAST @ SAN JACINTO',
|
||||
id: DOWNTOWN_ID,
|
||||
lat: '29.76824',
|
||||
lng: '-95.355711',
|
||||
dir: 'South',
|
||||
fc: '6',
|
||||
fp: '300',
|
||||
url: "showWin('/layers/gc.aspx?cam=1002&loc=IH-10_East_at_SAN_JACINTO')",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('a Houston row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = houstonCameraToSource(row());
|
||||
assert.equal(source.id, `houston-${DOWNTOWN_ID}`);
|
||||
assert.equal(source.name, '10 EAST @ SAN JACINTO');
|
||||
assert.equal(source.city, 'Houston');
|
||||
assert.equal(source.cityId, 'houston');
|
||||
assert.equal(source.provider, 'Houston TranStar');
|
||||
assert.equal(source.lat, 29.76824);
|
||||
assert.equal(source.lon, -95.355711);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'houston-transtar');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'TranStar disclaimer — redistribution and external framing need TxDOT written permission',
|
||||
);
|
||||
assert.equal(source.groundElevationM, HOUSTON_GROUND_ELEVATION_M);
|
||||
assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID));
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(HOUSTON_IMAGE_ORIGIN));
|
||||
// dir South = the camera sits south of I-10; the nearest lanes run east.
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.headingDeg, 90);
|
||||
});
|
||||
|
||||
test('frame URLs are synthesized from the numeric id; the payload URL is ignored', () => {
|
||||
const source = houstonCameraToSource(
|
||||
row({ url: 'https://evil.example/cameras/x/image' }),
|
||||
);
|
||||
assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('zero ids, off-city geometry and unusable ids are dropped', () => {
|
||||
assert.equal(houstonCameraToSource(row({ id: '0' })), null);
|
||||
assert.equal(houstonCameraToSource(row({ id: '00' })), null);
|
||||
assert.equal(houstonCameraToSource(row({ id: '../etc/passwd' })), null);
|
||||
assert.equal(houstonCameraToSource(row({ id: '12' })), null);
|
||||
assert.equal(houstonCameraToSource(row({ id: '1002/../x' })), null);
|
||||
// Dallas: a plausible lat/lon, but not a Houston camera.
|
||||
assert.equal(
|
||||
houstonCameraToSource(row({ lat: '32.7767', lng: '-96.797' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(houstonCameraToSource(row({ lat: '0', lng: '0' })), null);
|
||||
assert.equal(houstonCameraToSource(null), null);
|
||||
// 3-digit and 5-digit snapshot ids stay.
|
||||
assert.equal(houstonCameraToSource(row({ id: '102' })).id, 'houston-102');
|
||||
assert.equal(houstonCameraToSource(row({ id: '13711' })).id, 'houston-13711');
|
||||
});
|
||||
|
||||
test('dir is the side of the road: it gives a low-confidence travel heading', () => {
|
||||
// TranStar's map reads dir as "Traffic closest to the camera is traveling":
|
||||
// South -> Eastbound, North -> Westbound, West -> Southbound, East -> Northbound.
|
||||
const expected = { South: 90, North: 270, West: 180, East: 0, south: 90 };
|
||||
for (const [dir, heading] of Object.entries(expected)) {
|
||||
const source = houstonCameraToSource(row({ dir }));
|
||||
assert.equal(source.headingDeg, heading, `dir ${dir}`);
|
||||
assert.equal(source.headingConfidence, 'low', `dir ${dir}`);
|
||||
assert.equal(source.pitchDeg, -18, `dir ${dir}`);
|
||||
assert.equal(source.fovDeg, 44, `dir ${dir}`);
|
||||
assert.equal(source.rangeM, 145, `dir ${dir}`);
|
||||
assert.equal(source.mountHeightM, 8, `dir ${dir}`);
|
||||
}
|
||||
|
||||
const none = houstonCameraToSource(row({ dir: 'None' }));
|
||||
assert.equal(none.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(none.headingDeg));
|
||||
assert.equal(
|
||||
none.headingDeg,
|
||||
houstonCameraToSource(row({ dir: '' })).headingDeg,
|
||||
);
|
||||
for (const dir of ['Northeast', 'constructor', '__proto__', undefined]) {
|
||||
assert.equal(
|
||||
houstonCameraToSource(row({ dir })).headingDeg,
|
||||
none.headingDeg,
|
||||
`dir ${dir} falls back to the id hash`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
houstonCameraName({ location: ' 45 GULF @ WEST DALLAS ' }, 'houston-102'),
|
||||
'45 GULF @ WEST DALLAS',
|
||||
);
|
||||
assert.equal(houstonCameraName({}, 'houston-102'), 'Houston Camera 102');
|
||||
assert.equal(houstonCameraName({}, ''), 'Houston Camera');
|
||||
});
|
||||
|
||||
test('the unselected label is the interchange, trimmed to the code width', () => {
|
||||
assert.equal(houstonCameraToSource(row()).code, '10 EAST @ SAN JACINTO');
|
||||
const long = houstonCameraToSource(
|
||||
row({
|
||||
location:
|
||||
'IH-10 Katy Managed Lanes Eastbound at a very long interchange name that must truncate',
|
||||
}),
|
||||
);
|
||||
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
});
|
||||
|
||||
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.headers.Accept, init.redirect]);
|
||||
return Response.json({
|
||||
cameras: [
|
||||
row(),
|
||||
row({ location: '10 EAST @ SAN JACINTO (duplicate)' }),
|
||||
row({
|
||||
id: GALLERIA_ID,
|
||||
location: '610 WEST LOOP @ SAN FELIPE',
|
||||
lat: '29.749',
|
||||
lng: '-95.461',
|
||||
dir: 'North',
|
||||
}),
|
||||
row({ url: 'https://evil.example/x.jpg', id: '0' }),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadHoustonSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [[HOUSTON_CAMERAS_URL, '*/*', 'manual']]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
[`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`],
|
||||
'nearest-to-anchor first: Uptown sits closer to its pin than downtown does',
|
||||
);
|
||||
assert.equal(cameras[1].name, '10 EAST @ SAN JACINTO');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.js' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[HOUSTON_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const body = JSON.stringify({ cameras: [row()] });
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript',
|
||||
'content-length': String(HOUSTON_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
// Valid JSON with a usable camera, so only the byte cap can refuse it.
|
||||
const oversized = JSON.stringify({
|
||||
cameras: [row()],
|
||||
padding: 'x'.repeat(HOUSTON_MAX_CATALOG_BYTES + 1024),
|
||||
});
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/javascript' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadHoustonSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced pack cap keeps cameras nearest the metro anchors', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const saved = process.env.CCTV_HOUSTON_MAX_SOURCES;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_HOUSTON_MAX_SOURCES;
|
||||
else process.env.CCTV_HOUSTON_MAX_SOURCES = saved;
|
||||
});
|
||||
process.env.CCTV_HOUSTON_MAX_SOURCES = '8';
|
||||
const rows = [
|
||||
...[29.32, 29.322, 29.324, 29.326, 29.328, 29.33].map((lat, i) =>
|
||||
row({
|
||||
id: String(3000 + i),
|
||||
lat: String(lat),
|
||||
lng: '-94.80',
|
||||
location: `Galveston ${i}`,
|
||||
dir: 'East',
|
||||
}),
|
||||
),
|
||||
...[29.76, 29.759, 29.758, 29.757, 29.756, 29.755].map((lat, i) =>
|
||||
row({
|
||||
id: String(4000 + i),
|
||||
lat: String(lat),
|
||||
lng: '-95.3698',
|
||||
location: `Downtown ${i}`,
|
||||
dir: 'North',
|
||||
}),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ cameras: rows }),
|
||||
);
|
||||
const cameras = await loadHoustonSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.name.slice(0, 8)),
|
||||
[
|
||||
'Downtown',
|
||||
'Downtown',
|
||||
'Downtown',
|
||||
'Downtown',
|
||||
'Downtown',
|
||||
'Downtown',
|
||||
'Galvesto',
|
||||
'Galvesto',
|
||||
],
|
||||
'downtown fills first; the Galveston anchor still keeps a share',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the Houston catalog to the Houston endpoint and an empty payload to
|
||||
* every other pack, so one catalog refresh exercises the registration without
|
||||
* reaching the network.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === HOUSTON_CAMERAS_URL) {
|
||||
return Response.json({
|
||||
cameras: [
|
||||
row(),
|
||||
row({
|
||||
id: GALLERIA_ID,
|
||||
location: '610 WEST LOOP @ SAN FELIPE',
|
||||
lat: '29.749',
|
||||
lng: '-95.461',
|
||||
dir: 'West',
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
test('the Houston lane is wired into the catalog and its loader runs', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
process.env.CCTV_HOUSTON_ENABLED = '1';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.includes(HOUSTON_CAMERAS_URL),
|
||||
'the catalog refresh invokes the Houston loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'houston').map((s) => s.id),
|
||||
[`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`],
|
||||
'Houston cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
for (const value of [undefined, '0']) {
|
||||
if (value === undefined) delete process.env.CCTV_HOUSTON_ENABLED;
|
||||
else process.env.CCTV_HOUSTON_ENABLED = value;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.includes(HOUSTON_CAMERAS_URL),
|
||||
false,
|
||||
'the pack reaches TranStar only after an explicit opt-in',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'houston'),
|
||||
[],
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
test('isLikelyHoustonCoordinate spans the metro and rejects the rest', () => {
|
||||
assert.equal(isLikelyHoustonCoordinate(29.7604, -95.3698), true); // Downtown
|
||||
assert.equal(isLikelyHoustonCoordinate(29.3013, -94.7977), true); // Galveston
|
||||
assert.equal(isLikelyHoustonCoordinate(29.9902, -95.3368), true); // IAH
|
||||
assert.equal(isLikelyHoustonCoordinate(32.7767, -96.797), false); // Dallas
|
||||
assert.equal(isLikelyHoustonCoordinate(30.2672, -97.7431), false); // Austin
|
||||
assert.equal(isLikelyHoustonCoordinate(0, 0), false);
|
||||
assert.equal(isLikelyHoustonCoordinate(NaN, -95.37), false);
|
||||
});
|
||||
@@ -34,6 +34,8 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
'ontario 511': 3 * 60 * 1000,
|
||||
// TxDOT publishes roughly once a minute; 3 min matches the other highway packs.
|
||||
txdot: 3 * 60 * 1000,
|
||||
// TranStar says snapshots update about every 3 minutes.
|
||||
'houston transtar': 3 * 60 * 1000,
|
||||
// Digitraffic publishes a new weathercam frame on each station's
|
||||
// collectionInterval, 600 s for every station sampled; polling faster only
|
||||
// re-fetches the same JPEG.
|
||||
|
||||
@@ -639,6 +639,7 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Ontario 511' }), 180_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Houston TranStar' }), 180_000);
|
||||
// Digitraffic publishes one weathercam frame per 600 s collection interval.
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
|
||||
assert.equal(
|
||||
|
||||
@@ -181,6 +181,13 @@ export const DATA_CREDITS = [
|
||||
' · Incident information: ' +
|
||||
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
|
||||
},
|
||||
{
|
||||
key: 'faa-sua',
|
||||
html:
|
||||
'Prohibited & restricted airspace: ' +
|
||||
'<a href="https://adds-faa.opendata.arcgis.com/" target="_blank" rel="noopener">FAA Special Use Airspace</a>' +
|
||||
' (prohibited and restricted zones; informational only)',
|
||||
},
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
{
|
||||
@@ -311,6 +318,14 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
|
||||
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'houston-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Houston, when enabled): ' +
|
||||
'<a href="https://traffic.houstontranstar.org/cctv/transtar/" target="_blank" rel="noopener">Houston TranStar</a>. ' +
|
||||
"Copying, distributing or framing its images needs TxDOT's written permission under the " +
|
||||
'<a href="https://traffic.houstontranstar.org/disclaimer.aspx" target="_blank" rel="noopener">TranStar Content Usage terms</a>',
|
||||
},
|
||||
{
|
||||
key: 'hk-cctv',
|
||||
html:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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' }), []);
|
||||
});
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 34);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
@@ -139,6 +139,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
's2-ghostmaps': '5',
|
||||
'sea-marks': '6',
|
||||
'ski-pistes': '7',
|
||||
'drone-airspace': '8',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
@@ -164,9 +165,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '8']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '9']])
|
||||
.future,
|
||||
'8',
|
||||
'9',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -184,14 +185,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
|
||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||
withPublishedBase(
|
||||
{ rows: [...reservationRows, ['retired-layer', '8']] },
|
||||
{ rows: [...reservationRows, ['retired-layer', '9']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '9');
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '9',
|
||||
newcomer: '00',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -199,15 +200,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '8',
|
||||
competing: '9',
|
||||
}),
|
||||
/next free token 9/,
|
||||
/next free token 00/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '8',
|
||||
newcomer: '9',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
@@ -215,8 +216,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
);
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '8']] }, (cwd) => {
|
||||
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -237,8 +238,8 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '8']],
|
||||
[{ id: 'pr-b', token: '8' }],
|
||||
[...reservationRows, ['pr-b', '9']],
|
||||
[{ id: 'pr-b', token: '9' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
@@ -246,10 +247,10 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '8'], ['pr-b', '8']],
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '9']],
|
||||
[
|
||||
{ id: 'pr-a', token: '8' },
|
||||
{ id: 'pr-b', token: '8' },
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '9' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
@@ -257,16 +258,16 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '9');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '8']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '8' }]);
|
||||
assert.equal(nextLayerStateToken(published), '00');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '9']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
|
||||
const helper = spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureNext), 'pr-b'],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(helper.status, 0, helper.stderr);
|
||||
assert.match(helper.stdout, /pr-b: 9/);
|
||||
assert.match(helper.stdout, /pr-b: 00/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
@@ -274,20 +275,20 @@ test('PR B manually replaces provisional 8 with 9 after PR A publishes 8', () =>
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '8'], ['pr-b', '9']],
|
||||
[...reservationRows, ['pr-a', '9'], ['pr-b', '00']],
|
||||
[
|
||||
{ id: 'pr-a', token: '8' },
|
||||
{ id: 'pr-b', token: '9' },
|
||||
{ id: 'pr-a', token: '9' },
|
||||
{ id: 'pr-b', token: '00' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /35 published, 1 new/);
|
||||
assert.match(corrected.stdout, /36 published, 1 new/);
|
||||
});
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'89'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...digitRows,
|
||||
@@ -339,7 +340,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
|
||||
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
|
||||
try {
|
||||
const priorDigits = [...'89'].map((digit) => [
|
||||
const priorDigits = [...'9'].map((digit) => [
|
||||
`qa-prior-digit-${digit}`,
|
||||
digit,
|
||||
]);
|
||||
@@ -376,7 +377,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
const params = new URLSearchParams([['v', '2']]);
|
||||
codec.encodeLayerStateParams(params, state);
|
||||
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
|
||||
assert.equal(params.get('l').length, 79);
|
||||
assert.equal(params.get('l').length, 81);
|
||||
const restored = codec.decodeLayerStateParams(params);
|
||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||
assert.deepEqual(restored?.options, state.options);
|
||||
@@ -394,7 +395,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 35);
|
||||
assert.equal(Object.keys(published).length, 36);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
["bikeshare", "b"],
|
||||
["cctv", "c"],
|
||||
["directions", "n"],
|
||||
["drone-airspace", "8"],
|
||||
["earthquakes", "e"],
|
||||
["fire-perimeters", "2"],
|
||||
["flights", "f"],
|
||||
|
||||
@@ -90,6 +90,8 @@ async function createRealLocalLayerHarness({
|
||||
globalThis.window = { dispatchEvent() {} };
|
||||
let sampleHeightImpl = sampleHeight;
|
||||
const sampleCalls = { count: 0 };
|
||||
// The camera frustum, reduced to the one answer ground sampling asks of it.
|
||||
let baseVisibility = Cesium.Intersect.INSIDE;
|
||||
const overlayHost = {
|
||||
setVisible: (...args) => hostCalls.push(['visible', ...args]),
|
||||
setEntries: (...args) => hostCalls.push(['entries', ...args]),
|
||||
@@ -107,7 +109,19 @@ async function createRealLocalLayerHarness({
|
||||
},
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000),
|
||||
frustum: { fov: Math.PI / 3 },
|
||||
directionWC: new Cesium.Cartesian3(),
|
||||
upWC: new Cesium.Cartesian3(),
|
||||
frustum: {
|
||||
fov: Math.PI / 3,
|
||||
// No planes: everything is in view. One plane no point satisfies:
|
||||
// nothing is.
|
||||
computeCullingVolume: () => ({
|
||||
planes:
|
||||
baseVisibility === Cesium.Intersect.OUTSIDE
|
||||
? [new Cesium.Cartesian4(0, 0, 0, -1)]
|
||||
: [],
|
||||
}),
|
||||
},
|
||||
moveEnd,
|
||||
flyTo() {},
|
||||
},
|
||||
@@ -151,6 +165,10 @@ async function createRealLocalLayerHarness({
|
||||
sampleCalls,
|
||||
/** Swap scene.sampleHeight mid-test (e.g. tiles finally arrive). */
|
||||
setSampleHeight(next) { sampleHeightImpl = next; },
|
||||
/** Put the feature's base inside (true) or outside the camera frustum. */
|
||||
setBaseInView(inView) {
|
||||
baseVisibility = inView ? Cesium.Intersect.INSIDE : Cesium.Intersect.OUTSIDE;
|
||||
},
|
||||
cleanup() {
|
||||
if (originalWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = originalWindow;
|
||||
@@ -736,6 +754,136 @@ test('ground sampling waits for visible globe tiles before caching a height', as
|
||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
|
||||
});
|
||||
|
||||
test('an unseen base is not sampled and arms no retry until it is in view', async (t) => {
|
||||
// Out of view the scene has no surface under the base, so the depth sample
|
||||
// would pass through the Earth (about -6,344 km) and fail on every retry.
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||
});
|
||||
_resetRenderGovernorForTest();
|
||||
const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
|
||||
installRenderGovernor({ scene: governorScene });
|
||||
governorScene.renders = 0; // discard the governor's own install settling frame
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const clock = installFakeClock(t);
|
||||
t.after(() => {
|
||||
env.layer.destroy(env.viewer);
|
||||
env.cleanup();
|
||||
_resetRenderGovernorForTest();
|
||||
});
|
||||
env.viewer.scene.globe = { show: true, tilesLoaded: true, getHeight: () => 117 };
|
||||
setCameraAltitude(env, 20_000);
|
||||
env.setBaseInView(false);
|
||||
runArmedRetryChain(env, t, clock, 5);
|
||||
assert.equal(env.sampleCalls.count, 0, 'an off-screen base must not be sampled');
|
||||
assert.equal(governorScene.renders, 0, 'an off-screen base must not keep a parked camera rendering');
|
||||
assert.ok(Math.abs(baseHeightM(env)) < 0.01);
|
||||
|
||||
// In the frustum but over the horizon: 50 m up, about 67 km away.
|
||||
env.setBaseInView(true);
|
||||
env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.0, 30.205, 50);
|
||||
runArmedRetryChain(env, t, clock, 5);
|
||||
assert.equal(env.sampleCalls.count, 0, 'a base over the horizon must not be sampled');
|
||||
assert.equal(governorScene.renders, 0);
|
||||
|
||||
setCameraAltitude(env, 20_000);
|
||||
env.preRender.raise();
|
||||
assert.equal(env.sampleCalls.count, 1, 'the base is sampled on the first walk that sees it');
|
||||
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
|
||||
});
|
||||
|
||||
/**
|
||||
* Give the harness a real Cesium camera `southM` metres south of the base at
|
||||
* `cameraM` metres above the ellipsoid, looking at the base's ground at
|
||||
* `groundM` (or directly away from it).
|
||||
*/
|
||||
function aimRealCamera(env, { groundM, cameraM, southM, away = false, fov = Math.PI / 3 }) {
|
||||
const entity = env.dataSources[0].entities.values[0];
|
||||
const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian);
|
||||
const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, groundM);
|
||||
const position = Cesium.Cartesian3.fromRadians(
|
||||
base.longitude,
|
||||
base.latitude - southM / 6_371_000,
|
||||
cameraM,
|
||||
);
|
||||
const direction = Cesium.Cartesian3.normalize(
|
||||
away
|
||||
? Cesium.Cartesian3.subtract(position, ground, new Cesium.Cartesian3())
|
||||
: Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const right = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.cross(
|
||||
direction,
|
||||
Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3());
|
||||
const frustum = new Cesium.PerspectiveFrustum({
|
||||
fov,
|
||||
aspectRatio: 800 / 600,
|
||||
near: 1,
|
||||
far: 1e8,
|
||||
});
|
||||
Object.assign(env.viewer.camera, {
|
||||
positionWC: position,
|
||||
directionWC: direction,
|
||||
upWC: up,
|
||||
frustum,
|
||||
});
|
||||
return frustum
|
||||
.computeCullingVolume(position, direction, up)
|
||||
.computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1));
|
||||
}
|
||||
|
||||
const loadedTerrain = (height) => ({ show: true, tilesLoaded: true, getHeight: () => height });
|
||||
const hiddenGlobe = { show: false, tilesLoaded: true, getHeight: () => undefined };
|
||||
|
||||
for (const { label, globe, groundM, cameraM, southM, fov, baseOffScreen } of [
|
||||
// The Dead Sea (about -430 m) under a hidden globe, seen steeply down
|
||||
// through a narrow view: both 0 m and 9,000 m above it are off screen.
|
||||
{ label: 'hidden-globe depression', globe: hiddenGlobe, groundM: -430, cameraM: 20, southM: 180, fov: Math.PI / 12, baseOffScreen: true },
|
||||
// 2,500 m terrain from 3 km away at a shallow angle: the ellipsoid-height
|
||||
// base is far below the bottom of the screen.
|
||||
{ label: 'high ground with loaded terrain', globe: loadedTerrain(2_500), groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true },
|
||||
{ label: 'high ground under a hidden globe', globe: hiddenGlobe, groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true },
|
||||
// A low-geoid coast (about -63 m near Mumbai) from a camera 50 m above the
|
||||
// ellipsoid: the ellipsoidal horizon would hide the ground itself.
|
||||
{ label: 'ground below the ellipsoid', globe: loadedTerrain(-63), groundM: -63, cameraM: 50, southM: 2_000, baseOffScreen: false },
|
||||
]) {
|
||||
test(`${label} in view is sampled`, async (t) => {
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true, sampleHeight: () => groundM,
|
||||
});
|
||||
const clock = installFakeClock(t);
|
||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||
env.viewer.scene.globe = globe;
|
||||
const baseVisibility = aimRealCamera(env, { groundM, cameraM, southM, fov });
|
||||
if (baseOffScreen)
|
||||
assert.equal(baseVisibility, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen');
|
||||
|
||||
clock.advance(2_100);
|
||||
env.preRender.raise();
|
||||
assert.equal(env.sampleCalls.count, 1, 'the ground in view is sampled');
|
||||
assert.ok(Math.abs(baseHeightM(env) - groundM) < 0.01);
|
||||
});
|
||||
}
|
||||
|
||||
test('under a hidden globe, ground behind the camera is not sampled', async (t) => {
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true, sampleHeight: () => 2_500,
|
||||
});
|
||||
const clock = installFakeClock(t);
|
||||
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
|
||||
env.viewer.scene.globe = hiddenGlobe;
|
||||
aimRealCamera(env, { groundM: 2_500, cameraM: 3_500, southM: 3_000, away: true });
|
||||
clock.advance(2_100);
|
||||
env.preRender.raise();
|
||||
assert.equal(env.sampleCalls.count, 0);
|
||||
});
|
||||
|
||||
test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => {
|
||||
const env = await createRealLocalLayerHarness({
|
||||
sampleHeightSupported: true, sampleHeight: () => 117,
|
||||
|
||||
@@ -1000,9 +1000,9 @@ export function createLocalGeoJsonLayer(
|
||||
// governor the retry's preRender never arrives on a parked camera
|
||||
// and the stem stays at ellipsoid height (buried/floating) until
|
||||
// the user happens to move. Schedule the frame the retry needs.
|
||||
// Gated on a sampleable scene and in-range records only, so a far
|
||||
// camera (or a keyless scene) stays fully idle; the distance is
|
||||
// only computed for still-unsampled stems.
|
||||
// Gated on a sampleable scene and in-range, in-view records only,
|
||||
// so a far camera (or a keyless scene) stays fully idle; the
|
||||
// distance is only computed for still-unsampled stems.
|
||||
if (
|
||||
canSampleGround &&
|
||||
!record.groundSampled &&
|
||||
@@ -1010,7 +1010,8 @@ export function createLocalGeoJsonLayer(
|
||||
Cesium.Cartesian3.distance(
|
||||
viewer.camera.positionWC,
|
||||
record.base,
|
||||
) < GROUND_SAMPLE_MAX_DISTANCE_M
|
||||
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
|
||||
groundSampleInView(viewer, record)
|
||||
) {
|
||||
groundRetryPending = true;
|
||||
}
|
||||
@@ -1130,9 +1131,87 @@ function refreshLocalTerrainFloor(viewer, record) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const groundSampleOccluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
);
|
||||
|
||||
const groundSampleCarto = new Cesium.Cartographic();
|
||||
const groundSampleLow = new Cesium.Cartesian3();
|
||||
const groundSampleHigh = new Cesium.Cartesian3();
|
||||
|
||||
/** The record's vertical at `height` metres above the ellipsoid. */
|
||||
function groundSamplePoint(record, height, result) {
|
||||
groundSampleCarto.longitude = record.carto.longitude;
|
||||
groundSampleCarto.latitude = record.carto.latitude;
|
||||
groundSampleCarto.height = height;
|
||||
return Cesium.Cartographic.toCartesian(
|
||||
groundSampleCarto,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the camera can see the ground under the record: inside the view
|
||||
* frustum and above the horizon. scene.sampleHeight reads only surface
|
||||
* rendered for the current view, so a sample of unseen ground passes through
|
||||
* the Earth (about -6,344 km near Ashburn on Esri) and fails on every 2 s
|
||||
* retry, each one a full offscreen render; 110 unseen data centres there held
|
||||
* rest frames at 58-589 ms. Unsampled bases sit on the ellipsoid, so the test
|
||||
* uses loaded globe terrain when there is some. Otherwise (a hidden globe) it
|
||||
* tests the vertical across +/-GROUND_SAMPLE_MAX_ABS_HEIGHT_M, every height a
|
||||
* sample can accept, so high or below-ellipsoid ground in view is never
|
||||
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
|
||||
* sees it.
|
||||
*/
|
||||
function groundSampleInView(viewer, record) {
|
||||
const camera = viewer.camera;
|
||||
const globe = viewer.scene.globe;
|
||||
const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined;
|
||||
const known =
|
||||
Number.isFinite(terrain) &&
|
||||
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
||||
const low = groundSamplePoint(
|
||||
record,
|
||||
known ? terrain : -GROUND_SAMPLE_MAX_ABS_HEIGHT_M,
|
||||
groundSampleLow,
|
||||
);
|
||||
const high = known
|
||||
? low
|
||||
: groundSamplePoint(
|
||||
record,
|
||||
GROUND_SAMPLE_MAX_ABS_HEIGHT_M,
|
||||
groundSampleHigh,
|
||||
);
|
||||
// The vertical is unseen only when both ends are outside the same plane.
|
||||
const { planes } = camera.frustum.computeCullingVolume(
|
||||
camera.positionWC,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
for (const plane of planes) {
|
||||
if (
|
||||
Cesium.Cartesian3.dot(plane, low) + plane.w < 0 &&
|
||||
Cesium.Cartesian3.dot(plane, high) + plane.w < 0
|
||||
)
|
||||
return false;
|
||||
}
|
||||
// The ellipsoidal occluder hides every point below the ellipsoid from a
|
||||
// camera near it, so ground below 0 m (low-geoid coasts, the Dead Sea) is
|
||||
// tested at 0 m; unknown ground at the top of the vertical.
|
||||
const horizonHeight = known
|
||||
? Math.max(terrain, 0)
|
||||
: GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
|
||||
groundSampleOccluder.cameraPosition = camera.positionWC;
|
||||
return groundSampleOccluder.isPointVisible(
|
||||
groundSamplePoint(record, horizonHeight, groundSampleHigh),
|
||||
);
|
||||
}
|
||||
|
||||
function sampleLocalGroundHeight(viewer, record, now) {
|
||||
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
|
||||
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
|
||||
if (!groundSampleInView(viewer, record)) return false;
|
||||
record.lastGroundSampleMs = now;
|
||||
const globe = viewer.scene.globe;
|
||||
if (globe?.show && globe.tilesLoaded === false) return false;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* Rio de Janeiro route ids to the line a rider would say.
|
||||
*
|
||||
* SMTR's realtime feeds send internal keys (`O0731AAN0A` is SN731,
|
||||
* `20000501110` is BRT line 50). They look decodable but are not:
|
||||
* `O0014AAA0A` is LECD135, not 14. The table is copied from `routes.txt` in
|
||||
* SMTR's static GTFS (https://dados.mobilidade.rio/gtfs/schedule, CC BY 4.0),
|
||||
* valid to 2026-12-31. An id missing here renders as its raw key.
|
||||
*/
|
||||
export const RIO_ROUTE_NAMES = Object.freeze({
|
||||
20000101130: '10',
|
||||
20000111110: '11',
|
||||
20000121130: '12',
|
||||
20000131130: '13',
|
||||
20000141130: '14',
|
||||
20000151130: '15',
|
||||
20000161130: '16',
|
||||
20000171110: '17',
|
||||
20000181130: '18',
|
||||
20000191130: '19',
|
||||
20000201130: '20',
|
||||
20000221110: '22',
|
||||
20000251130: '25',
|
||||
20000281130: '28',
|
||||
20000311140: '31',
|
||||
20000351110: '35',
|
||||
20000401130: '40',
|
||||
20000411130: '41',
|
||||
20000421110: '42',
|
||||
20000431130: '43',
|
||||
20000461130: '46',
|
||||
20000501110: '50',
|
||||
20000511110: '51',
|
||||
20000521110: '52',
|
||||
20000531130: '53',
|
||||
20000601110: '60',
|
||||
20000611130: '61',
|
||||
20000671130: '67',
|
||||
20000681130: '68',
|
||||
20000701110: '70',
|
||||
20000731130: '73',
|
||||
20000801110: '80',
|
||||
20000901110: '90',
|
||||
'20000EXEC1110': 'ESP01',
|
||||
E2110AAA0A: '2110',
|
||||
E2111AAA0A: '2111',
|
||||
E2112AAA0A: '2112',
|
||||
E2114AAA0A: '2114',
|
||||
E2303AAA0A: '2303',
|
||||
E2305AAA0A: '2305',
|
||||
E2307AAA0A: '2307',
|
||||
E2308AAA0A: '2308',
|
||||
E2309AAA0A: '2309',
|
||||
E2310AAA0A: '2310',
|
||||
E2334AAA0A: '2334',
|
||||
E2335AAA0A: '2335',
|
||||
E2336AAA0A: '2336',
|
||||
E2337AAA0A: '2337',
|
||||
E2338AAA0A: '2338',
|
||||
E2339AAA0A: '2339',
|
||||
E2342AAA0A: '2342',
|
||||
E2343AAA0A: '2343',
|
||||
E2344AAA0A: '2344',
|
||||
E2345AAA0A: '2345',
|
||||
E2381AAA0A: '2381',
|
||||
E2383AAA0A: '2383',
|
||||
E2613AAA0A: '2613',
|
||||
E2614AAA0A: '2614',
|
||||
E2802AAA0A: '2802',
|
||||
E2803AAA0A: '2803',
|
||||
E2804AAA0A: '2804',
|
||||
O0006AAA0A: '006',
|
||||
O0007AAA0A: '007',
|
||||
O0007AAN0A: 'SN007',
|
||||
O0010AAA0A: '010',
|
||||
O0014AAA0A: 'LECD135',
|
||||
O0100AAA0A: '100',
|
||||
O0104AAA0A: '104',
|
||||
O0104AAN0A: 'SN104',
|
||||
O0107AAA0A: '107',
|
||||
O0109AAA0A: 'LECD131',
|
||||
O0111AAA0A: 'LECD134',
|
||||
O0117AAA0A: '117',
|
||||
O0133AAA0A: '133',
|
||||
O0133AAN0A: 'SN133',
|
||||
O0157AAA0A: 'LECD127',
|
||||
O0160AAA0A: '160',
|
||||
O0161AAA0A: '161',
|
||||
O0162AAA0A: '162',
|
||||
O0164AAA0A: 'LECD136',
|
||||
O0165AAA0A: '165',
|
||||
O0167AAA0A: '167',
|
||||
O0169AAA0A: '169',
|
||||
O0201AAA0A: '201',
|
||||
O0202AAA0A: '202',
|
||||
O0209AAA0A: '209',
|
||||
O0209AAN0A: 'SN209',
|
||||
O0210AAA0A: '210',
|
||||
O0217AAA0A: '217',
|
||||
O0220AAA0A: '220',
|
||||
O0229AAA0A: '229',
|
||||
O0232AAA0A: '232',
|
||||
O0232AAN0A: 'SN232',
|
||||
O0238AAA0A: '238',
|
||||
O0238AAN0A: 'SN238',
|
||||
O0239AAA0A: '239',
|
||||
O0247AAA0A: '247',
|
||||
O0249AAA0A: '249',
|
||||
O0254AAA0A: '254',
|
||||
O0265AAA0A: '265',
|
||||
O0265AAN0A: 'SN265',
|
||||
O0265AAP0A: 'SP265',
|
||||
O0275AAA0A: '275',
|
||||
O0277AAA0A: '277',
|
||||
O0292AAA0A: '292',
|
||||
O0292AAN0A: 'SN292',
|
||||
O0296AAA0A: '296',
|
||||
O0298AAA0A: '298',
|
||||
O0298AAN0A: 'SN298',
|
||||
O0300AAA0A: '300',
|
||||
O0300AAR0A: 'SR300',
|
||||
O0301AAA0A: '301',
|
||||
O0302AAA0A: '302',
|
||||
O0302AAN0A: 'SN302',
|
||||
O0306AAA0A: '306',
|
||||
O0311AAA0A: '311',
|
||||
O0312AAA0A: '312',
|
||||
O0312AAN0A: 'SN312',
|
||||
O0313AAA0A: '313',
|
||||
O0315AAA0A: '315',
|
||||
O0319AAA0A: '319',
|
||||
O0319AAN0A: 'SN319',
|
||||
O0319AAV0A: 'SV319',
|
||||
O0321AAA0A: '321',
|
||||
O0323AAA0A: '323',
|
||||
O0324AAA0A: '324',
|
||||
O0324AAN0A: 'SN324',
|
||||
O0324AAP0A: 'SP324',
|
||||
O0325AAA0A: '325',
|
||||
O0326AAA0A: '326',
|
||||
O0326AAPBA: 'SPB326',
|
||||
O0327AAA0A: '327',
|
||||
O0328AAA0A: '328',
|
||||
O0328AAN0A: 'SN328',
|
||||
O0328AAP0A: 'SP328',
|
||||
O0328AAR0A: 'SR328',
|
||||
O0329AAA0A: '329',
|
||||
O0329AAP0A: 'SP329',
|
||||
O0335AAA0A: '335',
|
||||
O0338AAA0A: '338',
|
||||
O0341AAA0A: '341',
|
||||
O0342AAA0A: '342',
|
||||
O0342AAR0A: 'SR342',
|
||||
O0343AAA0A: '343',
|
||||
O0343AAP0A: 'SP343',
|
||||
O0348AAA0A: '348',
|
||||
O0349AAA0A: '349',
|
||||
O0350AAA0A: '350',
|
||||
O0352AAA0A: '352',
|
||||
O0353AAA0A: '353',
|
||||
O0353AAN0A: 'SN353',
|
||||
O0355AAA0A: '355',
|
||||
O0355AAN0A: 'SN355',
|
||||
O0355AAR0A: 'SR355',
|
||||
O0361AAA0A: '361',
|
||||
O0362AAA0A: '362',
|
||||
O0362AAR0A: 'SR362',
|
||||
O0363AAA0A: '363',
|
||||
O0363AAN0A: 'SN363',
|
||||
O0363AAV0A: 'SV363',
|
||||
O0368AAA0A: '368',
|
||||
O0368AAN0A: 'SN368',
|
||||
O0369AAA0A: '369',
|
||||
O0371AAA0A: '371',
|
||||
O0378AAA0A: '378',
|
||||
O0378AAR0A: 'SR378',
|
||||
O0380AAA0A: '380',
|
||||
O0383AAA0A: '383',
|
||||
O0383AAN0A: 'SN383',
|
||||
O0384AAA0A: '384',
|
||||
O0384AAR0A: 'SR384',
|
||||
O0385AAA0A: '385',
|
||||
O0386AAA0A: '386',
|
||||
O0386AAR0A: 'SR386',
|
||||
O0388AAA0A: '388',
|
||||
O0388AAN0A: 'SN388',
|
||||
O0388AAR0A: 'SR388',
|
||||
O0389AAP0A: 'SP389',
|
||||
O0390AAA0A: '390',
|
||||
O0391AAV0A: 'SV391',
|
||||
O0393AAA0A: '393',
|
||||
O0393AAN0A: 'SN393',
|
||||
O0393AAR0A: 'SR393',
|
||||
O0397AAA0A: '397',
|
||||
O0397AAN0A: 'SN397',
|
||||
O0397AAR0A: 'SR397',
|
||||
O0399AAA0A: '399',
|
||||
O0399AAN0A: 'SN399',
|
||||
O0399AAR0A: 'SR399',
|
||||
O0404AAP0A: 'SP404',
|
||||
O0409AAA0A: '409',
|
||||
O0410AAA0A: '410',
|
||||
O0410AAN0A: 'LECD139',
|
||||
O0415AAA0A: '415',
|
||||
O0415AAN0A: 'SN415',
|
||||
O0416AAA0A: '416',
|
||||
O0422AAA0A: '422',
|
||||
O0422AAN0A: 'SN422',
|
||||
O0426AAA0A: '426',
|
||||
O0433AAA0A: 'LECD150',
|
||||
O0435AAA0A: 'LECD151',
|
||||
O0448AAA0A: '448',
|
||||
O0455AAA0A: '455',
|
||||
O0456AAA0A: 'LECD132',
|
||||
O0457AAA0A: '457',
|
||||
O0457AAN0A: 'SN457',
|
||||
O0461AAA0A: '461',
|
||||
O0472AAA0A: '472',
|
||||
O0473AAA0A: '473',
|
||||
O0474AAA0A: '474',
|
||||
O0474AAN0A: 'SN474',
|
||||
O0474AAV0A: 'SV474',
|
||||
O0475AAA0A: 'LECD133',
|
||||
O0476AAA0A: '476',
|
||||
O0483AAA0A: '483',
|
||||
O0483AAN0A: 'SN483',
|
||||
O0483AAPAA: 'SPA483',
|
||||
O0483AAPBA: 'SPB483',
|
||||
O0483AAV0A: 'SV483',
|
||||
O0484AAA0A: '484',
|
||||
O0485AAA0A: '485',
|
||||
O0485AAP0A: 'SP485',
|
||||
O0486AAA0A: '486',
|
||||
O0492AAA0A: '492',
|
||||
O0497AAA0A: '497',
|
||||
O0497AAN0A: 'SN497',
|
||||
O0498AAA0A: '498',
|
||||
O0507AAA0A: '507',
|
||||
O0513AAA0A: '513',
|
||||
O0518AAA0A: '518',
|
||||
O0519AAA0A: '519',
|
||||
O0536AAA0A: 'LECD138',
|
||||
O0539AAA0A: '539',
|
||||
O0550AAA0A: '550',
|
||||
O0550AAN0A: 'SN550',
|
||||
O0550AAPAA: 'SPA550',
|
||||
O0550AAPBA: 'SPB550',
|
||||
O0552AAA0A: '552',
|
||||
O0552AAN0A: 'LECD149',
|
||||
O0553AAA0A: '553',
|
||||
O0554AAA0A: '554',
|
||||
O0554AAN0A: 'SN554',
|
||||
O0555AAA0A: '555',
|
||||
O0557AAA0A: '557',
|
||||
O0565AAA0A: '565',
|
||||
O0565AAN0A: 'SN565',
|
||||
O0583AAA0A: '583',
|
||||
O0584AAA0A: '584',
|
||||
O0585AAA0A: '585',
|
||||
O0600AAA0A: '600',
|
||||
O0600AAN0A: 'SN600',
|
||||
O0601AAA0A: '601',
|
||||
O0603AAA0A: '603',
|
||||
O0606AAA0A: '606',
|
||||
O0606AAV0A: 'SV606',
|
||||
O0607AAA0A: '607',
|
||||
O0608AAA0A: '608',
|
||||
O0610AAA0A: '610',
|
||||
O0611AAA0A: '611',
|
||||
O0613AAA0A: '613',
|
||||
O0613AAPAA: 'SPA613',
|
||||
O0614AAA0A: '614',
|
||||
O0616AAA0A: '616',
|
||||
O0621AAA0A: '621',
|
||||
O0622AAA0A: '622',
|
||||
O0623AAA0A: '623',
|
||||
O0624AAA0A: '624',
|
||||
O0624AAN0A: 'SN624',
|
||||
O0624AAV0A: 'SV624',
|
||||
O0625AAA0A: '625',
|
||||
O0626AAA0A: '626',
|
||||
O0627AAA0A: '627',
|
||||
O0628AAA0A: '628',
|
||||
O0629AAA0A: '629',
|
||||
O0629AAN0A: 'SN629',
|
||||
O0630AAA0A: '630',
|
||||
O0634AAA0A: '634',
|
||||
O0635AAA0A: '635',
|
||||
O0635AAP0A: 'SP635',
|
||||
O0636AAA0A: '636',
|
||||
O0636AAN0A: 'SN636',
|
||||
O0638AAA0A: '638',
|
||||
O0639AAA0A: '639',
|
||||
O0639AAN0A: 'SN639',
|
||||
O0645AAA0A: '645',
|
||||
O0649AAA0A: 'LECD126',
|
||||
O0650AAA0A: '650',
|
||||
O0651AAA0A: '651',
|
||||
O0652AAA0A: '652',
|
||||
O0653AAA0A: '653',
|
||||
O0663AAA0A: '663',
|
||||
O0665AAA0A: '665',
|
||||
O0665AAVAA: 'LECD147',
|
||||
O0665AAVBA: 'SVB665',
|
||||
O0669AAA0A: '669',
|
||||
O0669AAV0A: 'SV669',
|
||||
O0678AAA0A: '678',
|
||||
O0679AAA0A: '679',
|
||||
O0685AAA0A: 'LECD140',
|
||||
O0685AAVAA: 'LECD148',
|
||||
O0685AAVBA: 'LECD141',
|
||||
O0687AAA0A: '687',
|
||||
O0688AAA0A: '688',
|
||||
O0691AAA0A: '691',
|
||||
O0692AAA0A: '692',
|
||||
O0692AAV0A: 'SV692',
|
||||
O0693AAA0A: '693',
|
||||
O0696AAA0A: '696',
|
||||
O0696AAN0A: 'SN696',
|
||||
O0711AAA0A: '711',
|
||||
O0712AAA0A: '712',
|
||||
O0721AAA0A: '721',
|
||||
O0731AAA0A: '731',
|
||||
O0731AAN0A: 'SN731',
|
||||
O0737AAA0A: '737',
|
||||
O0741AAA0A: '741',
|
||||
O0743AAA0A: '743',
|
||||
O0745AAA0A: '745',
|
||||
O0746AAA0A: '746',
|
||||
O0753AAA0A: '753',
|
||||
O0754AAA0A: '754',
|
||||
O0756AAA0A: '756',
|
||||
O0757AAA0A: '757',
|
||||
O0759AAA0A: '759',
|
||||
O0759AAP0A: 'SP759',
|
||||
O0764AAA0A: '764',
|
||||
O0765AAA0A: '765',
|
||||
O0766AAA0A: '766',
|
||||
O0766AAP0A: 'SP766',
|
||||
O0770AAA0A: '770',
|
||||
O0771AAA0A: '771',
|
||||
O0774AAA0A: '774',
|
||||
O0774AAV0A: 'LECD142',
|
||||
O0775AAA0A: '775',
|
||||
O0777AAV0A: 'SV777',
|
||||
O0778AAA0A: '778',
|
||||
O0779AAA0A: '779',
|
||||
O0779AAN0A: 'SN779',
|
||||
O0780AAA0A: '780',
|
||||
O0783AAA0A: '783',
|
||||
O0785AAA0A: '785',
|
||||
O0786AAA0A: '786',
|
||||
O0788AAA0A: '788',
|
||||
O0790AAA0A: '790',
|
||||
O0790AAN0A: 'SN790',
|
||||
O0790AAV0A: 'SV790',
|
||||
O0791AAA0A: '791',
|
||||
O0793AAA0A: '793',
|
||||
O0794AAA0A: '794',
|
||||
O0794AAN0A: 'SN794',
|
||||
O0795AAP0A: 'SP795',
|
||||
O0796AAA0A: '796',
|
||||
O0798AAA0A: '798',
|
||||
O0799AAA0A: '799',
|
||||
O0801AAA0A: '801',
|
||||
O0801AAP0A: 'SP801',
|
||||
O0802AAA0A: '802',
|
||||
O0803AAA0A: '803',
|
||||
O0803AAN0A: 'SN803',
|
||||
O0803AAP0A: 'SP803',
|
||||
O0804AAA0A: '804',
|
||||
O0805AAA0A: '805',
|
||||
O0805AAN0A: 'SN805',
|
||||
O0805AAP0A: 'SP805',
|
||||
O0807AAA0A: '807',
|
||||
O0808AAA0A: '808',
|
||||
O0809AAA0A: '809',
|
||||
O0810AAA0A: '810',
|
||||
O0810AAN0A: 'SN810',
|
||||
O0810AAP0A: 'SP810',
|
||||
O0812AAA0A: '812',
|
||||
O0817AAA0A: '817',
|
||||
O0819AAA0A: '819',
|
||||
O0820AAA0A: '820',
|
||||
O0821AAA0A: '821',
|
||||
O0821AAN0A: 'SN821',
|
||||
O0822AAA0A: '822',
|
||||
O0825AAA0A: '825',
|
||||
O0825AAN0A: 'SN825',
|
||||
O0826AAA0A: '826',
|
||||
O0829AAA0A: '829',
|
||||
O0830AAA0A: '830',
|
||||
O0831AAA0A: '831',
|
||||
O0831AAV0A: 'SV831',
|
||||
O0833AAA0A: '833',
|
||||
O0834AAA0A: '834',
|
||||
O0835AAA0A: '835',
|
||||
O0836AAA0A: '836',
|
||||
O0837AAA0A: '837',
|
||||
O0838AAA0A: '838',
|
||||
O0840AAA0A: '840',
|
||||
O0840AAN0A: 'SN840',
|
||||
O0841AAA0A: '841',
|
||||
O0842AAA0A: '842',
|
||||
O0843AAA0A: '843',
|
||||
O0844AAA0A: '844',
|
||||
O0845AAA0A: '845',
|
||||
O0846AAA0A: '846',
|
||||
O0846AAN0A: 'SN846',
|
||||
O0847AAA0A: '847',
|
||||
O0848AAA0A: '848',
|
||||
O0848AAN0A: 'SN848',
|
||||
O0849AAA0A: '849',
|
||||
O0850AAA0A: '850',
|
||||
O0851AAA0A: '851',
|
||||
O0852AAA0A: '852',
|
||||
O0853AAA0A: '853',
|
||||
O0853AAN0A: 'SN853',
|
||||
O0853AAV0A: 'SV853',
|
||||
O0854AAAAA: '854',
|
||||
O0855AAA0A: '855',
|
||||
O0856AAA0A: '856',
|
||||
O0857AAA0A: '857',
|
||||
O0857AAN0A: 'SN857',
|
||||
O0859AAA0A: '859',
|
||||
O0861AAA0A: '861',
|
||||
O0861AAN0A: 'SN861',
|
||||
O0862AAA0A: '862',
|
||||
O0863AAA0A: '863',
|
||||
O0863AAV0A: 'SV863',
|
||||
O0864AAA0A: '864',
|
||||
O0864AAP0A: 'SP864',
|
||||
O0865AAA0A: '865',
|
||||
O0866AAA0A: '866',
|
||||
O0866AAV0A: 'SV866',
|
||||
O0867AAA0A: '867',
|
||||
O0867AAN0A: 'SN867',
|
||||
O0868AAA0A: '868',
|
||||
O0869AAA0A: '869',
|
||||
O0870AAA0A: '870',
|
||||
O0871AAA0A: '871',
|
||||
O0872AAA0A: '872',
|
||||
O0878AAA0A: '878',
|
||||
O0878AAN0A: 'SN878',
|
||||
O0878AAV0A: 'SV878',
|
||||
O0880AAA0A: '880',
|
||||
O0880AAN0A: 'SN880',
|
||||
O0881AAA0A: '881',
|
||||
O0883AAA0A: '883',
|
||||
O0884AAA0A: '884',
|
||||
O0884AAN0A: 'SN884',
|
||||
O0885AAA0A: '885',
|
||||
O0885AAN0A: 'SN885',
|
||||
O0892AAA0A: '892',
|
||||
O0893AAA0A: '893',
|
||||
O0893AAN0A: 'SN893',
|
||||
O0894AAA0A: '894',
|
||||
O0895AAA0A: '895',
|
||||
O0895AAN0A: 'SN895',
|
||||
O0897AAA0A: '897',
|
||||
O0897AAN0A: 'SN897',
|
||||
O0898AAA0A: '898',
|
||||
O0898AAN0A: 'SN898',
|
||||
O0899AAA0A: '899',
|
||||
O0899AAV0A: 'SV899',
|
||||
O0900AAA0A: '900',
|
||||
O0901AAA0A: '901',
|
||||
O0901AAVBA: 'SVB901',
|
||||
O0905AAA0A: '905',
|
||||
O0906AAA0A: '906',
|
||||
O0908AAA0A: '908',
|
||||
O0908AAN0A: 'SN908',
|
||||
O0908AAV0A: 'SV908',
|
||||
O0910AAA0A: '910',
|
||||
O0913AAA0A: '913',
|
||||
O0915AAA0A: '915',
|
||||
O0917AAA0A: '917',
|
||||
O0917AAV0A: 'SV917',
|
||||
O0918AAA0A: '918',
|
||||
O0918AAP0A: 'SP918',
|
||||
O0919AAA0A: '919',
|
||||
O0919AAN0A: 'SN919',
|
||||
O0920AAA0A: '920',
|
||||
O0921AAA0A: '921',
|
||||
O0921AAV0A: 'SV921',
|
||||
O0922AAA0A: '922',
|
||||
O0922AAV0A: 'SV922',
|
||||
O0925AAA0A: '925',
|
||||
O0926AAA0A: '926',
|
||||
O0928AAA0A: '928',
|
||||
O0932AAA0A: '932',
|
||||
O0940AAA0A: '940',
|
||||
O0944AAA0A: 'LECD143',
|
||||
O0946AAA0A: '946',
|
||||
O0950AAA0A: 'LECD144',
|
||||
O0951AAA0A: 'LECD145',
|
||||
O0954AAA0A: '954',
|
||||
O0954AAN0A: 'SN954',
|
||||
O0959AAA0A: 'LECD146',
|
||||
O0961AAA0A: '961',
|
||||
O0975AAA0A: '975',
|
||||
O0979AAA0A: '979',
|
||||
O0987AAA0A: '987',
|
||||
O0990AAA0A: '990',
|
||||
});
|
||||
@@ -1,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,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 服务商。"
|
||||
}
|
||||
@@ -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 服務商。"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Vendored
+1
@@ -23,6 +23,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
attachDataManager(dataManager) {
|
||||
layerState.dataManager = dataManager;
|
||||
parts.dependencies.watchAdoptions(dataManager);
|
||||
},
|
||||
|
||||
setParams(params = {}) {
|
||||
|
||||
@@ -79,6 +79,25 @@ export function createDependencies({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Give up a dependency the user adopts. A direct selection, or the Cockpit
|
||||
* riding one of its aircraft, promotes a layer Contacts turned on into the
|
||||
* user's own (`DataLayerManager.adoptLayerVisibility`). Releasing it when
|
||||
* Contacts goes off would tear down that track or Cockpit before the
|
||||
* Context restore could put the layer back.
|
||||
*/
|
||||
function relinquishAdoptedDependency(change) {
|
||||
if (change?.type === 'visibility' && change.adopted && change.enabled)
|
||||
layerState.ownedDependencies.delete(change.layerId);
|
||||
}
|
||||
|
||||
/** Follow one manager's adoptions; null stops following. */
|
||||
function watchAdoptions(dataManager) {
|
||||
layerState.unwatchAdoptions?.();
|
||||
layerState.unwatchAdoptions =
|
||||
dataManager?.subscribe?.(relinquishAdoptedDependency) || null;
|
||||
}
|
||||
|
||||
function activateOperationalContext() {
|
||||
const activationId = ++layerState.activationId;
|
||||
layerState.autoFocusRetryPending = false;
|
||||
@@ -113,6 +132,7 @@ export function createDependencies({
|
||||
refreshAfterDeferredDependency,
|
||||
enableDependencies,
|
||||
releaseOwnedDependencies,
|
||||
watchAdoptions,
|
||||
activateOperationalContext,
|
||||
};
|
||||
}
|
||||
@@ -340,6 +340,7 @@ export function createLifecycle({
|
||||
layerState.clearListener = null;
|
||||
layerState.subjectClearListener = null;
|
||||
layerState.viewer = null;
|
||||
parts.dependencies.watchAdoptions(null);
|
||||
layerState.dataManager = null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
@@ -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(':'))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -12,20 +12,28 @@
|
||||
* Cameras without an identified model are untouched — unknown never implies
|
||||
* anything.
|
||||
*
|
||||
* Spec records are vendored verbatim from the CCTV Camera Database
|
||||
* Specification fields are vendored from the CCTV Camera Database
|
||||
* (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset
|
||||
* of 28,000+ camera models transcribed from manufacturer datasheets (84% with
|
||||
* FOV, as of v2.26.0). This module vendors the records that the currently integrated public
|
||||
* of 29,000+ camera models transcribed from manufacturer datasheets (76% with
|
||||
* FOV, as of v2.29.0). This module vendors records that the integrated public
|
||||
* catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis
|
||||
* Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that
|
||||
* appear in Sarasota County FL and Sioux Falls SD open data; packs that
|
||||
* appear in Sarasota County FL and Sioux Falls SD open data, plus the Bosch MIC
|
||||
* PTZs for the City of Amsterdam camera register proposed in #251; packs that
|
||||
* publish other hardware models can grow it, or vendor the full extract.
|
||||
*
|
||||
* Scope guardrail: records describe camera PRODUCTS (optics, resolution,
|
||||
* night vision) — never endpoints, stream paths, or access/config data.
|
||||
*/
|
||||
|
||||
/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */
|
||||
/**
|
||||
* Records from the dataset's model-enrichment extract: v2.26.0, plus the Bosch
|
||||
* MIC records from v2.29.0 with titles shortened to model codes.
|
||||
* A Bosch MIC order code ends in a colour letter (B, G, W; registries write
|
||||
* `x`) on one shared datasheet. The colours are aliases of one record whose
|
||||
* `model` leaves the letter out, so the HUD never names a variant the source
|
||||
* did not.
|
||||
*/
|
||||
const RECORDS = [
|
||||
{
|
||||
id: 'axis-q6135-le',
|
||||
@@ -264,6 +272,67 @@ const RECORDS = [
|
||||
autotracking: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-mic-7130-pb4',
|
||||
brand: 'Bosch',
|
||||
model: 'MIC-7130-P_4',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-mic-7130-pb4/',
|
||||
aliases: [
|
||||
'MIC IP starlight 7000 HD',
|
||||
'MIC-7130-PB4',
|
||||
'MIC-7130-PG4',
|
||||
'MIC-7130-PW4',
|
||||
'MIC-7130-Px4',
|
||||
],
|
||||
fov_deg: '2.1-59',
|
||||
resolution: { mp: 1, w: 1280, h: 720, label: '720p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-mic-7502-z30b',
|
||||
brand: 'Bosch',
|
||||
model: 'MIC-7502-Z30',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-mic-7502-z30b/',
|
||||
aliases: [
|
||||
'MIC-7502-Z30B',
|
||||
'MIC-7502-Z30G',
|
||||
'MIC-7502-Z30W',
|
||||
'MIC-7502-Z30x',
|
||||
],
|
||||
fov_deg: '2.3-63.7',
|
||||
resolution: { mp: 2.13, w: 1920, h: 1080, label: '1080p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
night_vision: { type: 'color' },
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-mic-7522-z30b',
|
||||
brand: 'Bosch',
|
||||
model: 'MIC-7522-Z30',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-mic-7522-z30b/',
|
||||
aliases: [
|
||||
'MIC-7522-Z30B',
|
||||
'MIC-7522-Z30G',
|
||||
'MIC-7522-Z30W',
|
||||
'MIC-7522-Z30x',
|
||||
],
|
||||
fov_deg: '2.1-58.3',
|
||||
// i18n-ignore: datasheet label
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
|
||||
ptz: true,
|
||||
varifocal: true,
|
||||
night_vision: { type: 'color' },
|
||||
environment: ['outdoor'],
|
||||
ndaa_compliant: true,
|
||||
},
|
||||
{
|
||||
id: 'cohu-3920-idome',
|
||||
brand: 'Cohu',
|
||||
@@ -431,7 +500,7 @@ const NAME_INDEX = RECORDS.flatMap((record) => {
|
||||
* guesses).
|
||||
* @param {string} model - Hardware model as named by a source pack or seed.
|
||||
* @param {string} [manufacturer] - The source's manufacturer, when published.
|
||||
* @returns {Object|null} The verbatim spec record, or null when unknown.
|
||||
* @returns {Object|null} The matched spec record, or null when unknown.
|
||||
*/
|
||||
export function lookupModelSpec(model, manufacturer) {
|
||||
const query = words(model).map(stripVariantSuffix);
|
||||
|
||||
@@ -245,6 +245,95 @@ test('King County snapshot model strings still resolve', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('Amsterdam camera register model strings resolve', () => {
|
||||
// The register writes the Bosch MIC colour letter as `x`.
|
||||
assert.equal(
|
||||
lookupModelSpec('Bosch MIC-7522-Z30x')?.id,
|
||||
'bosch-mic-7522-z30b',
|
||||
);
|
||||
assert.equal(
|
||||
lookupModelSpec('Bosch MIC-7502-Z30x')?.id,
|
||||
'bosch-mic-7502-z30b',
|
||||
);
|
||||
assert.equal(lookupModelSpec('Bosch MIC-7130-Px4')?.id, 'bosch-mic-7130-pb4');
|
||||
assert.equal(lookupModelSpec('MIC-7522-Z30W')?.id, 'bosch-mic-7522-z30b');
|
||||
// Its ANPR units are not in the table: unknown, never a guess.
|
||||
assert.equal(lookupModelSpec('CSG-8800'), null);
|
||||
// The MIC and AUTODOME starlight 7000 HD (code-less names, so the brand
|
||||
// is needed) stay apart.
|
||||
assert.equal(
|
||||
lookupModelSpec('MIC IP starlight 7000 HD', 'Bosch')?.id,
|
||||
'bosch-mic-7130-pb4',
|
||||
);
|
||||
assert.equal(
|
||||
lookupModelSpec('AUTODOME IP starlight 7000 HD', 'Bosch')?.id,
|
||||
'bosch-autodome-ip-starlight-7000-hd',
|
||||
);
|
||||
// The MIC datasheets state FOV without an axis, so no horizontal range.
|
||||
assert.equal(horizontalFovRangeDeg(lookupModelSpec('MIC-7522-Z30B')), null);
|
||||
});
|
||||
|
||||
test('Bosch MIC colour aliases resolve without accepting unknown variants', () => {
|
||||
for (const [pattern, id] of [
|
||||
['MIC-7130-P?4', 'bosch-mic-7130-pb4'],
|
||||
['MIC-7502-Z30?', 'bosch-mic-7502-z30b'],
|
||||
['MIC-7522-Z30?', 'bosch-mic-7522-z30b'],
|
||||
]) {
|
||||
for (const colour of ['B', 'G', 'W', 'x']) {
|
||||
const model = pattern.replace('?', colour);
|
||||
assert.equal(lookupModelSpec(model)?.id, id, model);
|
||||
assert.equal(lookupModelSpec(`bosch ${model.toLowerCase()}`)?.id, id);
|
||||
assert.equal(lookupModelSpec(model, 'Bosch')?.id, id);
|
||||
assert.equal(lookupModelSpec(model, 'Axis'), null);
|
||||
assert.equal(lookupModelSpec(`${model}-UNKNOWN`), null);
|
||||
}
|
||||
assert.equal(lookupModelSpec(pattern.replace('?', 'R')), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('Bosch MIC HUD summary never names a colour the source did not', () => {
|
||||
assert.equal(
|
||||
specSummary(lookupModelSpec('MIC-7522-Z30W')),
|
||||
'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ',
|
||||
);
|
||||
assert.equal(
|
||||
specSummary(lookupModelSpec('Bosch MIC-7522-Z30x')),
|
||||
'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ',
|
||||
);
|
||||
for (const pattern of ['MIC-7130-P?4', 'MIC-7502-Z30?', 'MIC-7522-Z30?']) {
|
||||
for (const colour of ['B', 'G', 'W', 'x']) {
|
||||
const spec = lookupModelSpec(pattern.replace('?', colour));
|
||||
const summary = specSummary(spec);
|
||||
for (const other of ['B', 'G', 'W', 'x']) {
|
||||
assert.ok(!summary.includes(pattern.replace('?', other)), summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Bosch MIC catalog enrichment preserves estimated and explicit optics', () => {
|
||||
for (const model of [
|
||||
'Bosch MIC-7130-Px4',
|
||||
'Bosch MIC-7502-Z30x',
|
||||
'Bosch MIC-7522-Z30x',
|
||||
]) {
|
||||
for (const pose of [{}, { fovDeg: 44 }, { poseSource: 'curated' }]) {
|
||||
const [camera] = browserCatalog().buildCatalogFromSources([
|
||||
{ id: 'amsterdam-mic', lat: 52.37, lon: 4.9, model, ...pose },
|
||||
]);
|
||||
assert.ok(camera.spec, model);
|
||||
assert.equal(camera.fovDeg, pose.fovDeg ?? 74, model);
|
||||
assert.equal(camera.fovSource, undefined);
|
||||
assert.equal(camera.specFovMinDeg, undefined);
|
||||
assert.equal(camera.specFovMaxDeg, undefined);
|
||||
assert.equal(horizontalFovRangeDeg(camera.spec), null);
|
||||
assert.equal(fixedHorizontalFovDeg(camera.spec), null);
|
||||
assert.equal(specFovCapabilityToken(camera), null);
|
||||
assert.match(specSummary(camera.spec), /^Bosch MIC-.* · PTZ$/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('catalog never overwrites an explicit FOV or a curated pose', () => {
|
||||
const [explicit, curated, cohu, pelco] =
|
||||
browserCatalog().buildCatalogFromSources([
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/** Card model for one selected special-use airspace zone. Pure — no Cesium types. */
|
||||
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const DRONE_AIRSPACE_OVERLAY_SOURCE_ID = 'drone-airspace';
|
||||
|
||||
/** Fill/line CSS color for a zone by TYPE_CODE. */
|
||||
export function droneAirspaceAccent(typeCode) {
|
||||
return typeCode === 'P' ? '#ff3b30' : '#ff7a00';
|
||||
}
|
||||
|
||||
/** Shoelace area (degree², sign dropped) — relative sizes only. */
|
||||
function ringArea(ring) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < ring.length - 1; i++) {
|
||||
const [x1, y1] = ring[i];
|
||||
const [x2, y2] = ring[i + 1];
|
||||
sum += x1 * y2 - x2 * y1;
|
||||
}
|
||||
return Math.abs(sum / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor point for the zone card: the centroid of the largest polygon's
|
||||
* outer ring. Degrees, so callers own the Cartesian conversion.
|
||||
* @param {Array<Array<Array<[number, number]>>>} polygons
|
||||
* @returns {{lon: number, lat: number}}
|
||||
*/
|
||||
export function droneAirspaceAnchorDegrees(polygons) {
|
||||
let best = null;
|
||||
let bestArea = -1;
|
||||
for (const rings of polygons) {
|
||||
const outer = rings[0];
|
||||
const area = ringArea(outer);
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = outer;
|
||||
}
|
||||
}
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
const count = best.length - 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
lon += best[i][0];
|
||||
lat += best[i][1];
|
||||
}
|
||||
return { lon: lon / count, lat: lat / count };
|
||||
}
|
||||
|
||||
function altitudeLine(row) {
|
||||
const lower = row.lowerAltitude;
|
||||
const upper = row.upperAltitude;
|
||||
if (lower && upper) return t('{lower} to {upper}', { lower, upper });
|
||||
if (upper) return t('Up to {upper}', { upper });
|
||||
if (lower) return t('From {lower}', { lower });
|
||||
return null;
|
||||
}
|
||||
|
||||
function placeLine(row) {
|
||||
if (row.city && row.state) return `${row.city}, ${row.state}`;
|
||||
return row.city || row.state || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the overlay-host entry for one selected zone. The caller supplies
|
||||
* `position` (Cartesian) separately — this model stays JSON-safe for tests.
|
||||
* @param {Object} row - Normalized zone row (see records.js).
|
||||
* @returns {Object} World-overlay entry without `position`.
|
||||
*/
|
||||
export function buildDroneAirspaceCard(row) {
|
||||
const facts = [row.type ? t(row.type) : row.typeCode].filter(Boolean);
|
||||
const altitude = altitudeLine(row);
|
||||
if (altitude) facts.push(altitude);
|
||||
if (row.timesOfUse) facts.push(row.timesOfUse);
|
||||
|
||||
const details = [facts.join(' · ')];
|
||||
const place = placeLine(row);
|
||||
if (place) details.push(place);
|
||||
if (row.agency) details.push(row.agency);
|
||||
details.push(t('Informational only · not for flight planning'));
|
||||
|
||||
const type = row.type ? t(row.type) : 'SUA';
|
||||
const title = `${type.toUpperCase()} · ${row.name || t('Unnamed zone')}`;
|
||||
return {
|
||||
id: `drone-airspace-card:${row.stableId}`,
|
||||
selected: true,
|
||||
interactive: false,
|
||||
title,
|
||||
details,
|
||||
accent: droneAirspaceAccent(row.typeCode),
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
droneAirspaceAnchorDegrees,
|
||||
droneAirspaceAccent,
|
||||
buildDroneAirspaceCard,
|
||||
} from './cards.js';
|
||||
|
||||
const square = [
|
||||
[-77.2, 38.8],
|
||||
[-77.0, 38.8],
|
||||
[-77.0, 39.0],
|
||||
[-77.2, 39.0],
|
||||
[-77.2, 38.8],
|
||||
];
|
||||
const tiny = [
|
||||
[-76.01, 38.0],
|
||||
[-76.0, 38.0],
|
||||
[-76.0, 38.01],
|
||||
[-76.01, 38.0],
|
||||
];
|
||||
|
||||
test('the card anchors at the centroid of the largest polygon', () => {
|
||||
const anchor = droneAirspaceAnchorDegrees([[tiny], [square]]);
|
||||
assert.ok(Math.abs(anchor.lon - -77.1) < 1e-9);
|
||||
assert.ok(Math.abs(anchor.lat - 38.9) < 1e-9);
|
||||
});
|
||||
|
||||
test('a prohibited zone card names the type, altitudes and the disclaimer', () => {
|
||||
const card = buildDroneAirspaceCard({
|
||||
stableId: '310',
|
||||
name: 'P-56',
|
||||
typeCode: 'P',
|
||||
type: 'Prohibited',
|
||||
lowerAltitude: 'SFC',
|
||||
upperAltitude: '18,000 ft MSL',
|
||||
timesOfUse: 'CONTINUOUS',
|
||||
agency: 'WASHINGTON CENTER',
|
||||
city: 'WASHINGTON',
|
||||
state: 'DC',
|
||||
});
|
||||
assert.equal(card.id, 'drone-airspace-card:310');
|
||||
assert.equal(card.title, 'PROHIBITED · P-56');
|
||||
assert.deepEqual(card.details, [
|
||||
'Prohibited · SFC to 18,000 ft MSL · CONTINUOUS',
|
||||
'WASHINGTON, DC',
|
||||
'WASHINGTON CENTER',
|
||||
'Informational only · not for flight planning',
|
||||
]);
|
||||
assert.equal(card.accent, droneAirspaceAccent('P'));
|
||||
assert.equal(card.interactive, false);
|
||||
assert.equal(card.selected, true);
|
||||
});
|
||||
|
||||
test('missing attributes degrade to available facts instead of placeholders', () => {
|
||||
const card = buildDroneAirspaceCard({
|
||||
stableId: 'x',
|
||||
name: null,
|
||||
typeCode: 'R',
|
||||
type: 'Restricted',
|
||||
lowerAltitude: null,
|
||||
upperAltitude: null,
|
||||
timesOfUse: null,
|
||||
agency: null,
|
||||
city: null,
|
||||
state: null,
|
||||
});
|
||||
assert.equal(card.title, 'RESTRICTED · Unnamed zone');
|
||||
assert.deepEqual(card.details, [
|
||||
'Restricted',
|
||||
'Informational only · not for flight planning',
|
||||
]);
|
||||
assert.equal(card.accent, droneAirspaceAccent('R'));
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { VOICE_OFF_LAYERS } from '../../voice/layerManifest.js';
|
||||
import { ANALYST_LAYERS } from '../../data/analystEngine.js';
|
||||
|
||||
const read = (path) =>
|
||||
readFileSync(new URL(`../../../${path}`, import.meta.url), 'utf8');
|
||||
const currentState = read('docs/CURRENT-STATE.md');
|
||||
const section = currentState.slice(
|
||||
currentState.indexOf('## Prohibited & Restricted Airspace'),
|
||||
currentState.indexOf('\n## ', currentState.indexOf('## Prohibited')) + 1,
|
||||
);
|
||||
const tokens = JSON.parse(read('src/data/layerStateTokenReservations.json'));
|
||||
|
||||
test('CURRENT-STATE names the reserved share-link token', () => {
|
||||
const token = JSON.stringify(tokens).match(
|
||||
/\["drone-airspace","([^"]+)"\]/,
|
||||
)?.[1];
|
||||
assert.ok(token, 'drone-airspace has a reserved token');
|
||||
assert.match(section, new RegExp(`share-link token \`${token}\``));
|
||||
});
|
||||
|
||||
test('CURRENT-STATE matches the layer being outside voice and the analyst', () => {
|
||||
assert.ok(Object.hasOwn(VOICE_OFF_LAYERS, 'drone-airspace'));
|
||||
assert.ok(!Object.hasOwn(ANALYST_LAYERS, 'drone-airspace'));
|
||||
assert.doesNotMatch(section, /Voice can toggle/);
|
||||
assert.match(section, /Voice is off for this layer for now/);
|
||||
const analyst = currentState.match(/Analyst records come on demand[^\n]*/)[0];
|
||||
assert.doesNotMatch(analyst, /drone/i);
|
||||
});
|
||||
|
||||
test('CURRENT-STATE states what the layer does not cover', () => {
|
||||
assert.match(section, /UAS Facility Map/);
|
||||
assert.match(section, /LAANC/);
|
||||
assert.match(section, /TFRs/);
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import {
|
||||
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
|
||||
droneAirspaceAnchorDegrees,
|
||||
buildDroneAirspaceCard,
|
||||
droneAirspaceAccent,
|
||||
} from './cards.js';
|
||||
export { normalizeDroneAirspaceSnapshot } from './records.js';
|
||||
export { createDroneAirspaceSource } from './source.js';
|
||||
export * from './cards.js';
|
||||
|
||||
/** Fill/line color for a zone by TYPE_CODE. */
|
||||
export function droneAirspaceColor(typeCode) {
|
||||
return Cesium.Color.fromCssColorString(droneAirspaceAccent(typeCode));
|
||||
}
|
||||
|
||||
const ringPositions = (ring) =>
|
||||
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
|
||||
|
||||
/**
|
||||
* FNV-1a over a zone's rings: a short, deterministic fingerprint so a
|
||||
* boundary change with unchanged attributes still alters the refresh signature.
|
||||
*/
|
||||
function geometryHash(polygons) {
|
||||
const text = JSON.stringify(polygons);
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
const PICK_PREFIX = 'drone-airspace:';
|
||||
const CARD_HOST_OPTIONS = Object.freeze({
|
||||
cohortLimit: 1,
|
||||
collisionCapacity: 1,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
/** Own one drone-restriction display, its refresh lifecycle, and click selection. */
|
||||
export function createDroneAirspaceLayer({
|
||||
source,
|
||||
overlayHost = null,
|
||||
screenSpaceEventHandlerFactory = null,
|
||||
picking = null,
|
||||
pointer = null,
|
||||
} = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('Drone airspace requires a snapshot source');
|
||||
let _viewer = null;
|
||||
let _request = null;
|
||||
let _snapshotSignature = null;
|
||||
let _dataSource = null;
|
||||
let _count = 0;
|
||||
let _lastUpdate = null;
|
||||
let _lastError = null;
|
||||
let _stale = false;
|
||||
let _enabled = false;
|
||||
let _clickHandler = null;
|
||||
let _selectedId = null;
|
||||
let _selectedCardId = null;
|
||||
/** @type {Map<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
|
||||
const _rowById = new Map();
|
||||
|
||||
const canSelect = () =>
|
||||
overlayHost && screenSpaceEventHandlerFactory && picking;
|
||||
|
||||
function publishSelectedCard() {
|
||||
if (!canSelect()) return;
|
||||
const row = _selectedId ? _rowById.get(_selectedId) : null;
|
||||
if (!row) {
|
||||
_selectedId = null;
|
||||
_selectedCardId = null;
|
||||
overlayHost.setEntries(
|
||||
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
|
||||
[],
|
||||
CARD_HOST_OPTIONS,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const card = {
|
||||
...buildDroneAirspaceCard(row),
|
||||
position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat),
|
||||
};
|
||||
_selectedCardId = card.id;
|
||||
overlayHost.setEntries(
|
||||
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
|
||||
[card],
|
||||
CARD_HOST_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a scene pick to one of this layer's zone ids, or null. */
|
||||
function pickedZoneId(picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||
return null;
|
||||
const zoneId = pickId.slice(PICK_PREFIX.length).split(':')[0];
|
||||
return _rowById.has(zoneId) ? zoneId : null;
|
||||
}
|
||||
|
||||
function installClickHandler() {
|
||||
if (!canSelect() || _clickHandler || !_viewer) return;
|
||||
// Deliberately NOT registered in the pick-ownership registry: sibling
|
||||
// layers yield to owned picks before their own overlay-card hit-tests,
|
||||
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
|
||||
// cards inert anywhere over a zone. Unowned, our picks read as
|
||||
// "empty space" to siblings — the same contract as fire perimeters.
|
||||
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
|
||||
_clickHandler.setInputAction((click) => {
|
||||
if (pointer && !pointer.isPointerFree()) return;
|
||||
const cardHit = overlayHost.hitTest?.(
|
||||
click.position?.x,
|
||||
click.position?.y,
|
||||
{ sourceId: DRONE_AIRSPACE_OVERLAY_SOURCE_ID },
|
||||
);
|
||||
if (cardHit && cardHit.entryId === _selectedCardId) return;
|
||||
const picked = _viewer.scene.pick(click.position);
|
||||
const zoneId = picked ? pickedZoneId(picked) : null;
|
||||
if (zoneId) {
|
||||
_selectedId = zoneId;
|
||||
publishSelectedCard();
|
||||
return;
|
||||
}
|
||||
if (picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return;
|
||||
}
|
||||
if (_selectedId) {
|
||||
_selectedId = null;
|
||||
publishSelectedCard();
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function removeClickHandler() {
|
||||
if (_clickHandler) {
|
||||
_clickHandler.destroy();
|
||||
_clickHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
_selectedId = null;
|
||||
_selectedCardId = null;
|
||||
if (overlayHost) {
|
||||
overlayHost.clearSource(DRONE_AIRSPACE_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, false);
|
||||
}
|
||||
}
|
||||
|
||||
const layer = {
|
||||
id: 'drone-airspace',
|
||||
name: 'Prohibited & Restricted Airspace',
|
||||
icon: '🚫',
|
||||
source: 'FAA SUA',
|
||||
updateInterval: 3600000,
|
||||
|
||||
init(viewer) {
|
||||
if (_viewer)
|
||||
throw new Error('Drone airspace layer is already initialized');
|
||||
_viewer = viewer;
|
||||
_dataSource = new Cesium.CustomDataSource('drone-airspace');
|
||||
_dataSource.show = false;
|
||||
viewer.dataSources.add(_dataSource);
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
_enabled = false;
|
||||
console.log('[Data:DroneAirspace] Initialized');
|
||||
},
|
||||
|
||||
enable() {
|
||||
_enabled = true;
|
||||
if (_dataSource) _dataSource.show = true;
|
||||
overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true);
|
||||
installClickHandler();
|
||||
},
|
||||
|
||||
disable() {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
_enabled = false;
|
||||
if (_dataSource) _dataSource.show = false;
|
||||
removeClickHandler();
|
||||
clearSelection();
|
||||
},
|
||||
|
||||
async update() {
|
||||
if (!_enabled || !_dataSource) return false;
|
||||
_request?.abort();
|
||||
const request = new AbortController();
|
||||
_request = request;
|
||||
try {
|
||||
// The source contract is getSnapshot; a source that can also say how
|
||||
// fresh its rows are (the FAA proxy) offers getSnapshotDetail.
|
||||
const snapshot =
|
||||
typeof source.getSnapshotDetail === 'function'
|
||||
? await source.getSnapshotDetail({ signal: request.signal })
|
||||
: { rows: await source.getSnapshot({ signal: request.signal }) };
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
const { rows } = snapshot;
|
||||
_stale = snapshot.stale === true;
|
||||
// A stale snapshot is as old as its fetch, not this poll.
|
||||
const updatedAt =
|
||||
_stale && Number.isFinite(snapshot.fetchedAt)
|
||||
? snapshot.fetchedAt
|
||||
: Date.now();
|
||||
|
||||
// FAA amends boundaries in its 8-week cycle without any attribute
|
||||
// changing, so the geometry is part of the signature.
|
||||
const signature = JSON.stringify(
|
||||
rows
|
||||
.map(({ polygons, ...facts }) => ({
|
||||
...facts,
|
||||
geometry: geometryHash(polygons),
|
||||
}))
|
||||
.sort((a, b) => a.stableId.localeCompare(b.stableId)),
|
||||
);
|
||||
if (signature === _snapshotSignature) {
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_lastUpdate = updatedAt;
|
||||
_lastError = null;
|
||||
return true;
|
||||
}
|
||||
const nextEntities = [];
|
||||
_rowById.clear();
|
||||
for (const row of rows) {
|
||||
const color = droneAirspaceColor(row.typeCode);
|
||||
for (const [index, rings] of row.polygons.entries()) {
|
||||
const [outer, ...holes] = rings;
|
||||
const outerPositions = ringPositions(outer);
|
||||
nextEntities.push(
|
||||
new Cesium.Entity({
|
||||
id: `drone-airspace:${row.stableId}:${index}`,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
outerPositions,
|
||||
holes.map(
|
||||
(hole) =>
|
||||
new Cesium.PolygonHierarchy(ringPositions(hole)),
|
||||
),
|
||||
),
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(0.25),
|
||||
),
|
||||
},
|
||||
polyline: {
|
||||
positions: outerPositions,
|
||||
clampToGround: true,
|
||||
width: 2,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(0.9),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
const { polygons, ...facts } = row;
|
||||
_rowById.set(row.stableId, {
|
||||
...facts,
|
||||
anchor: droneAirspaceAnchorDegrees(polygons),
|
||||
});
|
||||
}
|
||||
|
||||
_dataSource.entities.removeAll();
|
||||
for (const entity of nextEntities) _dataSource.entities.add(entity);
|
||||
_snapshotSignature = signature;
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_count = rows.length;
|
||||
_lastUpdate = updatedAt;
|
||||
_lastError = null;
|
||||
console.log(
|
||||
`[Data:DroneAirspace] Updated: ${_count} zones, ${nextEntities.length} polygons`,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
console.warn('[Data:DroneAirspace] Fetch error:', e);
|
||||
_lastError =
|
||||
e?.message || msg('Restricted airspace source unavailable');
|
||||
// A failure now outranks the stale snapshot still drawn.
|
||||
_stale = false;
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer = _viewer) {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
removeClickHandler();
|
||||
clearSelection();
|
||||
_rowById.clear();
|
||||
_snapshotSignature = null;
|
||||
_viewer = null;
|
||||
_enabled = false;
|
||||
if (_dataSource) {
|
||||
viewer.dataSources.remove(_dataSource, true);
|
||||
_dataSource = null;
|
||||
}
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
},
|
||||
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (!_dataSource || !_dataSource.show) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
const result = [];
|
||||
for (const row of _rowById.values()) {
|
||||
if (result.length >= limit) break;
|
||||
const { anchor, stableId, ...facts } = row;
|
||||
result.push({
|
||||
id: stableId,
|
||||
...facts,
|
||||
lat: anchor.lat,
|
||||
lon: anchor.lon,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getRowControls() {
|
||||
const bands = [
|
||||
{ label: t('Prohibited (P)'), color: droneAirspaceAccent('P') },
|
||||
{ label: t('Restricted (R)'), color: droneAirspaceAccent('R') },
|
||||
];
|
||||
return {
|
||||
chips: [],
|
||||
legend: bands.map((band, index) => ({
|
||||
...band,
|
||||
count: [..._rowById.values()].filter(
|
||||
(row) => droneAirspaceAccent(row.typeCode) === band.color,
|
||||
).length,
|
||||
...(index === 0
|
||||
? {
|
||||
blurb: t(
|
||||
'FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: _count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
stale: _stale,
|
||||
};
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createDroneAirspaceLayer } from './index.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
function harness(source, { pick = () => null, cardHit = () => null } = {}) {
|
||||
const sources = [];
|
||||
const overlay = { entries: new Map(), visible: null };
|
||||
const clicks = { handler: null, destroyed: 0 };
|
||||
const owners = new Map();
|
||||
const viewer = {
|
||||
scene: { pick },
|
||||
dataSources: {
|
||||
add(value) {
|
||||
sources.push(value);
|
||||
},
|
||||
remove(value) {
|
||||
sources.splice(sources.indexOf(value), 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
const layer = createDroneAirspaceLayer({
|
||||
source,
|
||||
overlayHost: {
|
||||
setEntries(sourceId, entries) {
|
||||
overlay.entries.set(sourceId, entries);
|
||||
},
|
||||
setVisible(sourceId, visible) {
|
||||
overlay.visible = visible;
|
||||
},
|
||||
clearSource(sourceId) {
|
||||
overlay.entries.delete(sourceId);
|
||||
},
|
||||
hitTest: (x, y, options) => cardHit(x, y, options),
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction(callback) {
|
||||
clicks.handler = callback;
|
||||
},
|
||||
destroy() {
|
||||
clicks.destroyed += 1;
|
||||
clicks.handler = null;
|
||||
},
|
||||
}),
|
||||
picking: {
|
||||
resolvePickId: (picked) => picked?.id ?? null,
|
||||
isOwnedByOtherLayer: (layerId, pickedId) =>
|
||||
String(pickedId).startsWith('other-layer:'),
|
||||
registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate),
|
||||
unregisterPickOwner: (layerId) => owners.delete(layerId),
|
||||
},
|
||||
pointer: { isPointerFree: () => true },
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
return { layer, viewer, sources, overlay, clicks, owners };
|
||||
}
|
||||
|
||||
const ring = [
|
||||
[-77.04, 38.89],
|
||||
[-77.03, 38.89],
|
||||
[-77.03, 38.9],
|
||||
[-77.04, 38.89],
|
||||
];
|
||||
const row = {
|
||||
stableId: '310',
|
||||
name: 'P-56',
|
||||
typeCode: 'P',
|
||||
type: 'Prohibited',
|
||||
lowerAltitude: 'SFC',
|
||||
upperAltitude: '18,000 ft MSL',
|
||||
timesOfUse: 'CONTINUOUS',
|
||||
agency: null,
|
||||
city: 'WASHINGTON',
|
||||
state: 'DC',
|
||||
polygons: [[ring]],
|
||||
};
|
||||
|
||||
test('each zone polygon renders as one filled entity with its outline', async () => {
|
||||
const h = harness({
|
||||
getSnapshot: async () => [
|
||||
row,
|
||||
{ ...row, stableId: '400', typeCode: 'R', polygons: [[ring], [ring]] },
|
||||
],
|
||||
});
|
||||
assert.equal(await h.layer.update(h.viewer), true);
|
||||
const entities = h.sources[0].entities.values;
|
||||
assert.equal(entities.length, 3);
|
||||
assert.equal(entities[0].id, 'drone-airspace:310:0');
|
||||
assert.ok(entities[0].polygon, 'zone fill missing');
|
||||
assert.ok(entities[0].polyline, 'zone outline missing');
|
||||
assert.equal(h.layer.getStats().count, 2);
|
||||
});
|
||||
|
||||
test('clicking a zone publishes its card; empty space clears it', async () => {
|
||||
let pickResult = null;
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{ pick: () => pickResult },
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
|
||||
assert.equal(h.owners.size, 0);
|
||||
|
||||
pickResult = { id: 'drone-airspace:310:0' };
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
const entries = h.overlay.entries.get('drone-airspace');
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].id, 'drone-airspace-card:310');
|
||||
assert.equal(entries[0].title, 'PROHIBITED · P-56');
|
||||
assert.ok(entries[0].position, 'card must carry a world anchor');
|
||||
|
||||
pickResult = { id: 'other-layer:aircraft-1' };
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
assert.equal(
|
||||
h.overlay.entries.get('drone-airspace')?.length,
|
||||
1,
|
||||
'sibling-layer picks must not clear the selection',
|
||||
);
|
||||
|
||||
pickResult = null;
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
assert.equal(h.overlay.entries.get('drone-airspace')?.length ?? 0, 0);
|
||||
});
|
||||
|
||||
test('late refresh cannot publish after disable or destroy', async () => {
|
||||
for (const action of ['disable', 'destroy']) {
|
||||
let resolve, signal;
|
||||
const h = harness({
|
||||
getSnapshot(options) {
|
||||
signal = options.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
});
|
||||
const pending = h.layer.update(h.viewer);
|
||||
h.layer[action](h.viewer);
|
||||
assert.equal(signal.aborted, true);
|
||||
if (action === 'disable') h.layer.enable(h.viewer);
|
||||
resolve([row]);
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.layer.getStats().count, 0);
|
||||
h.layer.destroy(h.viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('the row legend names prohibited and restricted with the disclaimer', async () => {
|
||||
const h = harness({
|
||||
getSnapshot: async () => [
|
||||
row,
|
||||
{ ...row, stableId: '400', typeCode: 'R', type: 'Restricted' },
|
||||
],
|
||||
});
|
||||
await h.layer.update(h.viewer);
|
||||
const legend = h.layer.getRowControls().legend;
|
||||
assert.equal(legend[0].label, 'Prohibited (P)');
|
||||
assert.equal(legend[1].label, 'Restricted (R)');
|
||||
assert.equal(legend[0].count, 1);
|
||||
assert.equal(legend[1].count, 1);
|
||||
assert.match(legend[0].blurb, /not a substitute/);
|
||||
});
|
||||
|
||||
test('a stale snapshot reads STALE, dated by its own fetch, and a fresh one clears it', async () => {
|
||||
const fetchedAt = Date.now() - 3 * 60 * 60 * 1000;
|
||||
let snapshot = { rows: [row], stale: true, fetchedAt };
|
||||
const h = harness({
|
||||
getSnapshot: async () => snapshot.rows,
|
||||
getSnapshotDetail: async () => snapshot,
|
||||
});
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, true);
|
||||
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'stale');
|
||||
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, false);
|
||||
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'nominal');
|
||||
});
|
||||
|
||||
test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => {
|
||||
let fail = false;
|
||||
const h = harness({
|
||||
getSnapshot: async () => [row],
|
||||
getSnapshotDetail: async () => {
|
||||
if (fail) throw new Error('FAA SUA HTTP 502');
|
||||
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
|
||||
},
|
||||
});
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'stale');
|
||||
fail = true;
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, false);
|
||||
assert.equal(h.layer.getStats().error, 'FAA SUA HTTP 502');
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'degraded');
|
||||
});
|
||||
|
||||
test('a boundary change alone redraws the zone; identical geometry does not', async () => {
|
||||
const moved = ring.map(([lon, lat]) => [lon + 0.5, lat]);
|
||||
let rows = [row];
|
||||
const h = harness({ getSnapshot: async () => rows });
|
||||
await h.layer.update(h.viewer);
|
||||
const [first] = h.sources[0].entities.values;
|
||||
rows = [{ ...row, polygons: [[ring.map((point) => [...point])]] }];
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.sources[0].entities.values[0], first, 'same geometry kept');
|
||||
rows = [{ ...row, polygons: [[moved]] }];
|
||||
await h.layer.update(h.viewer);
|
||||
const [redrawn] = h.sources[0].entities.values;
|
||||
assert.notEqual(redrawn, first, 'moved boundary must redraw');
|
||||
assert.notDeepEqual(
|
||||
redrawn.polyline.positions.getValue(),
|
||||
first.polyline.positions.getValue(),
|
||||
);
|
||||
assert.ok(h.layer.getAnalystRecords()[0].lon > -76.6);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Normalize an FAA Special Use Airspace feed, skipping individually invalid
|
||||
* features. Only a payload that is not a feature collection at all rejects
|
||||
* the whole snapshot: a single degenerate polygon must never blank the layer.
|
||||
*/
|
||||
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
|
||||
const TYPE_LABELS = Object.freeze({
|
||||
P: msg('Prohibited'),
|
||||
R: msg('Restricted'),
|
||||
});
|
||||
|
||||
const finiteOrNull = (value) => (Number.isFinite(value) ? value : null);
|
||||
const textOrNull = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
const text = value.trim();
|
||||
return text ? text : null;
|
||||
};
|
||||
|
||||
/** FAA marks line breaks in TIMESOFUSE with `%`; read them as `; ` separators. */
|
||||
const timesOfUseOrNull = (value) =>
|
||||
textOrNull(
|
||||
typeof value === 'string'
|
||||
? value.replace(/\s*;?\s*%\s*/g, '; ').replace(/^[;\s]+|[;\s]+$/g, '')
|
||||
: value,
|
||||
);
|
||||
|
||||
function validRing(ring) {
|
||||
if (!Array.isArray(ring) || ring.length < 4) return false;
|
||||
for (const position of ring) {
|
||||
if (!Array.isArray(position) || position.length < 2) return false;
|
||||
const [lon, lat] = position;
|
||||
if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false;
|
||||
if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an
|
||||
* array of rings). Returns null on malformed geometry, [] when empty. */
|
||||
function normalizePolygons(geometry) {
|
||||
if (!geometry || typeof geometry !== 'object') return null;
|
||||
let polygons;
|
||||
if (geometry.type === 'Polygon') polygons = [geometry.coordinates];
|
||||
else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates;
|
||||
else return null;
|
||||
if (!Array.isArray(polygons)) return null;
|
||||
const result = [];
|
||||
for (const rings of polygons) {
|
||||
if (!Array.isArray(rings)) return null;
|
||||
if (!rings.length) continue;
|
||||
if (!rings.every(validRing)) return null;
|
||||
result.push(rings);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* One SUA altitude limit from its VAL/UOM/CODE triple, in aviation notation:
|
||||
* `SFC`, `500 ft AGL`, `5,000 ft MSL`, `FL180` or `Unlimited`. The *_DESC
|
||||
* fields are inclusion qualifiers (TI/TNI/ANI), not altitudes. Returns null
|
||||
* when the triple does not describe a limit.
|
||||
*/
|
||||
export function droneAirspaceAltitude(value, uom, code) {
|
||||
const unit = textOrNull(uom)?.toUpperCase() ?? null;
|
||||
const reference = textOrNull(code)?.toUpperCase() ?? null;
|
||||
if (reference === 'UNLTD') return 'Unlimited';
|
||||
const number = Number(value);
|
||||
if (value == null || value === '' || !Number.isFinite(number) || number < 0)
|
||||
return null;
|
||||
if (unit === 'FL' || reference === 'STD') return `FL${Math.round(number)}`;
|
||||
const feet = `${Math.round(number).toLocaleString('en-US')} ft`;
|
||||
if (reference === 'SFC') return number === 0 ? 'SFC' : `${feet} AGL`;
|
||||
if (reference === 'MSL') return `${feet} MSL`;
|
||||
return unit === 'FT' || unit == null ? feet : null;
|
||||
}
|
||||
|
||||
/** Human label for a TYPE_CODE, or null when the code is not in the pack. */
|
||||
export function droneAirspaceTypeLabel(typeCode) {
|
||||
return TYPE_LABELS[typeCode] || null;
|
||||
}
|
||||
|
||||
export function normalizeDroneAirspaceSnapshot(geojson) {
|
||||
if (!Array.isArray(geojson?.features)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const feature of geojson.features) {
|
||||
const properties = feature?.properties;
|
||||
if (
|
||||
!properties ||
|
||||
typeof properties !== 'object' ||
|
||||
Array.isArray(properties)
|
||||
)
|
||||
continue;
|
||||
const typeCode = textOrNull(properties.TYPE_CODE);
|
||||
const type = droneAirspaceTypeLabel(typeCode);
|
||||
if (!type) continue;
|
||||
const polygons = normalizePolygons(feature.geometry);
|
||||
if (polygons === null || !polygons.length) continue;
|
||||
const objectId = finiteOrNull(Number(properties.OBJECTID));
|
||||
const featureId =
|
||||
feature.id == null || feature.id === '' ? null : String(feature.id);
|
||||
const stableId =
|
||||
objectId != null
|
||||
? String(objectId)
|
||||
: featureId && !ids.has(featureId)
|
||||
? featureId
|
||||
: null;
|
||||
if (stableId == null || ids.has(stableId)) continue;
|
||||
ids.add(stableId);
|
||||
rows.push({
|
||||
stableId,
|
||||
name: textOrNull(properties.NAME),
|
||||
typeCode,
|
||||
type,
|
||||
lowerAltitude: droneAirspaceAltitude(
|
||||
properties.LOWER_VAL,
|
||||
properties.LOWER_UOM,
|
||||
properties.LOWER_CODE,
|
||||
),
|
||||
upperAltitude: droneAirspaceAltitude(
|
||||
properties.UPPER_VAL,
|
||||
properties.UPPER_UOM,
|
||||
properties.UPPER_CODE,
|
||||
),
|
||||
timesOfUse: timesOfUseOrNull(properties.TIMESOFUSE),
|
||||
agency: textOrNull(properties.CONT_AGENT),
|
||||
city: textOrNull(properties.CITY),
|
||||
state: textOrNull(properties.STATE),
|
||||
polygons,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
droneAirspaceAltitude,
|
||||
droneAirspaceTypeLabel,
|
||||
normalizeDroneAirspaceSnapshot,
|
||||
} from './records.js';
|
||||
|
||||
const ring = [
|
||||
[-77.04, 38.89],
|
||||
[-77.03, 38.89],
|
||||
[-77.03, 38.9],
|
||||
[-77.04, 38.89],
|
||||
];
|
||||
|
||||
function feature(overrides = {}) {
|
||||
return {
|
||||
id: 310,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: {
|
||||
NAME: 'P-56',
|
||||
TYPE_CODE: 'P',
|
||||
UPPER_DESC: 'TNI',
|
||||
UPPER_VAL: '18000',
|
||||
UPPER_UOM: 'FT',
|
||||
UPPER_CODE: 'MSL',
|
||||
LOWER_DESC: null,
|
||||
LOWER_VAL: '0',
|
||||
LOWER_UOM: 'FT',
|
||||
LOWER_CODE: 'SFC',
|
||||
TIMESOFUSE: 'CONTINUOUS',
|
||||
CONT_AGENT: 'WASHINGTON CENTER',
|
||||
CITY: 'WASHINGTON',
|
||||
STATE: 'DC',
|
||||
OBJECTID: 310,
|
||||
...overrides.properties,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('a valid snapshot yields one row per prohibited or restricted zone', () => {
|
||||
const rows = normalizeDroneAirspaceSnapshot({
|
||||
features: [
|
||||
feature(),
|
||||
feature({
|
||||
id: 400,
|
||||
properties: {
|
||||
NAME: 'R-2508',
|
||||
TYPE_CODE: 'R',
|
||||
OBJECTID: 400,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(rows[0], {
|
||||
stableId: '310',
|
||||
name: 'P-56',
|
||||
typeCode: 'P',
|
||||
type: 'Prohibited',
|
||||
lowerAltitude: 'SFC',
|
||||
upperAltitude: '18,000 ft MSL',
|
||||
timesOfUse: 'CONTINUOUS',
|
||||
agency: 'WASHINGTON CENTER',
|
||||
city: 'WASHINGTON',
|
||||
state: 'DC',
|
||||
polygons: [[ring]],
|
||||
});
|
||||
assert.equal(rows[1].type, 'Restricted');
|
||||
assert.equal(rows[1].typeCode, 'R');
|
||||
});
|
||||
|
||||
test('altitudes come from VAL/UOM/CODE, not the TI/TNI/ANI qualifiers', () => {
|
||||
// P-40 and R-2508 as the FAA service publishes them.
|
||||
assert.equal(droneAirspaceAltitude('0', 'FT', 'SFC'), 'SFC');
|
||||
assert.equal(droneAirspaceAltitude('5000', 'FT', 'MSL'), '5,000 ft MSL');
|
||||
assert.equal(droneAirspaceAltitude('20000', 'FT', 'MSL'), '20,000 ft MSL');
|
||||
assert.equal(droneAirspaceAltitude('-9998', null, 'UNLTD'), 'Unlimited');
|
||||
assert.equal(droneAirspaceAltitude('1500', 'FT', 'SFC'), '1,500 ft AGL');
|
||||
assert.equal(droneAirspaceAltitude('180', 'FL', 'STD'), 'FL180');
|
||||
assert.equal(droneAirspaceAltitude('10000', 'FT', null), '10,000 ft');
|
||||
assert.equal(droneAirspaceAltitude(null, null, null), null);
|
||||
assert.equal(droneAirspaceAltitude('-9998', 'FT', 'MSL'), null);
|
||||
const [row] = normalizeDroneAirspaceSnapshot({
|
||||
features: [
|
||||
feature({
|
||||
properties: {
|
||||
NAME: 'R-2508',
|
||||
TYPE_CODE: 'R',
|
||||
OBJECTID: 400,
|
||||
UPPER_DESC: null,
|
||||
UPPER_VAL: '-9998',
|
||||
UPPER_UOM: null,
|
||||
UPPER_CODE: 'UNLTD',
|
||||
LOWER_VAL: '20000',
|
||||
LOWER_UOM: 'FT',
|
||||
LOWER_CODE: 'MSL',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(row.lowerAltitude, '20,000 ft MSL');
|
||||
assert.equal(row.upperAltitude, 'Unlimited');
|
||||
});
|
||||
|
||||
test('MOA, warning and advisory codes are dropped, as are bad geometries', () => {
|
||||
const rows = normalizeDroneAirspaceSnapshot({
|
||||
features: [
|
||||
feature({ properties: { TYPE_CODE: 'MOA', OBJECTID: 1 } }),
|
||||
feature({ properties: { TYPE_CODE: 'W', OBJECTID: 2 } }),
|
||||
feature({ properties: { TYPE_CODE: 'A', OBJECTID: 3 } }),
|
||||
feature({ geometry: null, properties: { OBJECTID: 4 } }),
|
||||
feature({
|
||||
geometry: { type: 'Point', coordinates: [-77, 38] },
|
||||
properties: { OBJECTID: 5 },
|
||||
}),
|
||||
feature(),
|
||||
],
|
||||
});
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].name, 'P-56');
|
||||
});
|
||||
|
||||
test('duplicate ids and a missing collection reject or skip as documented', () => {
|
||||
const dup = normalizeDroneAirspaceSnapshot({
|
||||
features: [feature(), feature({ id: 311 })],
|
||||
});
|
||||
assert.equal(dup.length, 1);
|
||||
assert.equal(normalizeDroneAirspaceSnapshot({}), null);
|
||||
assert.equal(normalizeDroneAirspaceSnapshot(null), null);
|
||||
assert.equal(droneAirspaceTypeLabel('P'), 'Prohibited');
|
||||
assert.equal(droneAirspaceTypeLabel('R'), 'Restricted');
|
||||
assert.equal(droneAirspaceTypeLabel('MOA'), null);
|
||||
});
|
||||
|
||||
test("FAA's % line-break markers in times of use read as separators", () => {
|
||||
const timesOfUse = (TIMESOFUSE) =>
|
||||
normalizeDroneAirspaceSnapshot({
|
||||
features: [
|
||||
feature({
|
||||
properties: {
|
||||
NAME: 'R-2508',
|
||||
TYPE_CODE: 'R',
|
||||
OBJECTID: 400,
|
||||
TIMESOFUSE,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})[0].timesOfUse;
|
||||
// The shape 56 live records carry: a marker after the semicolon.
|
||||
assert.equal(
|
||||
timesOfUse(
|
||||
'0700 - 2200 MON - FRI; 0800 - 1600 SAT - SUN; %OTHER TIMES BY NOTAM',
|
||||
),
|
||||
'0700 - 2200 MON - FRI; 0800 - 1600 SAT - SUN; OTHER TIMES BY NOTAM',
|
||||
);
|
||||
assert.equal(
|
||||
timesOfUse('SR - SS DAILY %OTHER TIMES BY NOTAM'),
|
||||
'SR - SS DAILY; OTHER TIMES BY NOTAM',
|
||||
);
|
||||
assert.equal(timesOfUse('%CONTINUOUS%'), 'CONTINUOUS');
|
||||
assert.equal(timesOfUse(' % '), null);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
/** Request a normalized snapshot through the bounded, same-origin FAA proxy. */
|
||||
export function createDroneAirspaceSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
/**
|
||||
* The rows with how fresh they are: the proxy answers from its last
|
||||
* snapshot, marked stale, while the FAA service is unreachable.
|
||||
* @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>}
|
||||
*/
|
||||
async function getSnapshotDetail({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/drone-airspace', { signal });
|
||||
if (!response.ok) throw new Error(`FAA SUA HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
80 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error('Malformed drone airspace snapshot');
|
||||
return {
|
||||
rows: payload.rows,
|
||||
stale: payload.stale === true,
|
||||
fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
/** The zone rows (see getSnapshotDetail for staleness). */
|
||||
async getSnapshot(options = {}) {
|
||||
return (await getSnapshotDetail(options)).rows;
|
||||
},
|
||||
getSnapshotDetail,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDroneAirspaceSource } from './source.js';
|
||||
import { normalizeDroneAirspaceSnapshot } from './records.js';
|
||||
import { droneAirspaceProxy } from '../../../server/providers/droneAirspace.js';
|
||||
|
||||
const ring = [
|
||||
[-77.04, 38.89],
|
||||
[-77.03, 38.89],
|
||||
[-77.03, 38.9],
|
||||
[-77.04, 38.89],
|
||||
];
|
||||
const validPayload = {
|
||||
features: [
|
||||
{
|
||||
id: 310,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: {
|
||||
NAME: 'P-56',
|
||||
TYPE_CODE: 'P',
|
||||
OBJECTID: 310,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('a successful response yields normalized zone rows', async () => {
|
||||
let requested;
|
||||
const source = createDroneAirspaceSource({
|
||||
fetchImpl: async (url) => {
|
||||
requested = String(url);
|
||||
return Response.json({
|
||||
rows: normalizeDroneAirspaceSnapshot(validPayload),
|
||||
});
|
||||
},
|
||||
});
|
||||
const rows = await source.getSnapshot();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].stableId, '310');
|
||||
assert.equal(requested, '/api/drone-airspace');
|
||||
});
|
||||
|
||||
test('a truncated response pages until the feed is complete', async () => {
|
||||
const pageRing = (id) => ({
|
||||
id,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: { NAME: `P-${id}`, TYPE_CODE: 'P', OBJECTID: id },
|
||||
});
|
||||
const requests = [];
|
||||
let handler;
|
||||
const plugin = droneAirspaceProxy({
|
||||
fetchImpl: async (url) => {
|
||||
requests.push(new URL(String(url)).searchParams.get('resultOffset'));
|
||||
const page = requests.length;
|
||||
return Response.json({
|
||||
features: [pageRing(page)],
|
||||
properties: { exceededTransferLimit: page < 3 },
|
||||
});
|
||||
},
|
||||
});
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use: (_path, callback) => {
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
let payload;
|
||||
await handler(
|
||||
{ url: '/', method: 'GET' },
|
||||
{
|
||||
writeHead(status) {
|
||||
assert.equal(status, 200);
|
||||
},
|
||||
end(body) {
|
||||
payload = JSON.parse(body);
|
||||
},
|
||||
},
|
||||
);
|
||||
const rows = payload.rows;
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.stableId),
|
||||
['1', '2', '3'],
|
||||
);
|
||||
assert.deepEqual(requests, [null, '1', '2']);
|
||||
});
|
||||
|
||||
test('an upstream failure surfaces its HTTP status', async () => {
|
||||
const source = createDroneAirspaceSource({
|
||||
fetchImpl: async () => ({ ok: false, status: 503 }),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /FAA SUA HTTP 503/);
|
||||
});
|
||||
|
||||
test('a malformed successful response is never accepted as an empty snapshot', async () => {
|
||||
for (const payload of [{}, { rows: null }, { rows: {} }]) {
|
||||
const source = createDroneAirspaceSource({
|
||||
fetchImpl: async () => Response.json(payload),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot(),
|
||||
/Malformed drone airspace snapshot/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the snapshot detail carries the proxy staleness and fetch time', async () => {
|
||||
const fetchedAt = Date.UTC(2026, 10, 3, 12);
|
||||
const rows = normalizeDroneAirspaceSnapshot(validPayload);
|
||||
const source = createDroneAirspaceSource({
|
||||
fetchImpl: async () => Response.json({ rows, stale: true, fetchedAt }),
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshotDetail(), {
|
||||
rows,
|
||||
stale: true,
|
||||
fetchedAt,
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshot(), rows);
|
||||
});
|
||||
@@ -0,0 +1,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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
|
||||
import { createEcccAirQualitySource } from '../layers/airQuality/source.js';
|
||||
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
|
||||
import { createDroneAirspaceSource } from '../layers/droneAirspace/source.js';
|
||||
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
|
||||
import { createAdsbGnssSource } from '../layers/gnss/source.js';
|
||||
|
||||
@@ -10,6 +11,7 @@ export function createReferenceSources() {
|
||||
earthquakes: createUsgsEarthquakeSource(),
|
||||
'air-quality': createEcccAirQualitySource(),
|
||||
'fire-perimeters': createWfigsPerimeterSource(),
|
||||
'drone-airspace': createDroneAirspaceSource(),
|
||||
cables: createBundledCableSource(),
|
||||
'gnss-integrity': createAdsbGnssSource(),
|
||||
};
|
||||
|
||||
@@ -16,17 +16,20 @@ test('reference factories retain compatibility without starting acquisition or s
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'fire-perimeters',
|
||||
'drone-airspace',
|
||||
'cables',
|
||||
'gnss-integrity',
|
||||
]);
|
||||
assert.notEqual(first.earthquakes, second.earthquakes);
|
||||
assert.notEqual(first['air-quality'], second['air-quality']);
|
||||
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
|
||||
assert.notEqual(first['drone-airspace'], second['drone-airspace']);
|
||||
assert.notEqual(first.cables, second.cables);
|
||||
assert.notEqual(first['gnss-integrity'], second['gnss-integrity']);
|
||||
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
|
||||
assert.equal(typeof first['air-quality'].getSnapshot, 'function');
|
||||
assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function');
|
||||
assert.equal(typeof first['drone-airspace'].getSnapshot, 'function');
|
||||
assert.equal(typeof first.cables.fetch, 'function');
|
||||
assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function');
|
||||
assert.equal(requests, 0);
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { createGeminiChatHandler } from '../server/providers/gemini/chat.js';
|
||||
import { createOpenAiChatHandler } from '../server/providers/openai/chat.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
|
||||
import {
|
||||
realtimeDirectives,
|
||||
realtimeInstructions,
|
||||
} from '../server/providers/openai/instructions.js';
|
||||
import {
|
||||
TEXT_WORDING,
|
||||
textInstructions,
|
||||
} from '../server/providers/common/textInstructions.js';
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: 'fly_to',
|
||||
description: 'Fly',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { lat: { type: 'number', minimum: -90, maximum: 90 } },
|
||||
required: ['lat'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
const providers = {
|
||||
gemini: {
|
||||
create: createGeminiChatHandler,
|
||||
input: { contents: [{ role: 'user', parts: [{ text: 'Hello' }] }] },
|
||||
reply: (bad = false) => ({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
thoughtSignature: 'opaque',
|
||||
functionCall: {
|
||||
name: 'fly_to',
|
||||
id: 'c',
|
||||
args: { lat: bad ? 100 : 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 7,
|
||||
candidatesTokenCount: 2,
|
||||
totalTokenCount: 10,
|
||||
thoughtsTokenCount: 1,
|
||||
},
|
||||
}),
|
||||
},
|
||||
openai: {
|
||||
create: createOpenAiChatHandler,
|
||||
input: { messages: [{ role: 'user', content: 'Hello' }] },
|
||||
reply: (bad = false) => ({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
type: 'function',
|
||||
id: 'c',
|
||||
function: {
|
||||
name: 'fly_to',
|
||||
arguments: bad ? '{' : '{"lat":1}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 },
|
||||
}),
|
||||
},
|
||||
};
|
||||
async function fixture(t, provider, fetchImpl, overrides = {}) {
|
||||
const calls = [];
|
||||
const handler = provider.create({
|
||||
tools,
|
||||
resolveApiKey: () => 'fixture-only',
|
||||
resolveRateLimit: () => '0',
|
||||
resolveHost: () => '',
|
||||
resolveAllowedHosts: () => '',
|
||||
environment: {},
|
||||
fetchImpl:
|
||||
fetchImpl ||
|
||||
(async (url, options) => {
|
||||
calls.push({ url, body: JSON.parse(options.body), options });
|
||||
return Response.json(provider.reply(true));
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
const server = http.createServer((req, res) => void handler(req, res));
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
t.after(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
return {
|
||||
calls,
|
||||
origin,
|
||||
raw: (options, body) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const request = http.request(origin, options, (response) => {
|
||||
let text = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk) => (text += chunk));
|
||||
response.on('end', () =>
|
||||
resolve({
|
||||
status: response.statusCode,
|
||||
headers: response.headers,
|
||||
body: text ? JSON.parse(text) : null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
request.on('error', reject);
|
||||
if (body === undefined) request.end();
|
||||
else request.end(body);
|
||||
}),
|
||||
send: (input) =>
|
||||
fetch(origin, {
|
||||
method: 'POST',
|
||||
headers: { Origin: origin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input ?? provider.input),
|
||||
}),
|
||||
};
|
||||
}
|
||||
for (const [name, provider] of Object.entries(providers)) {
|
||||
test(`${name} text route validates calls, retains usage and only accepts paired history`, async (t) => {
|
||||
const f = await fixture(t, provider);
|
||||
const response = await f.send();
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.equal(body.invalidCalls[0].index, 0);
|
||||
assert.equal(body.usage.totalTokens, 10);
|
||||
assert.equal(body.usage.outputTokens, 3);
|
||||
const nullCall = provider.reply();
|
||||
if (name === 'gemini')
|
||||
nullCall.candidates[0].content.parts[0].functionCall.args = null;
|
||||
else nullCall.choices[0].message.tool_calls[0].function.arguments = 'null';
|
||||
const nullFixture = await fixture(t, provider, async () =>
|
||||
Response.json(nullCall),
|
||||
);
|
||||
const nullBody = await (await nullFixture.send()).json();
|
||||
assert.equal(nullBody.invalidCalls.length, 1);
|
||||
assert.match(nullBody.invalidCalls[0].error, /must be an object/);
|
||||
|
||||
const input =
|
||||
name === 'gemini'
|
||||
? {
|
||||
contents: [
|
||||
...provider.input.contents,
|
||||
body.content,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'fly_to',
|
||||
id: 'c',
|
||||
response: { ok: false, error: 'Invalid' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
messages: [
|
||||
...provider.input.messages,
|
||||
body.content,
|
||||
{ role: 'tool', tool_call_id: 'c', content: '{"ok":false}' },
|
||||
],
|
||||
};
|
||||
assert.equal((await f.send(input)).status, 200);
|
||||
const orphan =
|
||||
name === 'gemini'
|
||||
? { contents: [...input.contents.slice(0, -1)] }
|
||||
: { messages: input.messages.slice(0, -1) };
|
||||
assert.equal((await f.send(orphan)).status, 400);
|
||||
assert.equal(
|
||||
(await f.send({ ...provider.input, model: 'caller-model' })).status,
|
||||
400,
|
||||
);
|
||||
if (name === 'openai') {
|
||||
assert.equal(
|
||||
f.calls[0].url,
|
||||
'https://api.openai.com/v1/chat/completions',
|
||||
);
|
||||
assert.equal(f.calls[0].body.stream, false);
|
||||
assert.equal(f.calls[0].body.n, 1);
|
||||
assert.equal(f.calls[0].body.model, 'gpt-4.1-mini');
|
||||
assert.equal(f.calls[0].body.messages[0].role, 'system');
|
||||
assert.equal(
|
||||
(await f.send({ messages: [{ role: 'system', content: 'override' }] }))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
}
|
||||
});
|
||||
test(
|
||||
`${name} client disconnect aborts upstream and cancels a late body`,
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
async (t) => {
|
||||
let finish,
|
||||
signal,
|
||||
cancelled = 0,
|
||||
ready;
|
||||
const acquired = new Promise((resolve) => (ready = resolve));
|
||||
// A deadline this far out cannot be what aborts the upstream call.
|
||||
const f = await fixture(
|
||||
t,
|
||||
provider,
|
||||
(_url, options) => {
|
||||
signal = options.signal;
|
||||
ready();
|
||||
return new Promise((resolve) => (finish = resolve));
|
||||
},
|
||||
{ timeoutMs: 1e9 },
|
||||
);
|
||||
const request = http.request(f.origin, {
|
||||
method: 'POST',
|
||||
headers: { Origin: f.origin, 'Content-Type': 'application/json' },
|
||||
});
|
||||
request.on('error', () => {});
|
||||
request.end(JSON.stringify(provider.input));
|
||||
await acquired;
|
||||
const aborted = once(signal, 'abort');
|
||||
request.destroy();
|
||||
await aborted;
|
||||
assert.equal(signal.aborted, true);
|
||||
finish({
|
||||
body: {
|
||||
cancel: async () => {
|
||||
cancelled++;
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.ok(cancelled > 0);
|
||||
},
|
||||
);
|
||||
test(`${name} invalid upstream content still returns reported usage`, async (t) => {
|
||||
const f = await fixture(t, provider, async () =>
|
||||
Response.json({
|
||||
...provider.reply(),
|
||||
...(name === 'gemini' ? { candidates: [] } : { choices: [] }),
|
||||
}),
|
||||
);
|
||||
const response = await f.send();
|
||||
assert.equal(response.status, 502);
|
||||
assert.equal((await response.json()).usage.totalTokens, 10);
|
||||
});
|
||||
test(`${name} route refuses bad methods, media types and bodies before upstream`, async (t) => {
|
||||
const f = await fixture(t, provider, undefined, { timeoutMs: 2000 });
|
||||
const json = { Origin: f.origin, 'Content-Type': 'application/json' };
|
||||
const get = await f.raw({ method: 'GET', headers: { Origin: f.origin } });
|
||||
assert.equal(get.status, 405);
|
||||
assert.equal(get.headers.allow, 'POST');
|
||||
const text = await f.raw(
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Origin: f.origin, 'Content-Type': 'text/plain' },
|
||||
},
|
||||
JSON.stringify(provider.input),
|
||||
);
|
||||
assert.equal(text.status, 415);
|
||||
// The declared length alone refuses; the short body never completes it.
|
||||
const declared = await f.raw(
|
||||
{ method: 'POST', headers: { ...json, 'Content-Length': 600 * 1024 } },
|
||||
'{}',
|
||||
);
|
||||
assert.equal(declared.status, 413);
|
||||
const streamed = await f.raw(
|
||||
{ method: 'POST', headers: { ...json, 'Transfer-Encoding': 'chunked' } },
|
||||
JSON.stringify({ ...provider.input, padding: 'x'.repeat(600 * 1024) }),
|
||||
);
|
||||
assert.equal(streamed.status, 413);
|
||||
assert.equal(f.calls.length, 0);
|
||||
});
|
||||
test(`${name} route names a missing key without calling upstream`, async (t) => {
|
||||
const f = await fixture(t, provider, undefined, {
|
||||
resolveApiKey: () => ' ',
|
||||
});
|
||||
const response = await f.send();
|
||||
assert.equal(response.status, 503);
|
||||
assert.deepEqual(await response.json(), {
|
||||
error: 'Add an API key in Provider Settings',
|
||||
code: 'API_KEY_MISSING',
|
||||
});
|
||||
assert.equal(f.calls.length, 0);
|
||||
});
|
||||
test(
|
||||
`${name} upstream deadline answers 504 and aborts the call`,
|
||||
{
|
||||
timeout: 5000,
|
||||
},
|
||||
async (t) => {
|
||||
let signal;
|
||||
const f = await fixture(
|
||||
t,
|
||||
provider,
|
||||
(_url, options) => {
|
||||
signal = options.signal;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
{ timeoutMs: 50 },
|
||||
);
|
||||
const response = await f.send();
|
||||
assert.equal(response.status, 504);
|
||||
assert.equal(signal.aborted, true);
|
||||
},
|
||||
);
|
||||
test(`${name} route refuses redirects and unexpected destinations`, async (t) => {
|
||||
const f = await fixture(t, provider);
|
||||
assert.equal((await f.send()).status, 200);
|
||||
assert.equal(f.calls[0].options.redirect, 'error');
|
||||
const moved = await fixture(t, provider, async () => {
|
||||
const response = Response.json(provider.reply());
|
||||
Object.defineProperty(response, 'url', {
|
||||
value: 'https://example.test/',
|
||||
});
|
||||
return response;
|
||||
});
|
||||
assert.equal((await moved.send()).status, 502);
|
||||
const redirected = await fixture(t, provider, async () => {
|
||||
const response = Response.json(provider.reply());
|
||||
Object.defineProperty(response, 'redirected', { value: true });
|
||||
return response;
|
||||
});
|
||||
assert.equal((await redirected.send()).status, 502);
|
||||
});
|
||||
test(`${name} route caps the upstream response and never echoes the key`, async (t) => {
|
||||
const large = await fixture(t, provider, async () =>
|
||||
Response.json({ ...provider.reply(), padding: 'x'.repeat(600 * 1024) }),
|
||||
);
|
||||
assert.equal((await large.send()).status, 502);
|
||||
const echo = provider.reply();
|
||||
if (name === 'gemini')
|
||||
echo.candidates[0].content.parts = [{ text: 'key fixture-only' }];
|
||||
else
|
||||
echo.choices[0].message = {
|
||||
role: 'assistant',
|
||||
content: 'key fixture-only',
|
||||
};
|
||||
const echoed = await fixture(t, provider, async () => Response.json(echo));
|
||||
const response = await echoed.send();
|
||||
assert.equal(response.status, 502);
|
||||
assert.ok(!(await response.text()).includes('fixture-only'));
|
||||
});
|
||||
test(`${name} upstream 429 and 503 keep their status and a sane Retry-After`, async (t) => {
|
||||
for (const [status, retryAfter, expected] of [
|
||||
[429, '7', '7'],
|
||||
[503, '7200', '3600'],
|
||||
[429, 'Wed, 21 Oct 2015 07:28:00 GMT', undefined],
|
||||
[500, '7', undefined],
|
||||
]) {
|
||||
const f = await fixture(t, provider, async () =>
|
||||
Response.json({}, { status, headers: { 'Retry-After': retryAfter } }),
|
||||
);
|
||||
const response = await f.send();
|
||||
assert.equal(response.status, status === 500 ? 502 : status);
|
||||
assert.equal(response.headers.get('retry-after') ?? undefined, expected);
|
||||
assert.deepEqual(await response.json(), {
|
||||
error: 'Could not connect. Try again.',
|
||||
...(status === 429 ? { code: 'RATE_LIMITED' } : {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
test(`${name} rejected key cools down until it changes or the window ends`, async (t) => {
|
||||
for (const status of [401, 403]) {
|
||||
let clock = 1_000_000;
|
||||
let key = 'fixture-only';
|
||||
let upstream = 0;
|
||||
const f = await fixture(
|
||||
t,
|
||||
provider,
|
||||
async () => {
|
||||
upstream++;
|
||||
return Response.json({}, { status });
|
||||
},
|
||||
{ now: () => clock, resolveApiKey: () => key },
|
||||
);
|
||||
assert.equal((await f.send()).status, 502);
|
||||
const cooling = await f.send();
|
||||
assert.equal(cooling.status, 502);
|
||||
assert.equal(cooling.headers.get('retry-after'), '60');
|
||||
assert.equal(upstream, 1, 'a rejected key must not reach upstream again');
|
||||
key = 'replacement-key';
|
||||
assert.equal((await f.send()).status, 502);
|
||||
assert.equal(upstream, 2, 'a new key is tried at once');
|
||||
clock += 59_000;
|
||||
assert.equal((await f.send()).status, 502);
|
||||
assert.equal(upstream, 2);
|
||||
clock += 1_000;
|
||||
assert.equal((await f.send()).status, 502);
|
||||
assert.equal(upstream, 3, 'the cooldown ends after a minute');
|
||||
}
|
||||
});
|
||||
test(`${name} an invalid-key 400 cools down like 401, and every refusal names the key`, async (t) => {
|
||||
let upstream = 0;
|
||||
let body = {
|
||||
error: {
|
||||
code: 400,
|
||||
status: 'INVALID_ARGUMENT',
|
||||
details: [{ reason: 'API_KEY_INVALID' }],
|
||||
},
|
||||
};
|
||||
const f = await fixture(t, provider, async () => {
|
||||
upstream++;
|
||||
return Response.json(body, { status: 400 });
|
||||
});
|
||||
const first = await f.send();
|
||||
assert.equal(first.status, 502);
|
||||
assert.equal((await first.json()).code, 'API_KEY_REJECTED');
|
||||
const cooling = await f.send();
|
||||
assert.equal((await cooling.json()).code, 'API_KEY_REJECTED');
|
||||
assert.equal(upstream, 1, 'the rejected key is not sent again');
|
||||
// Any other 400 is a plain failure and does not cool the key down.
|
||||
body = { error: { code: 400, status: 'INVALID_ARGUMENT' } };
|
||||
const plain = await fixture(t, provider, async () => {
|
||||
upstream++;
|
||||
return Response.json(body, { status: 400 });
|
||||
});
|
||||
const other = await plain.send();
|
||||
assert.equal(other.status, 502);
|
||||
assert.equal((await other.json()).code, undefined);
|
||||
await plain.send();
|
||||
assert.equal(upstream, 3);
|
||||
});
|
||||
test(`${name} a 403 or an out-of-credit 429 cools down with its own code`, async (t) => {
|
||||
for (const [status, body, code] of [
|
||||
[
|
||||
403,
|
||||
{ error: { code: 'unsupported_country_region_territory' } },
|
||||
'ACCESS_DENIED',
|
||||
],
|
||||
[429, { error: { code: 'insufficient_quota' } }, 'QUOTA_EXCEEDED'],
|
||||
]) {
|
||||
let upstream = 0;
|
||||
const f = await fixture(t, provider, async () => {
|
||||
upstream++;
|
||||
return Response.json(body, { status });
|
||||
});
|
||||
const first = await f.send();
|
||||
assert.equal(first.status, 502);
|
||||
assert.equal((await first.json()).code, code);
|
||||
const cooling = await f.send();
|
||||
assert.equal((await cooling.json()).code, code);
|
||||
assert.equal(upstream, 1, `${code} is not retried at once`);
|
||||
}
|
||||
});
|
||||
test(`${name} only an exact invalid-key reason marks a 400 as a rejected key`, async (t) => {
|
||||
let upstream = 0;
|
||||
const f = await fixture(t, provider, async () => {
|
||||
upstream++;
|
||||
return Response.json(
|
||||
{ error: { code: 400, message: 'Field API_KEY_INVALID is unknown' } },
|
||||
{ status: 400 },
|
||||
);
|
||||
});
|
||||
const reply = await f.send();
|
||||
assert.equal(reply.status, 502);
|
||||
assert.equal((await reply.json()).code, undefined);
|
||||
await f.send();
|
||||
assert.equal(upstream, 2);
|
||||
});
|
||||
test(`${name} refusal bodies are read within 16 KB and released when unread`, async (t) => {
|
||||
// An oversized error body is not parsed, so its reason does not count.
|
||||
const padded = await fixture(t, provider, async () =>
|
||||
Response.json(
|
||||
{
|
||||
error: {
|
||||
message: 'x'.repeat(20 * 1024),
|
||||
details: [{ reason: 'API_KEY_INVALID' }],
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
);
|
||||
const reply = await padded.send();
|
||||
assert.equal(reply.status, 502);
|
||||
assert.equal((await reply.json()).code, undefined);
|
||||
// A 401 body is never read, and is cancelled rather than left open.
|
||||
let cancelled = false;
|
||||
const unread = await fixture(
|
||||
t,
|
||||
provider,
|
||||
async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull: () => {},
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{ status: 401 },
|
||||
),
|
||||
);
|
||||
assert.equal((await (await unread.send()).json()).code, 'API_KEY_REJECTED');
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
test(`${name} offers osm_query, and names it, only with Overpass, as voice does`, async (t) => {
|
||||
for (const overpass of [false, true]) {
|
||||
const f = await fixture(t, provider, undefined, {
|
||||
tools: GEV_REALTIME_TOOLS,
|
||||
overpassConfigured: () => overpass,
|
||||
});
|
||||
assert.equal((await f.send()).status, 200);
|
||||
const { body } = f.calls[0];
|
||||
const offered =
|
||||
name === 'openai'
|
||||
? body.tools.map((tool) => tool.function.name)
|
||||
: body.tools[0].functionDeclarations.map((tool) => tool.name);
|
||||
const prompt =
|
||||
name === 'openai'
|
||||
? body.messages[0].content
|
||||
: body.systemInstruction.parts[0].text;
|
||||
assert.equal(offered.includes('osm_query'), overpass);
|
||||
assert.equal(prompt.includes('osm_query'), overpass);
|
||||
assert.ok(offered.includes('resolve_area'));
|
||||
}
|
||||
});
|
||||
test(`${name} throttled replies say they are rate limited`, async (t) => {
|
||||
const local = await fixture(t, provider, undefined, {
|
||||
resolveRateLimit: () => '1',
|
||||
});
|
||||
await local.send();
|
||||
const limited = await local.send();
|
||||
assert.equal(limited.status, 429);
|
||||
assert.equal((await limited.json()).code, 'RATE_LIMITED');
|
||||
const upstream = await fixture(t, provider, async () =>
|
||||
Response.json({}, { status: 429, headers: { 'Retry-After': '7' } }),
|
||||
);
|
||||
const busy = await upstream.send();
|
||||
assert.equal(busy.status, 429);
|
||||
assert.equal(busy.headers.get('retry-after'), '7');
|
||||
assert.equal((await busy.json()).code, 'RATE_LIMITED');
|
||||
});
|
||||
test(`${name} chat throttle follows the provider throttle unless set`, async (t) => {
|
||||
const burst = async (overrides, count) => {
|
||||
const f = await fixture(t, provider, undefined, overrides);
|
||||
const statuses = [];
|
||||
for (let i = 0; i < count; i++) statuses.push((await f.send()).status);
|
||||
return { statuses, calls: f.calls.length, last: f };
|
||||
};
|
||||
const unset = await burst(
|
||||
{
|
||||
resolveRateLimit: () => undefined,
|
||||
resolveProviderRateLimit: () => undefined,
|
||||
},
|
||||
31,
|
||||
);
|
||||
assert.equal(
|
||||
unset.calls,
|
||||
30,
|
||||
'the default cap is the provider default, 30',
|
||||
);
|
||||
assert.equal(unset.statuses.at(-1), 429);
|
||||
const follows = await burst(
|
||||
{ resolveRateLimit: () => '', resolveProviderRateLimit: () => '2' },
|
||||
3,
|
||||
);
|
||||
assert.deepEqual(follows.statuses, [200, 200, 429]);
|
||||
const own = await burst(
|
||||
{ resolveRateLimit: () => '3', resolveProviderRateLimit: () => '2' },
|
||||
4,
|
||||
);
|
||||
assert.deepEqual(own.statuses, [200, 200, 200, 429]);
|
||||
const off = await burst(
|
||||
{ resolveRateLimit: () => '0', resolveProviderRateLimit: () => '2' },
|
||||
4,
|
||||
);
|
||||
assert.deepEqual(off.statuses, [200, 200, 200, 200]);
|
||||
const capped = await fixture(t, provider, undefined, {
|
||||
resolveRateLimit: () => '1',
|
||||
});
|
||||
assert.equal((await capped.send()).status, 200);
|
||||
const refused = await capped.send();
|
||||
assert.equal(refused.status, 429);
|
||||
assert.equal(refused.headers.get('retry-after'), '60');
|
||||
assert.deepEqual(await refused.json(), {
|
||||
error: 'Could not connect. Try again.',
|
||||
code: 'RATE_LIMITED',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('text adapts speech wording while retaining shared Radio and annotation policies', () => {
|
||||
const directives = realtimeDirectives();
|
||||
assert.equal(realtimeInstructions(), directives.join('\n'));
|
||||
const text = textInstructions();
|
||||
assert.match(text, /text-only chat/);
|
||||
assert.ok(!text.includes('mic session is active'));
|
||||
for (const line of directives.filter(
|
||||
(line) =>
|
||||
(line.includes('radio') && !line.startsWith('PREAMBLES:')) ||
|
||||
line.startsWith('annotate_map results:'),
|
||||
))
|
||||
assert.ok(text.includes(line));
|
||||
});
|
||||
|
||||
test('every spoken phrase chat rewrites still occurs in the voice directives', () => {
|
||||
const voice = realtimeDirectives().join('\n');
|
||||
const text = textInstructions();
|
||||
for (const [spoken, written] of TEXT_WORDING) {
|
||||
assert.ok(voice.includes(spoken), `voice directives lost "${spoken}"`);
|
||||
assert.ok(!text.includes(spoken), `chat still says "${spoken}"`);
|
||||
assert.ok(text.includes(written), `chat lacks "${written}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('chat throttles read their own variables and fall back per provider', async (t) => {
|
||||
const names = [
|
||||
'GEV_RATELIMIT_OPENAI_CHAT_PER_MIN',
|
||||
'GEV_RATELIMIT_OPENAI_PER_MIN',
|
||||
'GEV_RATELIMIT_GEMINI_CHAT_PER_MIN',
|
||||
'GEV_RATELIMIT_GEMINI_PER_MIN',
|
||||
];
|
||||
const saved = Object.fromEntries(names.map((key) => [key, process.env[key]]));
|
||||
t.after(() => {
|
||||
for (const [key, value] of Object.entries(saved))
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
});
|
||||
const allowed = async (provider, count) => {
|
||||
const f = await fixture(t, provider, undefined, {
|
||||
resolveRateLimit: undefined,
|
||||
});
|
||||
let ok = 0;
|
||||
for (let i = 0; i < count; i++) if ((await f.send()).status === 200) ok++;
|
||||
return ok;
|
||||
};
|
||||
for (const key of names) delete process.env[key];
|
||||
process.env.GEV_RATELIMIT_OPENAI_PER_MIN = '2';
|
||||
process.env.GEV_RATELIMIT_GEMINI_PER_MIN = '3';
|
||||
assert.equal(await allowed(providers.openai, 5), 2);
|
||||
assert.equal(await allowed(providers.gemini, 5), 3);
|
||||
process.env.GEV_RATELIMIT_OPENAI_CHAT_PER_MIN = '4';
|
||||
process.env.GEV_RATELIMIT_GEMINI_CHAT_PER_MIN = '0';
|
||||
assert.equal(await allowed(providers.openai, 6), 4);
|
||||
assert.equal(await allowed(providers.gemini, 6), 6);
|
||||
});
|
||||
|
||||
test('the ChatGPT OAuth lock refuses OpenAI chat but not Gemini chat', async (t) => {
|
||||
for (const [value, code] of [
|
||||
['true', 'CODEX_OAUTH_REQUIRED'],
|
||||
['maybe', 'CODEX_OAUTH_REQUIRED'],
|
||||
['false', undefined],
|
||||
]) {
|
||||
const environment = { GEV_PREFER_CODEX_OAUTH: value };
|
||||
const openai = await fixture(t, providers.openai, undefined, {
|
||||
environment,
|
||||
});
|
||||
const response = await openai.send();
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, code ? 503 : 200);
|
||||
assert.equal(body.code, code);
|
||||
if (code) {
|
||||
assert.equal(typeof body.error, 'string');
|
||||
assert.equal(openai.calls.length, 0, 'the API key must not be billed');
|
||||
}
|
||||
const gemini = await fixture(t, providers.gemini, undefined, {
|
||||
environment,
|
||||
});
|
||||
assert.equal((await gemini.send()).status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenAI empty tool arrays are removed before history is returned', async (t) => {
|
||||
const f = await fixture(t, providers.openai, async () =>
|
||||
Response.json({
|
||||
choices: [
|
||||
{
|
||||
message: { role: 'assistant', content: 'Hello', tool_calls: [] },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
);
|
||||
const body = await (await f.send()).json();
|
||||
assert.equal(body.content.tool_calls, undefined);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user