Merge upstream main into pr/street-level-show-action

Re-pin the Realtime payload digests for main's control_cockpit wording.
This commit is contained in:
Said Turksever committed 2026-11-06 00:23:42 +00:00
commit 14f7326dbf
118 files changed
+5479 -353

No files matched your search

+19 -6
View File
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
ui.indexOf("if (normalized === 'exit') {"),
);
assert.ok(branch, 'cockpit enter branch is missing');
const gateIndex = branch.indexOf('isEntryAllowed?.()');
const gateIndex = branch.indexOf('this._cockpitEntryRefusal()');
const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
assert.ok(
gateIndex >= 0,
'entry consults the same gate the manual chip uses',
'entry refuses on the gate it can check before tracking',
);
assert.ok(
retargetIndex > gateIndex,
@@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
ui.indexOf("if (normalized === 'enter') {"),
ui.indexOf("if (normalized === 'exit') {"),
);
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/);
assert.match(branch, /Contacts must be active to enter Cockpit/);
assert.match(branch, /Contacts is still starting up/);
// Contacts is not required: only a Context change in flight or no aircraft
// layer to ride shuts the gate before tracking, and the entry itself then
// applies the manual chip's gate to the aircraft it rides.
assert.match(
branch,
/const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/,
);
assert.doesNotMatch(branch, /Contacts must be active/);
const refusal = ui.slice(
ui.indexOf(' _cockpitEntryRefusal() {'),
ui.indexOf(' getCockpitState() {'),
);
assert.match(refusal, /Context is still changing/);
assert.match(refusal, /No aircraft layer is on/);
assert.doesNotMatch(refusal, /[Cc]ontacts/);
});
test('cockpit state cannot report entryAllowed while already active', () => {
@@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => {
);
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
assert.match(state, /'contacts-starting'/);
assert.match(state, /'contacts-inactive'/);
assert.match(state, /'subject-layer-off'/);
assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required');
assert.match(state, /'no-tracked-aircraft'/);
});
+34
View File
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
],
});
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
const f = fixture(t);
const crowded = {
...snapshot(),
cohorts: [
{
id: 'flights',
count: 6,
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
id: `n${n}`,
label: `N${n}`,
distanceM: n * 1000,
})),
},
],
};
updateCockpitSignals.call(f.owner, crowded, 0, {
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
});
await nextTick();
assert.equal(f.owner.signalItems.length, 5);
assert.equal(f.owner.signalItems[0].key, 'feed-status');
assert.equal(f.owner.signalItems[0].tone, 'warning');
assert.equal(f.owner.signalItems[0].target, null);
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
updateCockpitSignals.call(f.owner, crowded, 0);
assert.equal(
f.owner.signalItems.some((item) => item.key === 'feed-status'),
false,
'with the feed back, the row goes',
);
});
test('rows render the signals the controller presents', async (t) => {
const f = fixture(t);
await render(f, [
+23 -16
View File
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
return restored;
}
/** The layers an aircraft Cockpit subject can come from. */
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
'flights',
'military',
]);
/**
* Cockpit entry belongs to the operational Contacts context bundle. A tracked
* aircraft alone is not sufficient: both observed-flight feeds must still be
* active so the cockpit's surrounding-contact picture is not presented as a
* complete context view when one source is disabled.
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
* (Live Flights for a civilian, Military Flights for a military aircraft) must
* be on. Contacts and the other feeds are not required. When a source the
* surrounding picture uses is off, the Cockpit's Contact card and Live
* Signals say the picture is partial instead of blocking entry, so a partial
* picture is never presented as complete. A Context mode change in flight
* still owns the camera, so entry waits it out.
*
* @param {object} input Current context and dependency visibility.
* @param {string|null} input.contextMode Active Context mode.
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled.
* @param {object} input Current subject and Context state.
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
* ('flights' or 'military'), or null with none tracked.
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
* @returns {boolean} Whether cockpit entry may be offered or activated.
*/
export function cockpitEntryAllowed({
contextMode,
subjectLayerId,
subjectLayerEnabled,
contextModeChanging,
flightsEnabled,
militaryEnabled,
}) {
return (
contextMode === 'flights' &&
!contextModeChanging &&
Boolean(flightsEnabled) &&
Boolean(militaryEnabled)
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
Boolean(subjectLayerEnabled) &&
!contextModeChanging
);
}
+18 -23
View File
@@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued
}
});
test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => {
test("cockpit entry needs only the tracked aircraft's own layer", () => {
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: true,
}), true);
}), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: 'military',
subjectLayerEnabled: true,
contextModeChanging: false,
}), true, 'a military aircraft needs Military Flights alone');
assert.equal(cockpitEntryAllowed({
subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: true,
flightsEnabled: true,
militaryEnabled: true,
}), false, 'Cockpit remains unavailable until Contacts activation settles');
}), false, 'Cockpit remains unavailable until a Context mode change settles');
assert.equal(cockpitEntryAllowed({
contextMode: null,
subjectLayerId: 'military',
subjectLayerEnabled: false,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: true,
}), false, 'ordinary layer selection is not an operational Contacts context');
}), false, "the ridden aircraft's own layer is required");
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: null,
subjectLayerEnabled: false,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: false,
}), false, 'the military contact feed is required');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
contextModeChanging: false,
flightsEnabled: false,
militaryEnabled: true,
}), false, 'the civilian contact feed is required');
}), false, 'there is no aircraft to ride');
});
test('context teardown guard restores coordination state after success and failure', async () => {
+22 -6
View File
@@ -45,7 +45,7 @@ test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () =
assert.equal(card.details.length, 1);
assert.equal(card.details[0], 'high · 2h · N20');
assert.equal(card.selected, false);
assert.equal(card.accent, accentForSeverity('red'), 'FRP 1520 is red-hot');
assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot');
assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched');
});
@@ -69,11 +69,27 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
assert.equal(selected.title, 'FIRE · 10 MW');
});
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => {
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => {
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
assert.match(card.details[0], /SNPP$/);
assert.equal(card.title, '▲ 0.8 MW');
assert.notEqual(card.accent, accentForSeverity('red'));
assert.equal(card.accent, accentForSeverity('ember'));
});
test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => {
const accentAt = (frp) =>
buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent;
assert.equal(accentAt(4.9), accentForSeverity('ember'));
assert.equal(accentAt(5), accentForSeverity('red'));
assert.equal(accentAt(20), accentForSeverity('flame'));
assert.equal(accentAt(50), accentForSeverity('orange'));
assert.equal(accentAt(100), accentForSeverity('gold'));
assert.equal(accentAt(300), accentForSeverity('white-hot'));
// Confidence never moves the color: a low-confidence 864 MW fire stays red.
assert.equal(
buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent,
accentForSeverity('white-hot'),
);
});
test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => {
@@ -93,12 +109,12 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
const candidate = {
cell: { count: 14, maxFrp: 210.4, newestAcqMs: NOW - 3 * H, night: 2 },
position: { x: 0, y: 0, z: 0 },
accent: accentForSeverity('orange'),
accent: accentForSeverity('gold'),
};
const card = buildCellCard(candidate, NOW);
assert.equal(card.title, '14 FIRES');
assert.equal(card.details[0], 'max 210 MW · new 3h');
assert.equal(card.accent, accentForSeverity('orange'));
assert.equal(card.accent, accentForSeverity('gold'));
});
test('buildCellCard: the count picks the plural form of the language', () => {
@@ -150,7 +166,7 @@ test('buildCellCard: singular noun and missing-age omission', () => {
const card = buildCellCard(candidate, NOW);
assert.equal(card.title, '1 FIRE');
assert.equal(card.details[0], 'max 9.9 MW');
assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow');
assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop');
});
test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => {
+209
View File
@@ -0,0 +1,209 @@
/**
* Snap Map–style heat raster for the Active Fires far-zoom bands: detections
* are linearly binned on an equirectangular grid (row 0 = north, column
* 0 = 180°W), blurred, normalized on a log scale and mapped through
* HEAT_PALETTE. Pure: no Cesium, no DOM, so the whole pipeline is unit-tested
* in node.
*/
/** Below this normalized heat nothing is painted: empty land stays empty. */
export const HEAT_FLOOR = 0.03;
/**
* The FRP marker ramp as a continuous glow, from a red edge through orange
* and gold to a pale white-hot core. The edge is a bright red, not the
* markers' dark ember, and starts more than half opaque: most of the world
* sits low on a scale set by the hottest regions, and a dark translucent red
* vanishes into forest and savanna imagery.
*/
export const HEAT_PALETTE = Object.freeze([
{ at: 0.0, rgb: [232, 58, 30], alpha: 0 },
{ at: 0.08, rgb: [232, 58, 30], alpha: 0.58 },
{ at: 0.25, rgb: [242, 86, 34], alpha: 0.7 },
{ at: 0.45, rgb: [248, 122, 40], alpha: 0.76 },
{ at: 0.65, rgb: [252, 166, 52], alpha: 0.82 },
{ at: 0.82, rgb: [254, 208, 92], alpha: 0.87 },
{ at: 1.0, rgb: [255, 245, 190], alpha: 0.92 },
]);
export function heatColorAt(t) {
if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0];
const v = Math.min(1, t);
let i = 1;
while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1;
const a = HEAT_PALETTE[i - 1];
const b = HEAT_PALETTE[i];
const k = (v - a.at) / (b.at - a.at || 1);
const mix = (p, q) => Math.round(p + (q - p) * k);
return [
mix(a.rgb[0], b.rgb[0]),
mix(a.rgb[1], b.rgb[1]),
mix(a.rgb[2], b.rgb[2]),
Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255),
];
}
/** Weight of a detection with no or zero FRP, in megawatts. */
export const HEAT_MIN_WEIGHT_MW = 0.5;
/**
* Heat is radiated power, not a head count: each detection adds its FRP in
* megawatts. Weak fires carry little power, so raising the minimum-FRP slider
* removes most detections without dimming the areas where strong fires burn
* (a count-based heat collapsed there and the hot spots faded).
*/
const defaultWeight = (fire) =>
Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0);
/**
* Linear binning (Wand 1994): each detection's weight is shared between the
* four pixel centres around it in proportion to closeness, instead of
* dropping whole into one pixel. Spots then sit at the true position rather
* than snapping by up to half a pixel (~10 km), and a lone weak fire blurs
* into a round spot instead of a square or a plus sign. Longitude wraps;
* rows beyond the poles fold back onto the edge row.
*/
export function binFires(fires, { width, height, weight = defaultWeight }) {
const grid = new Float32Array(width * height);
const row = (y) => Math.min(height - 1, Math.max(0, y)) * width;
const col = (x) => ((x % width) + width) % width;
for (const fire of fires) {
if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue;
const fx = ((fire.lon + 180) / 360) * width - 0.5;
const fy = ((90 - fire.lat) / 180) * height - 0.5;
const x0 = Math.floor(fx);
const y0 = Math.floor(fy);
const tx = fx - x0;
const ty = fy - y0;
const w = weight(fire);
const top = row(y0);
const bottom = row(y0 + 1);
const left = col(x0);
const right = col(x0 + 1);
grid[top + left] += w * (1 - tx) * (1 - ty);
grid[top + right] += w * tx * (1 - ty);
grid[bottom + left] += w * (1 - tx) * ty;
grid[bottom + right] += w * tx * ty;
}
return grid;
}
function kernel(sigma) {
const radius = Math.max(1, Math.ceil(sigma * 3));
const k = new Float32Array(radius * 2 + 1);
let sum = 0;
for (let i = -radius; i <= radius; i++) {
k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma));
sum += k[i + radius];
}
for (let i = 0; i < k.length; i++) k[i] /= sum;
return { k, radius };
}
export function blurGaussian(grid, width, height, sigma) {
const { k, radius } = kernel(sigma);
const tmp = new Float32Array(grid.length);
const out = new Float32Array(grid.length);
for (let y = 0; y < height; y++) {
const row = y * width;
for (let x = 0; x < width; x++) {
const v = grid[row + x];
if (v === 0) continue;
for (let i = -radius; i <= radius; i++) {
const xx = (((x + i) % width) + width) % width; // longitude wraps
tmp[row + xx] += v * k[i + radius];
}
}
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const v = tmp[y * width + x];
if (v === 0) continue;
for (let i = -radius; i <= radius; i++) {
const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp
out[yy * width + x] += v * k[i + radius];
}
}
}
return out;
}
/** Bins of the log-density histogram used to find the reference quantile. */
const QUANTILE_BINS = 4096;
/**
* The p-quantile of the non-zero values, from a histogram of log1p(value):
* one linear pass instead of sorting ~350k burning pixels (a sort cost
* 200 ms+ and dominated the build). Bin width on the log axis is
* max/4096, far finer than the 256-step palette it feeds.
*/
function percentileOfNonZero(values, p) {
let count = 0;
let max = 0;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v > 0) {
count += 1;
if (v > max) max = v;
}
}
if (!count) return 0;
const logMax = Math.log1p(max);
const bins = new Uint32Array(QUANTILE_BINS);
const toBin = (QUANTILE_BINS - 1) / logMax;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1;
}
const target = Math.floor(p * (count - 1));
let seen = 0;
for (let b = 0; b < QUANTILE_BINS; b++) {
seen += bins[b];
if (seen > target) return Math.expm1((b + 1) / toBin);
}
return max;
}
/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */
const LUT_STEPS = 256;
let paletteLut = null;
function heatLut() {
if (!paletteLut) {
paletteLut = new Uint8ClampedArray(LUT_STEPS * 4);
for (let i = 0; i < LUT_STEPS; i++) {
paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4);
}
}
return paletteLut;
}
export function buildHeatRaster(
fires,
{ width, height, sigmaPx, percentile = 0.995 },
) {
const density = blurGaussian(
binFires(fires, { width, height }),
width,
height,
sigmaPx,
);
const reference = percentileOfNonZero(density, percentile);
const rgba = new Uint8ClampedArray(width * height * 4);
if (reference > 0) {
const lut = heatLut();
const scale = (LUT_STEPS - 1) / Math.log1p(reference);
for (let i = 0; i < density.length; i++) {
const d = density[i];
if (d <= 0) continue;
const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale));
const l = step * 4;
if (lut[l + 3] === 0) continue;
const o = i * 4;
rgba[o] = lut[l];
rgba[o + 1] = lut[l + 1];
rgba[o + 2] = lut[l + 2];
rgba[o + 3] = lut[l + 3];
}
}
return { width, height, rgba, reference };
}
+118
View File
@@ -0,0 +1,118 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
HEAT_MIN_WEIGHT_MW,
HEAT_PALETTE,
heatColorAt,
binFires,
blurGaussian,
buildHeatRaster,
} from './firmsHeatRaster.js';
const fire = (lat, lon, frp = 10) => ({ lat, lon, frp });
test('palette runs ember to white-hot and starts fully transparent', () => {
assert.equal(HEAT_PALETTE[0].at, 0);
assert.equal(HEAT_PALETTE.at(-1).at, 1);
assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]);
assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted');
const low = heatColorAt(0.15);
const high = heatColorAt(1);
assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is red');
assert.ok(low[3] >= 0.5 * 255, 'low heat stays visible on any imagery');
assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler');
assert.ok(high[3] > low[3], 'hotter is more opaque');
for (let i = 1; i <= 100; i++) {
const [r, , b] = heatColorAt(i / 100);
assert.ok(r >= b, `no cold hue at ${i}%`);
}
});
test('binFires puts a detection at a pixel centre wholly in that pixel', () => {
// 4×2 grid: pixel centres at lon −135/−45/45/135, lat ±45.
const grid = binFires([fire(45, -135, 1), fire(-45, 135, 1)], {
width: 4,
height: 2,
});
assert.equal(grid[0], 1, 'north-west pixel');
assert.equal(grid[7], 1, 'south-east pixel');
assert.equal(
grid.reduce((a, b) => a + b, 0),
2,
);
});
test('binFires shares a detection between the pixels around it', () => {
const between = binFires([fire(45, 0, 1)], { width: 4, height: 2 });
assert.equal(between[1], 0.5, 'half to the pixel west of it');
assert.equal(between[2], 0.5, 'half to the pixel east of it');
const quarter = binFires([fire(45, -112.5, 1)], { width: 4, height: 2 });
assert.equal(quarter[0], 0.75, 'closer centre takes more');
assert.equal(quarter[1], 0.25);
const wrapped = binFires([fire(45, 180, 1)], { width: 4, height: 2 });
assert.equal(wrapped[3], 0.5, 'the antimeridian wraps');
assert.equal(wrapped[0], 0.5);
const polar = binFires([fire(90, -135, 1)], { width: 4, height: 2 });
assert.equal(polar[0], 1, 'beyond the last row folds back onto it');
});
test('binFires weights each detection by its radiative power', () => {
const grid = binFires(
[fire(45, 45, 250), fire(45, 45, 0), fire(45, 45, Number.NaN)],
{ width: 4, height: 2 },
);
assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW);
});
test('filtering out weak fires keeps a strong fire hot', () => {
const strong = fire(0.1, 0.1, 300);
const weak = Array.from({ length: 40 }, () => fire(0.1, 0.1, 2));
const reference = Array.from({ length: 10 }, () => fire(20.1, 20.1, 100));
const options = { width: 360, height: 180, sigmaPx: 2 };
const pixel = ((90 - 0.1) | 0) * 360 + ((0.1 + 180) | 0);
const red = (r) => r.rgba[pixel * 4];
const alpha = (r) => r.rgba[pixel * 4 + 3];
const unfiltered = buildHeatRaster([strong, ...weak, ...reference], options);
const filtered = buildHeatRaster([strong, ...reference], options);
assert.ok(alpha(filtered) > 0);
assert.ok(
alpha(filtered) >= alpha(unfiltered) * 0.9,
`alpha ${alpha(unfiltered)} -> ${alpha(filtered)}`,
);
assert.ok(
red(filtered) >= red(unfiltered) * 0.9,
`red ${red(unfiltered)} -> ${red(filtered)}`,
);
});
test('blurGaussian keeps mass and wraps across the antimeridian', () => {
const w = 16;
const h = 8;
const grid = new Float32Array(w * h);
grid[3 * w + 0] = 1;
const out = blurGaussian(grid, w, h, 1.5);
const sum = out.reduce((a, b) => a + b, 0);
assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`);
assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge');
});
test('buildHeatRaster is transparent where there are no fires', () => {
const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], {
width: 360,
height: 180,
sigmaPx: 2,
});
assert.equal(r.rgba.length, 360 * 180 * 4);
const alphaAt = (lat, lon) => {
const x = Math.floor(((lon + 180) / 360) * 360);
const y = Math.floor(((90 - lat) / 180) * 180);
return r.rgba[(y * 360 + x) * 4 + 3];
};
assert.ok(alphaAt(40, -120) > 0, 'blob over the fires');
assert.equal(alphaAt(-30, 60), 0, 'empty far away');
});
test('buildHeatRaster with no fires is fully transparent', () => {
const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 });
assert.ok(r.rgba.every((v) => v === 0));
});
+212
View File
@@ -0,0 +1,212 @@
/**
* @module firmsIntensity
* @description Fire intensity policy shared by the Active Fires layer and the
* agent fire tool: the six-stop ember → white-hot FRP color ramp, the log-scale
* slider that doubles as its legend, and the one filter every consumer
* (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs
* before anything is rendered or selected. Pure — no Cesium, no DOM.
*
* The ramp (`FRP_RAMP`, stepped, not interpolated) runs like a fire cooling
* down: a dim ember for weak fires through red, orange and gold to a pale
* white-hot core, lightness rising with power, so the brightest marks are the
* strongest fires. No cold hues: ~90% of detections are under 20 MW and a
* blue start painted most of the map blue. The thresholds are GEV's own, set
* on a real week's percentiles so every color appears: Portolan's
* 0/10/50/200/500/1000 MW leaves one hottest fire a week (HAL-675).
*/
/**
* Stepped FRP ramp, weakest first. A detection takes the LAST stop whose
* `minFrp` it reaches. Names key the glow-sprite cache and the card accents.
*/
export const FRP_RAMP = Object.freeze([
Object.freeze({ name: 'ember', minFrp: 0, hex: '#9e2a1b' }),
Object.freeze({ name: 'red', minFrp: 5, hex: '#d2401f' }),
Object.freeze({ name: 'flame', minFrp: 20, hex: '#f06a28' }),
Object.freeze({ name: 'orange', minFrp: 50, hex: '#fb9a2e' }),
Object.freeze({ name: 'gold', minFrp: 100, hex: '#fdc84a' }),
Object.freeze({ name: 'white-hot', minFrp: 300, hex: '#fff3b0' }),
]);
/** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */
export const FRP_SLIDER_TICKS = Object.freeze(
FRP_RAMP.map((stop) => stop.minFrp),
);
/** Minimum-FRP values the share codec accepts, in whole megawatts. */
export const MIN_FRP_MW_MIN = 0;
export const MIN_FRP_MW_MAX = 10_000;
/** Confidence switch positions; VIIRS reports only low / nominal / high. */
export const FIRMS_CONFIDENCE_MODES = Object.freeze(['all', 'nominal', 'high']);
/**
* Normalized-confidence floor for a switch position. The thresholds match
* `confidenceBucket` in the layer model (0.3 / 0.6 / 0.9 after firmsAdapt).
*/
const CONFIDENCE_FLOOR = Object.freeze({ all: 0, nominal: 0.45, high: 0.75 });
export const DEFAULT_FIRE_FILTER = Object.freeze({
minFrp: 0,
confidence: 'all',
});
/**
* The ramp stop for a fire radiative power.
* @param {number} frp Megawatts; non-finite or negative reads as 0.
* @returns {{name: string, minFrp: number, hex: string}}
*/
export function frpRampStop(frp) {
const value = Number.isFinite(frp) ? Math.max(0, frp) : 0;
let stop = FRP_RAMP[0];
for (const candidate of FRP_RAMP) {
if (value >= candidate.minFrp) stop = candidate;
else break;
}
return stop;
}
/** `#rrggbb` → `[r, g, b]` in 0..255. */
export function hexToRgb(hex) {
const text = String(hex).replace('#', '');
return [0, 2, 4].map((offset) =>
Number.parseInt(text.slice(offset, offset + 2), 16),
);
}
/**
* Log-scale slider axis: position 0 means "no minimum" (0 MW), and the rest
* of the track runs 1 MW → SLIDER_MAX_MW logarithmically. The axis floor is 1
* MW because the median detection is ~6 MW and a sub-megawatt floor hides
* nothing worth a tick.
*/
export const SLIDER_MAX_MW = 1000;
const LOG_MAX = Math.log10(SLIDER_MAX_MW);
/**
* Track position in 0..1 for a minimum FRP.
* @param {number} minFrp Megawatts.
* @returns {number}
*/
export function sliderPositionForFrp(minFrp) {
if (!Number.isFinite(minFrp) || minFrp < 1) return 0;
return Math.min(1, Math.log10(minFrp) / LOG_MAX);
}
/**
* Minimum FRP for a track position, rounded to whole megawatts so a slider
* value and its share-link form are the same number.
* @param {number} position 0..1.
* @returns {number}
*/
export function frpForSliderPosition(position) {
const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0;
if (p <= 0) return 0;
const frp = Math.pow(10, p * LOG_MAX);
return Math.max(1, Math.round(frp));
}
/**
* CSS `linear-gradient` painting the stepped ramp along the log axis, so the
* slider track IS the legend: the color under the thumb is the color of the
* weakest fire still shown.
* @returns {string}
*/
export function rampTrackGradient() {
const parts = [];
FRP_RAMP.forEach((stop, index) => {
const start = sliderPositionForFrp(stop.minFrp) * 100;
const next = FRP_RAMP[index + 1];
const end = next ? sliderPositionForFrp(next.minFrp) * 100 : 100;
parts.push(`${stop.hex} ${start.toFixed(2)}% ${end.toFixed(2)}%`);
});
return `linear-gradient(90deg, ${parts.join(', ')})`;
}
/**
* Minimum FRP → whole megawatts inside the codec bounds, or null.
* @param {*} value
* @returns {?number}
*/
export function normalizeMinFrp(value) {
const number =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== ''
? Number(value)
: NaN;
if (!Number.isFinite(number)) return null;
const rounded = Math.round(number);
return rounded >= MIN_FRP_MW_MIN && rounded <= MIN_FRP_MW_MAX
? rounded
: null;
}
/**
* Confidence mode → one of FIRMS_CONFIDENCE_MODES, or null.
* @param {*} value
* @returns {?string}
*/
export function normalizeConfidenceMode(value) {
const text = String(value ?? '')
.trim()
.toLowerCase();
return FIRMS_CONFIDENCE_MODES.includes(text) ? text : null;
}
/**
* Sanitize a partial filter against the current one; unknown or invalid
* fields keep their current value.
* @param {Object} [params]
* @param {{minFrp: number, confidence: string}} [current]
* @returns {{minFrp: number, confidence: string}}
*/
export function normalizeFireFilter(
params = {},
current = DEFAULT_FIRE_FILTER,
) {
const minFrp = normalizeMinFrp(params?.minFrp);
const confidence = normalizeConfidenceMode(params?.confidence);
return {
minFrp: minFrp === null ? current.minFrp : minFrp,
confidence: confidence === null ? current.confidence : confidence,
};
}
/**
* Whether one fire passes the filter. `confidence` is the normalized 0..1
* value firmsAdapt produces; `frp` is megawatts.
* @param {{frp: number, confidence: number}} fire
* @param {{minFrp: number, confidence: string}} filter
* @returns {boolean}
*/
export function firePassesFilter(fire, filter = DEFAULT_FIRE_FILTER) {
const frp = Number.isFinite(fire?.frp) ? fire.frp : 0;
if (frp < (filter.minFrp || 0)) return false;
const floor = CONFIDENCE_FLOOR[filter.confidence] ?? 0;
if (floor <= 0) return true;
const confidence = Number.isFinite(fire?.confidence) ? fire.confidence : 0;
return confidence >= floor;
}
/**
* The fires that pass the filter, in input order. Returns the SAME array when
* the filter is the default, so callers can tell "nothing hidden" cheaply.
* @template T
* @param {Array<T>} fires Internal records (normalized confidence).
* @param {{minFrp: number, confidence: string}} filter
* @returns {Array<T>}
*/
export function filterFires(fires, filter = DEFAULT_FIRE_FILTER) {
if (!Array.isArray(fires)) return [];
if (isDefaultFireFilter(filter)) return fires;
return fires.filter((fire) => firePassesFilter(fire, filter));
}
/** True when the filter hides nothing. */
export function isDefaultFireFilter(filter) {
return (
!(filter?.minFrp > 0) &&
(filter?.confidence === 'all' || filter?.confidence == null)
);
}
+221
View File
@@ -0,0 +1,221 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_FIRE_FILTER,
FIRMS_CONFIDENCE_MODES,
FRP_RAMP,
FRP_SLIDER_TICKS,
filterFires,
firePassesFilter,
frpForSliderPosition,
frpRampStop,
hexToRgb,
isDefaultFireFilter,
normalizeConfidenceMode,
normalizeFireFilter,
normalizeMinFrp,
rampTrackGradient,
sliderPositionForFrp,
} from './firmsIntensity.js';
test('the ramp has six ember → white-hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW', () => {
assert.deepEqual(
FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]),
[
['ember', 0, '#9e2a1b'],
['red', 5, '#d2401f'],
['flame', 20, '#f06a28'],
['orange', 50, '#fb9a2e'],
['gold', 100, '#fdc84a'],
['white-hot', 300, '#fff3b0'],
],
);
assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]);
assert.ok(Object.isFrozen(FRP_RAMP));
});
test('every stop is warm and each is lighter than the one below', () => {
const luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
let previous = -1;
for (const stop of FRP_RAMP) {
const rgb = hexToRgb(stop.hex);
assert.ok(rgb[0] >= rgb[2], `${stop.name} has no cold hue`);
assert.ok(
luma(rgb) > previous,
`${stop.name} is lighter than the stop below`,
);
previous = luma(rgb);
}
});
test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => {
assert.equal(frpRampStop(0).name, 'ember');
assert.equal(frpRampStop(4.99).name, 'ember');
assert.equal(frpRampStop(5).name, 'red');
assert.equal(frpRampStop(19.9).name, 'red');
assert.equal(frpRampStop(20).name, 'flame');
assert.equal(frpRampStop(50).name, 'orange');
assert.equal(frpRampStop(100).name, 'gold');
assert.equal(frpRampStop(299.9).name, 'gold');
assert.equal(frpRampStop(300).name, 'white-hot');
assert.equal(frpRampStop(1246).name, 'white-hot');
assert.equal(frpRampStop(NaN).name, 'ember');
assert.equal(frpRampStop(-3).name, 'ember');
assert.equal(frpRampStop(undefined).name, 'ember');
});
test('hex stops convert to the "r, g, b" accents the cards paint', () => {
assert.deepEqual(hexToRgb('#9e2a1b'), [158, 42, 27]);
assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]);
});
test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round-trip', () => {
assert.equal(sliderPositionForFrp(0), 0);
assert.equal(sliderPositionForFrp(0.5), 0);
assert.equal(sliderPositionForFrp(1), 0);
assert.ok(Math.abs(sliderPositionForFrp(10) - 1 / 3) < 1e-9);
assert.ok(Math.abs(sliderPositionForFrp(100) - 2 / 3) < 1e-9);
assert.equal(sliderPositionForFrp(1000), 1);
assert.equal(sliderPositionForFrp(5000), 1, 'clamped past the axis end');
assert.equal(frpForSliderPosition(0), 0);
assert.equal(frpForSliderPosition(1), 1000);
assert.equal(frpForSliderPosition(-1), 0);
assert.equal(frpForSliderPosition(2), 1000);
for (const frp of FRP_SLIDER_TICKS) {
assert.equal(
frpForSliderPosition(sliderPositionForFrp(frp)),
frp,
`tick ${frp} MW round-trips through the axis`,
);
}
// Ticks spread along the track instead of bunching at one end.
const positions = FRP_SLIDER_TICKS.map(sliderPositionForFrp);
for (let i = 1; i < positions.length; i += 1) {
assert.ok(positions[i] - positions[i - 1] >= 0.09, `tick gap ${i}`);
}
});
test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => {
const gradient = rampTrackGradient();
assert.match(gradient, /^linear-gradient\(90deg, /);
assert.match(gradient, /#9e2a1b 0\.00% 23\.3\d%/);
assert.match(gradient, /#d2401f 23\.3\d% 43\.3\d%/);
assert.match(gradient, /#fff3b0 82\.5\d% 100\.00%\)$/);
assert.equal(gradient.match(/#/g).length, FRP_RAMP.length);
});
test('minimum FRP normalizes to whole megawatts inside the codec bounds', () => {
assert.equal(normalizeMinFrp(0), 0);
assert.equal(normalizeMinFrp(19.6), 20);
assert.equal(normalizeMinFrp('100'), 100);
assert.equal(normalizeMinFrp(10000), 10000);
assert.equal(normalizeMinFrp(10001), null);
assert.equal(normalizeMinFrp(-1), null);
assert.equal(normalizeMinFrp('abc'), null);
assert.equal(normalizeMinFrp(''), null);
assert.equal(normalizeMinFrp(null), null);
assert.equal(normalizeMinFrp(NaN), null);
});
test('confidence modes are exactly all / nominal / high', () => {
assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']);
assert.equal(normalizeConfidenceMode(' High '), 'high');
assert.equal(normalizeConfidenceMode('nominal'), 'nominal');
assert.equal(normalizeConfidenceMode('low'), null);
assert.equal(normalizeConfidenceMode(0.9), null);
assert.equal(normalizeConfidenceMode(undefined), null);
});
test('a partial filter patches the current one and invalid fields keep their value', () => {
assert.deepEqual(normalizeFireFilter(), DEFAULT_FIRE_FILTER);
assert.deepEqual(normalizeFireFilter({ minFrp: 20 }), {
minFrp: 20,
confidence: 'all',
});
assert.deepEqual(
normalizeFireFilter(
{ confidence: 'high' },
{ minFrp: 50, confidence: 'all' },
),
{ minFrp: 50, confidence: 'high' },
);
assert.deepEqual(
normalizeFireFilter(
{ minFrp: 'nope', confidence: 'medium' },
{ minFrp: 50, confidence: 'nominal' },
),
{ minFrp: 50, confidence: 'nominal' },
);
assert.equal(isDefaultFireFilter(DEFAULT_FIRE_FILTER), true);
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'all' }), true);
assert.equal(isDefaultFireFilter({ minFrp: 1, confidence: 'all' }), false);
assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'high' }), false);
});
test('the filter keeps a fire at or above the floor and at or above the confidence mode', () => {
const low = { frp: 864, confidence: 0.3 };
const nominal = { frp: 20, confidence: 0.6 };
const high = { frp: 2, confidence: 0.9 };
assert.equal(firePassesFilter(low), true, 'default shows everything');
assert.equal(
firePassesFilter(nominal, { minFrp: 20, confidence: 'all' }),
true,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 21, confidence: 'all' }),
false,
);
assert.equal(
firePassesFilter(low, { minFrp: 0, confidence: 'nominal' }),
false,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 0, confidence: 'nominal' }),
true,
);
assert.equal(
firePassesFilter(high, { minFrp: 0, confidence: 'nominal' }),
true,
);
assert.equal(
firePassesFilter(nominal, { minFrp: 0, confidence: 'high' }),
false,
);
assert.equal(firePassesFilter(high, { minFrp: 0, confidence: 'high' }), true);
assert.equal(
firePassesFilter(high, { minFrp: 5, confidence: 'high' }),
false,
);
// Missing numbers read as 0, never as a pass-through.
assert.equal(firePassesFilter({}, { minFrp: 1, confidence: 'all' }), false);
assert.equal(
firePassesFilter({ frp: 10 }, { minFrp: 0, confidence: 'nominal' }),
false,
);
});
test('filterFires returns the same array for the default filter and preserves order otherwise', () => {
const fires = [
{ frp: 864, confidence: 0.3 },
{ frp: 20, confidence: 0.6 },
{ frp: 2, confidence: 0.9 },
{ frp: 120, confidence: 0.9 },
];
assert.equal(filterFires(fires), fires);
assert.equal(filterFires(fires, { minFrp: 0, confidence: 'all' }), fires);
assert.deepEqual(
filterFires(fires, { minFrp: 20, confidence: 'all' }).map((f) => f.frp),
[864, 20, 120],
);
assert.deepEqual(
filterFires(fires, { minFrp: 0, confidence: 'high' }).map((f) => f.frp),
[2, 120],
);
assert.deepEqual(
filterFires(fires, { minFrp: 100, confidence: 'nominal' }).map(
(f) => f.frp,
),
[120],
);
assert.deepEqual(filterFires(null, { minFrp: 1, confidence: 'all' }), []);
});
+151
View File
@@ -15,6 +15,7 @@ import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { HEAT_REBUILD_MS } from '../layers/firms/policy.js';
const LAYER_SOURCE = readLayerSource(
path.join(path.dirname(fileURLToPath(import.meta.url)), 'firmsHeatmap.js'),
@@ -392,3 +393,153 @@ test('disable cancels a pending source and a late response cannot repopulate the
assert.equal(h.layer.getStats().loading, false);
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
});
test('the heatmap is preloaded under the markers before the first zoom-out', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const priorDocument = globalThis.document;
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }),
putImageData() {},
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
}),
};
const h = harness({
withDataSource: true,
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000),
});
h.viewer.camera = { positionCartographic: { height: 1_000_000 } };
h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true };
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
const added = [];
try {
h.layer.attachShellServices({
imageryHost: () => ({
kind: 'globe',
collection: {
addImageryProvider: (provider) => {
const layer = { provider, show: true, alpha: 1 };
added.push(layer);
return layer;
},
remove() {},
raiseToTop() {},
},
}),
});
h.layer._renderLodForTest('close');
assert.equal(added.length, 0, 'the markers paint first; the build trails');
t.mock.timers.tick(HEAT_REBUILD_MS);
assert.equal(added.length, 1, 'then the heatmap is draped');
assert.equal(added[0].alpha, 0, 'invisible until the hand-off');
assert.equal(added[0].show, true, 'but mapped onto the globe tiles');
} finally {
h.layer.destroy(h.viewer);
h.cleanup();
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
}
});
test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', () => {
// A canvas stub for the heat drape and the detection glow sprites.
const priorDocument = globalThis.document;
const restoreDocument = () => {
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
};
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, hh) => ({
data: new Uint8ClampedArray(w * hh * 4),
}),
putImageData() {},
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
}),
};
const h = harness({
fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })],
withDataSource: true,
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000),
});
const added = [];
const removed = [];
let markers = null;
// The fade reads the camera height; the cull reads scene.camera.
const setHeight = (height) => {
h.viewer.camera = { positionCartographic: { height } };
};
setHeight(12_000_000);
h.viewer.scene.primitives = {
add(primitive) {
markers = primitive;
},
remove() {},
raiseToTop() {},
contains: () => true,
};
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
try {
const collection = {
addImageryProvider: (provider) => {
const layer = { provider, show: true, alpha: 1 };
added.push(layer);
return layer;
},
remove: (layer, destroy) => removed.push({ layer, destroy }),
raiseToTop() {},
};
h.layer.attachShellServices({
imageryHost: () => ({ kind: 'globe', collection }),
});
h.layer._renderLodForTest('global');
assert.equal(added.length, 1, 'one heat imagery layer');
assert.equal(added[0].show, true);
assert.equal(added[0].alpha, 1);
assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities');
assert.ok(h.published.length > 0, 'the far band still paints cards');
assert.equal(markers.length, 2, 'the markers are built under the heatmap');
assert.equal(markers.show, false, 'and hidden while the heatmap is whole');
h.layer._renderLodForTest('regional');
assert.equal(added.length, 1, 'switching far bands reuses the raster');
setHeight(1_000_000);
h.layer._renderLodForTest('close');
assert.equal(added.length, 1, 'the markers band keeps the same heat layer');
assert.equal(added[0].show, true, 'still mapped onto the globe tiles');
assert.equal(added[0].alpha, 0, 'faded out under the markers');
assert.equal(markers.show, true);
assert.equal(markers.get(0).color.alpha, 1);
// Mid hand-off both are drawn, half each, in either band.
setHeight(4_500_000);
h.layer._applyHandoffFadeForTest();
assert.equal(added[0].alpha, 0.5);
assert.equal(markers.show, true);
assert.equal(markers.get(0).color.alpha, 0.5);
h.layer._renderLodForTest('regional');
assert.equal(added[0].alpha, 0.5);
assert.equal(markers.get(1).color.alpha, 0.5, 'rebuilt markers get the fade');
h.layer.disable();
assert.equal(added[0].show, false, 'disable hides the heatmap');
h.layer.destroy(h.viewer);
assert.deepEqual(
removed.map((entry) => [entry.layer, entry.destroy]),
[[added[0], true]],
);
} finally {
h.cleanup();
restoreDocument();
}
});
+14 -9
View File
@@ -4,6 +4,8 @@
* dedicated card canvas moved into the shared world-overlay host.
*/
import { FRP_RAMP, hexToRgb } from './firmsIntensity.js';
/** Shipped ambient FIRMS card ceiling. Selected fires bypass this cohort. */
export const FIRMS_AMBIENT_COHORT_LIMIT = 18;
export const FIRMS_OVERLAY_SOURCE_ID = 'firms';
@@ -23,20 +25,23 @@ export function fireDetectionKey(fire) {
return `firms:${lat}:${lon}:${acq}:${source}`;
}
/** Severity accent palette — matches the FIRMS glow-sprite color stops. */
const ACCENT_RGB = Object.freeze({
red: '224, 82, 82',
orange: '240, 178, 62',
yellow: '244, 227, 108',
});
/**
* Severity accent palette — the FIRMS glow-sprite color stops (the six-stop
* FRP ramp in firmsIntensity.js) as "r, g, b" strings for card accents.
*/
const ACCENT_RGB = Object.freeze(
Object.fromEntries(
FRP_RAMP.map((stop) => [stop.name, hexToRgb(stop.hex).join(', ')]),
),
);
/**
* Severity stop name → "r, g, b" accent string (defaults to yellow).
* @param {string} stopName - 'red' | 'orange' | 'yellow'.
* Severity stop name → "r, g, b" accent string (defaults to the weakest stop).
* @param {string} stopName - A FRP_RAMP stop name ('ember' … 'white-hot').
* @returns {string}
*/
export function accentForSeverity(stopName) {
return ACCENT_RGB[stopName] || ACCENT_RGB.yellow;
return ACCENT_RGB[stopName] || ACCENT_RGB[FRP_RAMP[0].name];
}
/**
+10 -5
View File
@@ -9,11 +9,16 @@ import {
formatFrpMw,
} from './firmsLabels.js';
test('FIRMS formatting helpers retain the shipped severity palette', () => {
assert.equal(accentForSeverity('red'), '224, 82, 82');
assert.equal(accentForSeverity('orange'), '240, 178, 62');
assert.equal(accentForSeverity('yellow'), '244, 227, 108');
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow'));
test('FIRMS card accents are the six FRP ramp stops, weakest as the fallback', () => {
// Ember #9e2a1b … white-hot #fff3b0, lightness rising with power.
assert.equal(accentForSeverity('ember'), '158, 42, 27');
assert.equal(accentForSeverity('red'), '210, 64, 31');
assert.equal(accentForSeverity('flame'), '240, 106, 40');
assert.equal(accentForSeverity('orange'), '251, 154, 46');
assert.equal(accentForSeverity('gold'), '253, 200, 74');
assert.equal(accentForSeverity('white-hot'), '255, 243, 176');
assert.equal(accentForSeverity('blue'), accentForSeverity('ember'));
assert.equal(accentForSeverity('chartreuse'), accentForSeverity('ember'));
});
test('FIRMS power that rounds to 10 MW drops the decimal', () => {
+20 -1
View File
@@ -298,6 +298,20 @@ function satelliteFilterOption() {
}
const OPTION_GROUPS = Object.freeze({
// Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on
// the codec side; the panel's log slider rounds to it. Both default to
// "show everything": many of the strongest fires are rated low confidence.
// The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in
// firmsIntensity.js (pinned by layerState.test); spelled out here because
// this file runs in isolation in the token-ledger tests.
'local-firms': Object.freeze([
boundedIntegerOption('minFrp', 'f', 0, { min: 0, max: 10000 }),
enumOption('confidence', 'c', 'all', ['all', 'nominal', 'high'], {
all: 'a',
nominal: 'n',
high: 'h',
}),
]),
traffic: Object.freeze([
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
tomtom: 't',
@@ -626,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',
+82 -1
View File
@@ -29,6 +29,7 @@ import radioLayer from './radio.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js';
function deferred() {
let resolve;
@@ -3007,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',
});
});
+50
View File
@@ -98,6 +98,7 @@ function installAwarenessRuntime({
// status its module never emits guards nothing. See the settled-but-fetching
// tests below, which use militaryInstallations' genuine first-fetch readout.
layerStats = {},
subscribe = undefined,
} = {}) {
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
@@ -215,6 +216,7 @@ function installAwarenessRuntime({
setEnabled,
getLayerLifecycleState,
layers,
...(subscribe ? { subscribe } : {}),
});
militaryAwarenessLayer.setParams({ passive: true });
militaryAwarenessLayer.init(viewer);
@@ -383,6 +385,54 @@ test('Contacts does not claim a dependency already enabling for the user', async
}
});
test('a dependency the user adopts stays on when Contacts turns off', async () => {
// A direct selection, or the Cockpit riding one of its aircraft, promotes a
// layer Contacts turned on into the user's own (adoptLayerVisibility). The
// coordinator must give it up, or its OFF release tears the layer down —
// and the track or the Cockpit with it — before Context restores it.
const calls = [];
const subscribers = new Set();
const runtime = installAwarenessRuntime({
isEnabled: () => false,
isEffectivelyEnabled: () => false,
setEnabled: (layerId, enabled) => {
calls.push({ layerId, enabled });
return Promise.resolve(true);
},
subscribe: (listener) => {
subscribers.add(listener);
return () => subscribers.delete(listener);
},
});
try {
militaryAwarenessLayer.setParams({ passive: false });
await nextTurn();
for (const listener of subscribers)
listener({
type: 'visibility',
layerId: 'flights',
enabled: true,
origin: 'user',
adopted: true,
adoptedFromSelection: true,
});
await militaryAwarenessLayer.disable();
const released = calls
.filter(({ enabled }) => !enabled)
.map(({ layerId }) => layerId)
.sort();
assert.deepEqual(
released,
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
'the adopted layer is the user\'s now; the rest are still released',
);
} finally {
runtime.restore();
}
assert.equal(subscribers.size, 0, 'destroy drops the manager subscription');
});
test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => {
let enabled = false;
let releaseAircraft;
+505
View File
@@ -0,0 +1,505 @@
/**
* Rio de Janeiro route ids to the line a rider would say.
*
* SMTR's realtime feeds send internal keys (`O0731AAN0A` is SN731,
* `20000501110` is BRT line 50). They look decodable but are not:
* `O0014AAA0A` is LECD135, not 14. The table is copied from `routes.txt` in
* SMTR's static GTFS (https://dados.mobilidade.rio/gtfs/schedule, CC BY 4.0),
* valid to 2026-12-31. An id missing here renders as its raw key.
*/
export const RIO_ROUTE_NAMES = Object.freeze({
20000101130: '10',
20000111110: '11',
20000121130: '12',
20000131130: '13',
20000141130: '14',
20000151130: '15',
20000161130: '16',
20000171110: '17',
20000181130: '18',
20000191130: '19',
20000201130: '20',
20000221110: '22',
20000251130: '25',
20000281130: '28',
20000311140: '31',
20000351110: '35',
20000401130: '40',
20000411130: '41',
20000421110: '42',
20000431130: '43',
20000461130: '46',
20000501110: '50',
20000511110: '51',
20000521110: '52',
20000531130: '53',
20000601110: '60',
20000611130: '61',
20000671130: '67',
20000681130: '68',
20000701110: '70',
20000731130: '73',
20000801110: '80',
20000901110: '90',
'20000EXEC1110': 'ESP01',
E2110AAA0A: '2110',
E2111AAA0A: '2111',
E2112AAA0A: '2112',
E2114AAA0A: '2114',
E2303AAA0A: '2303',
E2305AAA0A: '2305',
E2307AAA0A: '2307',
E2308AAA0A: '2308',
E2309AAA0A: '2309',
E2310AAA0A: '2310',
E2334AAA0A: '2334',
E2335AAA0A: '2335',
E2336AAA0A: '2336',
E2337AAA0A: '2337',
E2338AAA0A: '2338',
E2339AAA0A: '2339',
E2342AAA0A: '2342',
E2343AAA0A: '2343',
E2344AAA0A: '2344',
E2345AAA0A: '2345',
E2381AAA0A: '2381',
E2383AAA0A: '2383',
E2613AAA0A: '2613',
E2614AAA0A: '2614',
E2802AAA0A: '2802',
E2803AAA0A: '2803',
E2804AAA0A: '2804',
O0006AAA0A: '006',
O0007AAA0A: '007',
O0007AAN0A: 'SN007',
O0010AAA0A: '010',
O0014AAA0A: 'LECD135',
O0100AAA0A: '100',
O0104AAA0A: '104',
O0104AAN0A: 'SN104',
O0107AAA0A: '107',
O0109AAA0A: 'LECD131',
O0111AAA0A: 'LECD134',
O0117AAA0A: '117',
O0133AAA0A: '133',
O0133AAN0A: 'SN133',
O0157AAA0A: 'LECD127',
O0160AAA0A: '160',
O0161AAA0A: '161',
O0162AAA0A: '162',
O0164AAA0A: 'LECD136',
O0165AAA0A: '165',
O0167AAA0A: '167',
O0169AAA0A: '169',
O0201AAA0A: '201',
O0202AAA0A: '202',
O0209AAA0A: '209',
O0209AAN0A: 'SN209',
O0210AAA0A: '210',
O0217AAA0A: '217',
O0220AAA0A: '220',
O0229AAA0A: '229',
O0232AAA0A: '232',
O0232AAN0A: 'SN232',
O0238AAA0A: '238',
O0238AAN0A: 'SN238',
O0239AAA0A: '239',
O0247AAA0A: '247',
O0249AAA0A: '249',
O0254AAA0A: '254',
O0265AAA0A: '265',
O0265AAN0A: 'SN265',
O0265AAP0A: 'SP265',
O0275AAA0A: '275',
O0277AAA0A: '277',
O0292AAA0A: '292',
O0292AAN0A: 'SN292',
O0296AAA0A: '296',
O0298AAA0A: '298',
O0298AAN0A: 'SN298',
O0300AAA0A: '300',
O0300AAR0A: 'SR300',
O0301AAA0A: '301',
O0302AAA0A: '302',
O0302AAN0A: 'SN302',
O0306AAA0A: '306',
O0311AAA0A: '311',
O0312AAA0A: '312',
O0312AAN0A: 'SN312',
O0313AAA0A: '313',
O0315AAA0A: '315',
O0319AAA0A: '319',
O0319AAN0A: 'SN319',
O0319AAV0A: 'SV319',
O0321AAA0A: '321',
O0323AAA0A: '323',
O0324AAA0A: '324',
O0324AAN0A: 'SN324',
O0324AAP0A: 'SP324',
O0325AAA0A: '325',
O0326AAA0A: '326',
O0326AAPBA: 'SPB326',
O0327AAA0A: '327',
O0328AAA0A: '328',
O0328AAN0A: 'SN328',
O0328AAP0A: 'SP328',
O0328AAR0A: 'SR328',
O0329AAA0A: '329',
O0329AAP0A: 'SP329',
O0335AAA0A: '335',
O0338AAA0A: '338',
O0341AAA0A: '341',
O0342AAA0A: '342',
O0342AAR0A: 'SR342',
O0343AAA0A: '343',
O0343AAP0A: 'SP343',
O0348AAA0A: '348',
O0349AAA0A: '349',
O0350AAA0A: '350',
O0352AAA0A: '352',
O0353AAA0A: '353',
O0353AAN0A: 'SN353',
O0355AAA0A: '355',
O0355AAN0A: 'SN355',
O0355AAR0A: 'SR355',
O0361AAA0A: '361',
O0362AAA0A: '362',
O0362AAR0A: 'SR362',
O0363AAA0A: '363',
O0363AAN0A: 'SN363',
O0363AAV0A: 'SV363',
O0368AAA0A: '368',
O0368AAN0A: 'SN368',
O0369AAA0A: '369',
O0371AAA0A: '371',
O0378AAA0A: '378',
O0378AAR0A: 'SR378',
O0380AAA0A: '380',
O0383AAA0A: '383',
O0383AAN0A: 'SN383',
O0384AAA0A: '384',
O0384AAR0A: 'SR384',
O0385AAA0A: '385',
O0386AAA0A: '386',
O0386AAR0A: 'SR386',
O0388AAA0A: '388',
O0388AAN0A: 'SN388',
O0388AAR0A: 'SR388',
O0389AAP0A: 'SP389',
O0390AAA0A: '390',
O0391AAV0A: 'SV391',
O0393AAA0A: '393',
O0393AAN0A: 'SN393',
O0393AAR0A: 'SR393',
O0397AAA0A: '397',
O0397AAN0A: 'SN397',
O0397AAR0A: 'SR397',
O0399AAA0A: '399',
O0399AAN0A: 'SN399',
O0399AAR0A: 'SR399',
O0404AAP0A: 'SP404',
O0409AAA0A: '409',
O0410AAA0A: '410',
O0410AAN0A: 'LECD139',
O0415AAA0A: '415',
O0415AAN0A: 'SN415',
O0416AAA0A: '416',
O0422AAA0A: '422',
O0422AAN0A: 'SN422',
O0426AAA0A: '426',
O0433AAA0A: 'LECD150',
O0435AAA0A: 'LECD151',
O0448AAA0A: '448',
O0455AAA0A: '455',
O0456AAA0A: 'LECD132',
O0457AAA0A: '457',
O0457AAN0A: 'SN457',
O0461AAA0A: '461',
O0472AAA0A: '472',
O0473AAA0A: '473',
O0474AAA0A: '474',
O0474AAN0A: 'SN474',
O0474AAV0A: 'SV474',
O0475AAA0A: 'LECD133',
O0476AAA0A: '476',
O0483AAA0A: '483',
O0483AAN0A: 'SN483',
O0483AAPAA: 'SPA483',
O0483AAPBA: 'SPB483',
O0483AAV0A: 'SV483',
O0484AAA0A: '484',
O0485AAA0A: '485',
O0485AAP0A: 'SP485',
O0486AAA0A: '486',
O0492AAA0A: '492',
O0497AAA0A: '497',
O0497AAN0A: 'SN497',
O0498AAA0A: '498',
O0507AAA0A: '507',
O0513AAA0A: '513',
O0518AAA0A: '518',
O0519AAA0A: '519',
O0536AAA0A: 'LECD138',
O0539AAA0A: '539',
O0550AAA0A: '550',
O0550AAN0A: 'SN550',
O0550AAPAA: 'SPA550',
O0550AAPBA: 'SPB550',
O0552AAA0A: '552',
O0552AAN0A: 'LECD149',
O0553AAA0A: '553',
O0554AAA0A: '554',
O0554AAN0A: 'SN554',
O0555AAA0A: '555',
O0557AAA0A: '557',
O0565AAA0A: '565',
O0565AAN0A: 'SN565',
O0583AAA0A: '583',
O0584AAA0A: '584',
O0585AAA0A: '585',
O0600AAA0A: '600',
O0600AAN0A: 'SN600',
O0601AAA0A: '601',
O0603AAA0A: '603',
O0606AAA0A: '606',
O0606AAV0A: 'SV606',
O0607AAA0A: '607',
O0608AAA0A: '608',
O0610AAA0A: '610',
O0611AAA0A: '611',
O0613AAA0A: '613',
O0613AAPAA: 'SPA613',
O0614AAA0A: '614',
O0616AAA0A: '616',
O0621AAA0A: '621',
O0622AAA0A: '622',
O0623AAA0A: '623',
O0624AAA0A: '624',
O0624AAN0A: 'SN624',
O0624AAV0A: 'SV624',
O0625AAA0A: '625',
O0626AAA0A: '626',
O0627AAA0A: '627',
O0628AAA0A: '628',
O0629AAA0A: '629',
O0629AAN0A: 'SN629',
O0630AAA0A: '630',
O0634AAA0A: '634',
O0635AAA0A: '635',
O0635AAP0A: 'SP635',
O0636AAA0A: '636',
O0636AAN0A: 'SN636',
O0638AAA0A: '638',
O0639AAA0A: '639',
O0639AAN0A: 'SN639',
O0645AAA0A: '645',
O0649AAA0A: 'LECD126',
O0650AAA0A: '650',
O0651AAA0A: '651',
O0652AAA0A: '652',
O0653AAA0A: '653',
O0663AAA0A: '663',
O0665AAA0A: '665',
O0665AAVAA: 'LECD147',
O0665AAVBA: 'SVB665',
O0669AAA0A: '669',
O0669AAV0A: 'SV669',
O0678AAA0A: '678',
O0679AAA0A: '679',
O0685AAA0A: 'LECD140',
O0685AAVAA: 'LECD148',
O0685AAVBA: 'LECD141',
O0687AAA0A: '687',
O0688AAA0A: '688',
O0691AAA0A: '691',
O0692AAA0A: '692',
O0692AAV0A: 'SV692',
O0693AAA0A: '693',
O0696AAA0A: '696',
O0696AAN0A: 'SN696',
O0711AAA0A: '711',
O0712AAA0A: '712',
O0721AAA0A: '721',
O0731AAA0A: '731',
O0731AAN0A: 'SN731',
O0737AAA0A: '737',
O0741AAA0A: '741',
O0743AAA0A: '743',
O0745AAA0A: '745',
O0746AAA0A: '746',
O0753AAA0A: '753',
O0754AAA0A: '754',
O0756AAA0A: '756',
O0757AAA0A: '757',
O0759AAA0A: '759',
O0759AAP0A: 'SP759',
O0764AAA0A: '764',
O0765AAA0A: '765',
O0766AAA0A: '766',
O0766AAP0A: 'SP766',
O0770AAA0A: '770',
O0771AAA0A: '771',
O0774AAA0A: '774',
O0774AAV0A: 'LECD142',
O0775AAA0A: '775',
O0777AAV0A: 'SV777',
O0778AAA0A: '778',
O0779AAA0A: '779',
O0779AAN0A: 'SN779',
O0780AAA0A: '780',
O0783AAA0A: '783',
O0785AAA0A: '785',
O0786AAA0A: '786',
O0788AAA0A: '788',
O0790AAA0A: '790',
O0790AAN0A: 'SN790',
O0790AAV0A: 'SV790',
O0791AAA0A: '791',
O0793AAA0A: '793',
O0794AAA0A: '794',
O0794AAN0A: 'SN794',
O0795AAP0A: 'SP795',
O0796AAA0A: '796',
O0798AAA0A: '798',
O0799AAA0A: '799',
O0801AAA0A: '801',
O0801AAP0A: 'SP801',
O0802AAA0A: '802',
O0803AAA0A: '803',
O0803AAN0A: 'SN803',
O0803AAP0A: 'SP803',
O0804AAA0A: '804',
O0805AAA0A: '805',
O0805AAN0A: 'SN805',
O0805AAP0A: 'SP805',
O0807AAA0A: '807',
O0808AAA0A: '808',
O0809AAA0A: '809',
O0810AAA0A: '810',
O0810AAN0A: 'SN810',
O0810AAP0A: 'SP810',
O0812AAA0A: '812',
O0817AAA0A: '817',
O0819AAA0A: '819',
O0820AAA0A: '820',
O0821AAA0A: '821',
O0821AAN0A: 'SN821',
O0822AAA0A: '822',
O0825AAA0A: '825',
O0825AAN0A: 'SN825',
O0826AAA0A: '826',
O0829AAA0A: '829',
O0830AAA0A: '830',
O0831AAA0A: '831',
O0831AAV0A: 'SV831',
O0833AAA0A: '833',
O0834AAA0A: '834',
O0835AAA0A: '835',
O0836AAA0A: '836',
O0837AAA0A: '837',
O0838AAA0A: '838',
O0840AAA0A: '840',
O0840AAN0A: 'SN840',
O0841AAA0A: '841',
O0842AAA0A: '842',
O0843AAA0A: '843',
O0844AAA0A: '844',
O0845AAA0A: '845',
O0846AAA0A: '846',
O0846AAN0A: 'SN846',
O0847AAA0A: '847',
O0848AAA0A: '848',
O0848AAN0A: 'SN848',
O0849AAA0A: '849',
O0850AAA0A: '850',
O0851AAA0A: '851',
O0852AAA0A: '852',
O0853AAA0A: '853',
O0853AAN0A: 'SN853',
O0853AAV0A: 'SV853',
O0854AAAAA: '854',
O0855AAA0A: '855',
O0856AAA0A: '856',
O0857AAA0A: '857',
O0857AAN0A: 'SN857',
O0859AAA0A: '859',
O0861AAA0A: '861',
O0861AAN0A: 'SN861',
O0862AAA0A: '862',
O0863AAA0A: '863',
O0863AAV0A: 'SV863',
O0864AAA0A: '864',
O0864AAP0A: 'SP864',
O0865AAA0A: '865',
O0866AAA0A: '866',
O0866AAV0A: 'SV866',
O0867AAA0A: '867',
O0867AAN0A: 'SN867',
O0868AAA0A: '868',
O0869AAA0A: '869',
O0870AAA0A: '870',
O0871AAA0A: '871',
O0872AAA0A: '872',
O0878AAA0A: '878',
O0878AAN0A: 'SN878',
O0878AAV0A: 'SV878',
O0880AAA0A: '880',
O0880AAN0A: 'SN880',
O0881AAA0A: '881',
O0883AAA0A: '883',
O0884AAA0A: '884',
O0884AAN0A: 'SN884',
O0885AAA0A: '885',
O0885AAN0A: 'SN885',
O0892AAA0A: '892',
O0893AAA0A: '893',
O0893AAN0A: 'SN893',
O0894AAA0A: '894',
O0895AAA0A: '895',
O0895AAN0A: 'SN895',
O0897AAA0A: '897',
O0897AAN0A: 'SN897',
O0898AAA0A: '898',
O0898AAN0A: 'SN898',
O0899AAA0A: '899',
O0899AAV0A: 'SV899',
O0900AAA0A: '900',
O0901AAA0A: '901',
O0901AAVBA: 'SVB901',
O0905AAA0A: '905',
O0906AAA0A: '906',
O0908AAA0A: '908',
O0908AAN0A: 'SN908',
O0908AAV0A: 'SV908',
O0910AAA0A: '910',
O0913AAA0A: '913',
O0915AAA0A: '915',
O0917AAA0A: '917',
O0917AAV0A: 'SV917',
O0918AAA0A: '918',
O0918AAP0A: 'SP918',
O0919AAA0A: '919',
O0919AAN0A: 'SN919',
O0920AAA0A: '920',
O0921AAA0A: '921',
O0921AAV0A: 'SV921',
O0922AAA0A: '922',
O0922AAV0A: 'SV922',
O0925AAA0A: '925',
O0926AAA0A: '926',
O0928AAA0A: '928',
O0932AAA0A: '932',
O0940AAA0A: '940',
O0944AAA0A: 'LECD143',
O0946AAA0A: '946',
O0950AAA0A: 'LECD144',
O0951AAA0A: 'LECD145',
O0954AAA0A: '954',
O0954AAN0A: 'SN954',
O0959AAA0A: 'LECD146',
O0961AAA0A: '961',
O0975AAA0A: '975',
O0979AAA0A: '979',
O0987AAA0A: '987',
O0990AAA0A: '990',
});
+59
View File
@@ -1,4 +1,5 @@
import { greatCircleKm } from '../geoDistance.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
/**
* @module transitFeeds
@@ -376,6 +377,18 @@ function burlingtonRouteLabel(routeId) {
return BURLINGTON_ROUTE_NAMES[routeId] || routeId;
}
/**
* Rio route key to the line a rider would say. One SMTR `routes.txt` covers
* both feeds (see rioRouteNames.js).
* @param {string|null} routeId
* @returns {string|null}
*/
function rioRouteLabel(routeId) {
return Object.hasOwn(RIO_ROUTE_NAMES, routeId)
? RIO_ROUTE_NAMES[routeId]
: routeId;
}
/**
* Registry of feeds. Order is presentation order in the stats/credit text.
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
@@ -729,6 +742,52 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus',
routeLabel: burlingtonRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-onibus',
name: 'Rio buses (SPPO)',
operator: 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro',
region: 'Rio de Janeiro, RJ',
// Every vehicle in a 2026-10-29 snapshot, from Santa Cruz in the west to
// the Zona Sul, sat within 31 km of this point.
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/onibus/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f',
attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro — buses (SPPO)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds are m/s, as the spec requires: checked against vehicle movement on 2026-11-05, though a 2026-10-29 snapshot read as km/h. Line names come from SMTR’s GTFS `routes.txt`, under the same CC BY 4.0 terms.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-brt',
name: 'Mobi-Rio BRT',
operator:
'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro, for the Mobi-Rio BRT fleet',
region: 'Rio de Janeiro, RJ',
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/brt/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4',
attribution:
'SMTR / Prefeitura da Cidade do Rio de Janeiro — BRT (Mobi-Rio)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are m/s too. Line names come from the same `routes.txt`.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
]);
/**
+70
View File
@@ -15,6 +15,7 @@ import {
transitModeResolved,
transitRouteLabel,
} from './transitFeeds.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
const ids = new Set();
@@ -482,3 +483,72 @@ test('Burlington is live and carries its terms link, which its licence requires'
assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/);
assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all');
});
test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São Paulo', () => {
const ids = (lat, lon) =>
transitFeedsInRange(lat, lon).map((feed) => feed.id);
for (const [place, lat, lon] of [
['Centro', -22.9068, -43.1729],
['Barra da Tijuca', -23.0004, -43.3659],
['Santa Cruz', -22.9194, -43.6847],
]) {
assert.ok(
ids(lat, lon).includes('smtr-rio-onibus'),
`buses reach ${place}`,
);
assert.ok(ids(lat, lon).includes('smtr-rio-brt'), `BRT reaches ${place}`);
}
assert.deepEqual(
ids(-23.5505, -46.6333).filter((id) => id.startsWith('smtr-rio')),
[],
'São Paulo, 360 km away, never polls Rio',
);
});
test('Rio route keys read as the line on the front of the bus', () => {
// Some keys look parseable and are not (`O0014AAA0A` is LECD135).
const buses = getTransitFeed('smtr-rio-onibus');
assert.equal(transitRouteLabel(buses, 'O0868AAA0A'), '868');
assert.equal(transitRouteLabel(buses, 'O0731AAN0A'), 'SN731');
assert.equal(transitRouteLabel(buses, 'O0759AAP0A'), 'SP759');
assert.equal(transitRouteLabel(buses, 'O0014AAA0A'), 'LECD135');
assert.equal(transitRouteLabel(buses, 'E2303AAA0A'), '2303');
const brt = getTransitFeed('smtr-rio-brt');
assert.equal(transitRouteLabel(brt, '20000501110'), '50');
assert.equal(transitRouteLabel(brt, '20000EXEC1110'), 'ESP01');
// Unknown ids, prototype names included, keep their raw key.
assert.equal(transitRouteLabel(buses, 'O9999AAA0A'), 'O9999AAA0A');
assert.equal(transitRouteLabel(buses, 'constructor'), 'constructor');
assert.equal(transitRouteLabel(brt, null), null);
});
test('the Rio route table is a clean join of published keys to names', () => {
const entries = Object.entries(RIO_ROUTE_NAMES);
assert.ok(entries.length > 400, 'the whole published table is carried');
for (const [id, name] of entries) {
assert.match(id, /^(?:[OE]\d{4}[A-Z0-9]{5}|2\d{4}[A-Z0-9]{4,8})$/, id);
assert.ok(name && name === name.trim(), `${id} has a name`);
assert.notEqual(name, id, `${id} is translated`);
}
});
test('Rio feeds are CC BY 4.0 and live', () => {
for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) {
const feed = getTransitFeed(id);
assert.equal(feed.defaultEnabled, true, `${id} is live`);
assert.equal(feed.license, 'CC BY 4.0');
assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//);
assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/);
assert.equal(transitModeFor(feed, null), 'bus');
}
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources
.split('\n')
.some((line) => line.startsWith('| **SMTR Rio de Janeiro')),
'Rio has a DATA_SOURCES row',
);
});
+10
View File
@@ -137,6 +137,7 @@ function encodeFeed({
const position = new PbfWriter();
position.writeFloatField(1, entity.lat);
position.writeFloatField(2, entity.lon);
if (entity.speed != null) position.writeFloatField(5, entity.speed);
vehicle.writeBytesField(2, position.finish());
if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp);
const descriptor = new PbfWriter();
@@ -284,6 +285,15 @@ test('the snapshot builder fills timestamps from the header it decoded', () => {
assert.equal(snapshot.feedTimestamp, 1_700_000_000);
});
test('Rio speeds pass through as the m/s they are published in', () => {
// Checked against vehicle movement on 2026-11-05.
const bytes = encodeFeed({
entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }],
});
const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1);
assert.equal(rio.vehicles[0].speedMps, 10);
});
test('conditional-request validators and gzip ride along with feed identification', () => {
const entur = getTransitFeed('entur-norway');
const plain = transitUpstreamHeaders(entur);
+3 -2
View File
@@ -1041,10 +1041,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(
+16
View File
@@ -2187,6 +2187,9 @@
"{value} KM/H": "{value} كم/س",
"{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "مصدر البيانات العسكرية متوقف · صورة جزئية",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "مصدر الرحلات متوقف · صورة جزئية",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "قائمة الطائرات القريبة غير مكتملة · لا يعني أن الوضع آمن",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
@@ -3214,6 +3217,19 @@
"Download the timing log of the last play": "تنزيل سجل توقيت آخر تشغيل",
"Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة",
"Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد",
"fire confidence filter|All": "الكل",
"fire confidence filter|Nominal+": "متوسطة فأعلى",
"fire confidence filter|High": "عالية",
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
"Fire intensity (FRP)": "الشدة (FRP)",
"≥ {value} MW": "{value} MW على الأقل",
"Confidence": "الثقة",
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
"fire intensity filter|All": "الكل",
"Detection confidence": "ثقة الرصد",
"{shown} of {total} shown": "المعروضة: {shown} من {total}",
"Name of {name}": "اسم {name}",
"Change the colour of {name}": "تغيير لون {name}",
"Colour of {name}": "لون {name}",
+16
View File
@@ -2087,6 +2087,9 @@
"{value} KM/H": "{value} KM/H",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "MILITÄR-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUG-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLUGZEUGE IN DER NÄHE UNVOLLSTÄNDIG · KEINE ENTWARNUNG",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
@@ -2946,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}",
+16
View File
@@ -1920,6 +1920,9 @@
"N/A": "N/D",
"{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO",
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
"ft": "ft",
"kts": "kn",
@@ -2983,6 +2986,19 @@
"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}",
+16
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "ACTUS RÉGIONALES",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT",
"CONTEXT STANDBY": "CONTEXTE EN VEILLE",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT",
@@ -3016,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}",
+16
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "NOTIZIE REGIONALI",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA",
"CONTEXT STANDBY": "CONTESTO IN ATTESA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA",
@@ -3016,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}",
+17 -1
View File
@@ -1612,6 +1612,9 @@
"REGIONAL NEWS": "地域ニュース",
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません",
"CONTEXT STANDBY": "コンテキスト待機",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません",
@@ -1684,7 +1687,7 @@
"Restore real aircraft": "実際の航空機に戻す",
"Reclassify as TR-3B": "TR-3Bに再分類",
"TRACK ACQUIRED": "追跡開始",
"{label} · COURSE {heading}°": "{label} · 針路 {heading}°",
"{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°",
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
"No selected data layers": "選択中のデータレイヤーはありません",
"{count} data layers could not be cleared": {
@@ -2876,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}の色",
+17 -1
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA",
"CONTEXT STANDBY": "CONTEXTO EM ESPERA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA",
@@ -1736,7 +1739,7 @@
"Restore real aircraft": "Restaurar a aeronave real",
"Reclassify as TR-3B": "Reclassificar como TR-3B",
"TRACK ACQUIRED": "RASTRO ADQUIRIDO",
"{label} · COURSE {heading}°": "{label} · CURSO {heading}°",
"{label} · COURSE {heading}°": "{label} · RUMO {heading}°",
"Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente",
"No selected data layers": "Nenhuma camada de dados selecionada",
"{count} data layers could not be cleared": {
@@ -3016,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}",
+17 -1
View File
@@ -458,7 +458,7 @@
"SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН",
"Select flight {title}": "Выбрать рейс {title}",
"TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА",
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
"{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°",
"Restore real aircraft": "Вернуть реальный самолёт",
"Reclassify as TR-3B": "Переклассифицировать как TR-3B",
"No selected data layers": "Нет выбранных слоёв данных",
@@ -1953,6 +1953,9 @@
"N/A": "Н/Д",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
"ft": "фт",
"kts": "уз",
@@ -3053,6 +3056,19 @@
"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}",
+17 -1
View File
@@ -1683,6 +1683,9 @@
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
"CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
@@ -1761,7 +1764,7 @@
"Restore real aircraft": "Повернути справжній літак",
"Reclassify as TR-3B": "Перекласифікувати як TR-3B",
"TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО",
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
"{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°",
"Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз",
"No selected data layers": "Шари даних не вибрано",
"{count} data layers could not be cleared": {
@@ -3045,6 +3048,19 @@
"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}",
+17 -1
View File
@@ -1588,6 +1588,9 @@
"REGIONAL NEWS": "地区新闻",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞",
"CONTEXT STANDBY": "态势待机",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞",
@@ -1660,7 +1663,7 @@
"Restore real aircraft": "恢复真实飞机",
"Reclassify as TR-3B": "重新归类为 TR-3B",
"TRACK ACQUIRED": "已获取航迹",
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
"{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°",
"Context could not restore every layer; try again": "态势未能恢复所有图层,请重试",
"No selected data layers": "没有已选数据图层",
"{count} data layers could not be cleared": {
@@ -2843,6 +2846,19 @@
"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} 起",
+17 -1
View File
@@ -1578,6 +1578,9 @@
"REGIONAL NEWS": "地區新聞",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞",
"CONTEXT STANDBY": "態勢待機",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞",
@@ -1650,7 +1653,7 @@
"Restore real aircraft": "恢復真實飛機",
"Reclassify as TR-3B": "重新歸類為 TR-3B",
"TRACK ACQUIRED": "已獲取航跡",
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
"{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°",
"Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試",
"No selected data layers": "沒有已選數據圖層",
"{count} data layers could not be cleared": {
@@ -2882,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": "路線導航、繪製和你自己的文件",
+12 -2
View File
@@ -201,6 +201,7 @@
"{value} KM/S",
"ΔN",
"Ⅱ PAUSE",
"≥ {value} MW",
"▲ {frp} MW"
],
"es": [
@@ -307,6 +308,7 @@
"WX",
"audio {state}",
"dB",
"fire confidence filter|Nominal+",
"fire confidence|nominal",
"ft",
"hPa",
@@ -329,6 +331,7 @@
"{value} KM",
"{value} KM/H",
"{value} KM/S",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
@@ -515,6 +518,7 @@
"ΔE",
"ΔN",
"Ⅱ PAUSE",
"≥ {value} MW",
"▲ {frp} MW"
],
"it": [
@@ -687,6 +691,7 @@
"{value} KM/H",
"{value} KM/S",
"{value} conf",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
@@ -738,6 +743,7 @@
"{type} SPD: {speed} HDG: {heading}",
"ΔE",
"ΔN",
"≥ {value} MW",
"▲ {frp} MW"
],
"pt": [
@@ -851,6 +857,7 @@
"WHOT/BHOT",
"WX",
"dB",
"fire confidence filter|Nominal+",
"fire confidence|nominal",
"ft",
"hPa",
@@ -869,7 +876,8 @@
"{value} KM/H",
"{value} KM/S",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"≥ {value} MW"
],
"ru": [
"AIS: --",
@@ -928,9 +936,9 @@
"MM",
"NVG",
"REC",
"SRC {value}",
"S2 GhostMaps",
"S2 · {name}",
"SRC {value}",
"Sentinel-2",
"Techno",
"Trance",
@@ -955,6 +963,7 @@
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/S",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
@@ -1012,6 +1021,7 @@
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/S",
"≥ {value} MW",
"ΔE",
"ΔN",
"▲ {frp} MW"
+1
View File
@@ -23,6 +23,7 @@ export function createControls({ state: layerState, services, parts, source }) {
attachDataManager(dataManager) {
layerState.dataManager = dataManager;
parts.dependencies.watchAdoptions(dataManager);
},
setParams(params = {}) {
+20
View File
@@ -79,6 +79,25 @@ export function createDependencies({
});
}
/**
* Give up a dependency the user adopts. A direct selection, or the Cockpit
* riding one of its aircraft, promotes a layer Contacts turned on into the
* user's own (`DataLayerManager.adoptLayerVisibility`). Releasing it when
* Contacts goes off would tear down that track or Cockpit before the
* Context restore could put the layer back.
*/
function relinquishAdoptedDependency(change) {
if (change?.type === 'visibility' && change.adopted && change.enabled)
layerState.ownedDependencies.delete(change.layerId);
}
/** Follow one manager's adoptions; null stops following. */
function watchAdoptions(dataManager) {
layerState.unwatchAdoptions?.();
layerState.unwatchAdoptions =
dataManager?.subscribe?.(relinquishAdoptedDependency) || null;
}
function activateOperationalContext() {
const activationId = ++layerState.activationId;
layerState.autoFocusRetryPending = false;
@@ -113,6 +132,7 @@ export function createDependencies({
refreshAfterDeferredDependency,
enableDependencies,
releaseOwnedDependencies,
watchAdoptions,
activateOperationalContext,
};
}
+1
View File
@@ -340,6 +340,7 @@ export function createLifecycle({
layerState.clearListener = null;
layerState.subjectClearListener = null;
layerState.viewer = null;
parts.dependencies.watchAdoptions(null);
layerState.dataManager = null;
},
};
+1
View File
@@ -196,6 +196,7 @@ export function createModel({ state: layerState, services, parts, source }) {
id: cohort.id,
label: cohort.label,
source: cohort.source,
sourceStatus: cohort.sourceStatus || null,
coverage: cohort.coverage || null,
relationship: cohort.summary.relationship,
count: cohort.summary.count,
+2 -1
View File
@@ -8,6 +8,7 @@ import {
t,
translateKnown,
} from '../../i18n/index.js';
import { sourceStatusLabel } from '../../ui/layerNames.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
import { distanceParts } from '../../data/distanceFormat.js';
@@ -150,7 +151,7 @@ export function createPanel({ state: layerState, parts }) {
relationship: summary.relationship.toLowerCase(),
label: cohort.label,
count: `${presentAwarenessCount(summary)}${pageLabel}`,
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
detail: `${sourceStatusLabel(cohort.source, cohort.sourceStatus)}${coverage} · ${presentAwarenessReason(summary)}`,
nearest,
};
}
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPanel } from './panel.js';
test('a Contacts row names its provider as the Layers panel does, with its feed state', () => {
const cohort = (id, source, sourceStatus) => ({
id,
label: id,
source,
sourceStatus,
summary: { relationship: 'OBSERVED', count: 1, nearest: [], reason: 'observed' },
});
const state = {
enabled: true,
results: {
subject: { label: 'AFR38JF' },
cohorts: [
cohort('flights', 'adsb.lol', 'fallback'),
cohort('military', 'adsb.lol', null),
cohort('ais-live-vessels', 'Datalastic', 'stale'),
],
},
cohortPages: new Map(),
panelListeners: new Set(),
panelSignature: '',
navigationIndex: 0,
};
const parts = {
navigation: { canNavigateNext: () => false },
rendering: { hideDirectionOverlay() {} },
};
createPanel({ state, parts }).renderResults();
assert.deepEqual(
state.panelView.rows.map((row) => row.detail),
['adsb.lol · FALLBACK · observed', 'adsb.lol · observed', 'Datalastic · STALE · observed'],
);
state.offPanelLocale?.();
});
+1
View File
@@ -91,6 +91,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
stats.count ?? null,
stats.status || null,
stats.error || null,
stats.source || null,
];
})
.map((parts) => parts.join(':'))
+26
View File
@@ -81,3 +81,29 @@ test('Contacts excludes square corners and measures from the current moving subj
assert.equal(moved.reason, 'mapped matches within 100 km of the subject');
assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 });
});
test('Contacts cohorts carry the provider and whether it answered as a fallback or stale', () => {
const services = { installations: { getNearby: () => [], getStats: () => ({}) },
flights: { getNearby: () => [] }, military: { getNearby: () => [] }, vessels: { getNearby: () => [] } };
const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source) } };
parts.queries = createQueries({ state: {}, services, parts });
const subjectOwner = createSubject({ state: {}, services, parts });
const states = {
// The OpenSky proxy answers from adsb.lol when OpenSky fails.
flights: { available: true, stats: { source: 'adsb.lol', coverage: '250nm regional fallback', count: 4, lastUpdate: 1 } },
military: { available: true, stats: { source: 'adsb.lol', fallback: false, count: 2, lastUpdate: 1 } },
'ais-live-vessels': { available: true, stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1 } },
'military-installations': { available: true, stats: { coverage: { kind: 'subject', radiusM: 100000 } } },
};
const { cohorts } = subjectOwner.evaluateSubject({
id: 'subject', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(0, 0, 10000),
}, states);
const byId = Object.fromEntries(cohorts.map((cohort) => [cohort.id, cohort]));
assert.equal(byId.flights.source, 'adsb.lol');
assert.equal(byId.flights.sourceStatus, 'fallback');
assert.equal(byId.military.sourceStatus, null);
assert.equal(byId['ais-live-vessels'].source, 'Datalastic');
assert.equal(byId['ais-live-vessels'].sourceStatus, 'stale');
assert.equal(byId['military-installations'].sourceStatus, null);
});
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createQueries } from './queries.js';
import { createSubject } from './subject.js';
import { createModel } from './model.js';
import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js';
import { layerFeedState } from '../../data/feedState.js';
import aisLiveVesselsLayer, {
_setVesselStateForTest,
_beginAisSessionForTest,
_applyAisFeedSnapshotForTest,
_setAisRuntimeForTest,
} from '../../data/aisLiveVessels.js';
test('a poll that returns the same stale AIS snapshot restores STALE on Contacts cohorts', () => {
globalThis.document ??= { body: null };
const services = {
installations: { getNearby: () => [], getStats: () => ({}) },
flights: { getNearby: () => [] },
military: { getNearby: () => [] },
vessels: { getNearby: () => [] },
};
const layerState = {
enabled: true,
passive: true,
sourceRevision: '',
cohortPages: new Map(),
subject: {
id: 'site-1',
layerId: 'military-installations',
label: 'Site',
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
};
let states;
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) =>
summarizeAwarenessCohort(items, source),
isFlightLayer: (id) => id === 'flights' || id === 'military',
},
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
panel: { renderResults() {} },
};
const realQueries = createQueries({ state: layerState, services, parts });
parts.queries = { ...realQueries, collectSourceStates: () => states };
parts.model = createModel({ state: layerState, services, parts });
const subject = createSubject({ state: layerState, services, parts });
const flights = (lastUpdate) => ({
available: true, enabled: true, stale: false,
stats: { source: 'OpenSky Network', count: 4, lastUpdate },
});
const vessels = (loading) => ({
available: true, enabled: true, stale: true,
stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1000, loading },
});
const rest = {
military: { available: true, enabled: true, stale: false, stats: { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 1 } },
'military-installations': { available: true, enabled: true, stale: false, stats: { coverage: { kind: 'subject', radiusM: 100000 } } },
};
const ais = () => layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels');
// 1. Stale AIS cache, nothing loading.
states = { flights: flights(1), 'ais-live-vessels': vessels(false), ...rest };
subject.refreshSelectedSubject(true);
assert.equal(ais().sourceStatus, 'stale');
// 2. AIS re-poll in flight while the flights poll lands (revision changes).
states = { flights: flights(2), 'ais-live-vessels': vessels(true), ...rest };
subject.refreshSelectedSubject();
assert.equal(ais().sourceStatus, 'stale', 'a poll in flight keeps the snapshot stale');
// 3. AIS poll returns the same stale snapshot; only loading flips back.
states = { flights: flights(2), 'ais-live-vessels': vessels(false), ...rest };
subject.refreshSelectedSubject();
subject.refreshSelectedSubject();
assert.equal(ais().sourceStatus, 'stale', 'Layers reads STALE again; Contacts/Cockpit must too');
});
function fakeRuntime() {
return {
now: () => 1000,
setTimeout: () => ({}),
clearTimeout() {},
};
}
test('first AIS connect (no snapshot yet) carries no STALE on the Contacts cohort', () => {
globalThis.document ??= { body: null };
_setAisRuntimeForTest(fakeRuntime());
_setVesselStateForTest({ viewer: {}, records: [] });
try {
_beginAisSessionForTest();
// What server/providers/vessels/ais-live.js answers while its socket connects.
_applyAisFeedSnapshotForTest({}, {
rows: [], source: 'AISStream', status: 'connecting', refreshing: true,
});
const vesselStats = aisLiveVesselsLayer.getStats();
assert.equal(vesselStats.loading, true);
assert.equal(vesselStats.stale, true);
assert.equal(vesselStats.lastUpdate, null);
assert.equal(layerFeedState(vesselStats), 'loading', 'Layers chip reads LOADING');
const flightsStats = { source: 'OpenSky Network', count: 2, lastUpdate: 5 };
const militaryStats = { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 5 };
const installStats = { coverage: { kind: 'subject', radiusM: 100000 } };
const statsById = {
flights: flightsStats,
military: militaryStats,
'ais-live-vessels': vesselStats,
'military-installations': installStats,
};
const services = {
installations: { getNearby: () => [], getStats: () => installStats },
flights: { getNearby: () => [] },
military: { getNearby: () => [] },
vessels: aisLiveVesselsLayer,
};
const layerState = {
enabled: true,
passive: true,
sourceRevision: '',
cohortPages: new Map(),
dataManager: {
isEnabled: () => true,
getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled' }),
layers: new Map(Object.entries(statsById).map(([id, s]) => [id, { module: { getStats: () => s } }])),
},
subject: {
id: 'site-1',
layerId: 'military-installations',
label: 'Site',
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
},
};
const parts = {
navigation: {
summarizeAwarenessCohortForNavigation: (items, source) => summarizeAwarenessCohort(items, source),
isFlightLayer: (id) => id === 'flights' || id === 'military',
},
rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} },
panel: { renderResults() {} },
};
parts.queries = createQueries({ state: layerState, services, parts });
parts.model = createModel({ state: layerState, services, parts });
const subject = createSubject({ state: layerState, services, parts });
const vesselsSource = parts.queries.collectSourceStates()['ais-live-vessels'];
assert.equal(vesselsSource.available, false, 'never-answered source is unavailable');
subject.refreshSelectedSubject(true);
const ais = layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels');
assert.equal(ais.sourceStatus, null, 'no snapshot yet: no STALE label, Layers says LOADING');
} finally {
_setVesselStateForTest({ enabled: false });
_setAisRuntimeForTest();
}
});
+2
View File
@@ -26,6 +26,8 @@ export function createState({ services }) {
// layers activate only after Contacts is explicitly selected.
passive: true,
ownedDependencies: new Set(),
/** Stops following the manager's adoptions (see watchAdoptions). */
unwatchAdoptions: null,
subject: null,
results: null,
visual: null,
+17
View File
@@ -3,6 +3,7 @@ import {
AWARENESS_RELATIONSHIP,
} from '../../data/militaryAwarenessEngine.js';
import * as Cesium from 'cesium';
import { layerFeedState } from '../../data/feedState.js';
import { t } from '../../i18n/index.js';
import {
AWARENESS_QUERY_LIMIT,
@@ -52,6 +53,18 @@ export function createSubject({ state: layerState, services, parts, source }) {
const vesselsState = sourceStates['ais-live-vessels'];
const installationsState = sourceStates['military-installations'];
const evaluatedAt = Date.now();
// A poll in flight keeps FALLBACK or STALE: the revision key ignores loading.
// Before the first answer there is no snapshot, so loading stays.
const sourceStatus = (sourceState) => {
const feedState = layerFeedState(
sourceState.available
? { ...sourceState.stats, loading: false }
: sourceState.stats,
);
return feedState === 'fallback' || feedState === 'stale'
? feedState
: null;
};
const retainedProvenance = (id, label, sourceState, fallbackSource) => {
const retainedSource = sourceState.stats.source || fallbackSource;
return {
@@ -94,6 +107,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'flights',
label: t('Flights'),
source: flightsState.stats.source || SOURCE_LABEL.flights,
sourceStatus: sourceStatus(flightsState),
provenance: retainedProvenance(
'flights',
'Flights',
@@ -113,6 +127,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'military',
label: t('Military flights'),
source: militaryState.stats.source || SOURCE_LABEL.military,
sourceStatus: sourceStatus(militaryState),
provenance: retainedProvenance(
'military',
'Military flights',
@@ -132,6 +147,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
id: 'ais-live-vessels',
label: t('AIS vessels'),
source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'],
sourceStatus: sourceStatus(vesselsState),
provenance: retainedProvenance(
'ais-live-vessels',
'AIS vessels',
@@ -149,6 +165,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
source:
installationsState.stats.source ||
SOURCE_LABEL['military-installations'],
sourceStatus: sourceStatus(installationsState),
provenance: retainedProvenance(
'military-installations',
'Mapped installations',
+75 -6
View File
@@ -12,20 +12,28 @@
* Cameras without an identified model are untouched — unknown never implies
* anything.
*
* Spec records are vendored verbatim from the CCTV Camera Database
* Specification fields are vendored from the CCTV Camera Database
* (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset
* of 28,000+ camera models transcribed from manufacturer datasheets (84% with
* FOV, as of v2.26.0). This module vendors the records that the currently integrated public
* of 29,000+ camera models transcribed from manufacturer datasheets (76% with
* FOV, as of v2.29.0). This module vendors records that the integrated public
* catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis
* Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that
* appear in Sarasota County FL and Sioux Falls SD open data; packs that
* appear in Sarasota County FL and Sioux Falls SD open data, plus the Bosch MIC
* PTZs for the City of Amsterdam camera register proposed in #251; packs that
* publish other hardware models can grow it, or vendor the full extract.
*
* Scope guardrail: records describe camera PRODUCTS (optics, resolution,
* night vision) — never endpoints, stream paths, or access/config data.
*/
/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */
/**
* Records from the dataset's model-enrichment extract: v2.26.0, plus the Bosch
* MIC records from v2.29.0 with titles shortened to model codes.
* A Bosch MIC order code ends in a colour letter (B, G, W; registries write
* `x`) on one shared datasheet. The colours are aliases of one record whose
* `model` leaves the letter out, so the HUD never names a variant the source
* did not.
*/
const RECORDS = [
{
id: 'axis-q6135-le',
@@ -264,6 +272,67 @@ const RECORDS = [
autotracking: true,
environment: ['outdoor'],
},
{
id: 'bosch-mic-7130-pb4',
brand: 'Bosch',
model: 'MIC-7130-P_4',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-mic-7130-pb4/',
aliases: [
'MIC IP starlight 7000 HD',
'MIC-7130-PB4',
'MIC-7130-PG4',
'MIC-7130-PW4',
'MIC-7130-Px4',
],
fov_deg: '2.1-59',
resolution: { mp: 1, w: 1280, h: 720, label: '720p' },
ptz: true,
autotracking: true,
varifocal: true,
environment: ['outdoor'],
},
{
id: 'bosch-mic-7502-z30b',
brand: 'Bosch',
model: 'MIC-7502-Z30',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-mic-7502-z30b/',
aliases: [
'MIC-7502-Z30B',
'MIC-7502-Z30G',
'MIC-7502-Z30W',
'MIC-7502-Z30x',
],
fov_deg: '2.3-63.7',
resolution: { mp: 2.13, w: 1920, h: 1080, label: '1080p' },
ptz: true,
autotracking: true,
varifocal: true,
night_vision: { type: 'color' },
environment: ['outdoor'],
},
{
id: 'bosch-mic-7522-z30b',
brand: 'Bosch',
model: 'MIC-7522-Z30',
type: 'ptz',
url: 'https://www.cctv-database.com/camera/bosch-mic-7522-z30b/',
aliases: [
'MIC-7522-Z30B',
'MIC-7522-Z30G',
'MIC-7522-Z30W',
'MIC-7522-Z30x',
],
fov_deg: '2.1-58.3',
// i18n-ignore: datasheet label
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
ptz: true,
varifocal: true,
night_vision: { type: 'color' },
environment: ['outdoor'],
ndaa_compliant: true,
},
{
id: 'cohu-3920-idome',
brand: 'Cohu',
@@ -431,7 +500,7 @@ const NAME_INDEX = RECORDS.flatMap((record) => {
* guesses).
* @param {string} model - Hardware model as named by a source pack or seed.
* @param {string} [manufacturer] - The source's manufacturer, when published.
* @returns {Object|null} The verbatim spec record, or null when unknown.
* @returns {Object|null} The matched spec record, or null when unknown.
*/
export function lookupModelSpec(model, manufacturer) {
const query = words(model).map(stripVariantSuffix);
+89
View File
@@ -245,6 +245,95 @@ test('King County snapshot model strings still resolve', () => {
);
});
test('Amsterdam camera register model strings resolve', () => {
// The register writes the Bosch MIC colour letter as `x`.
assert.equal(
lookupModelSpec('Bosch MIC-7522-Z30x')?.id,
'bosch-mic-7522-z30b',
);
assert.equal(
lookupModelSpec('Bosch MIC-7502-Z30x')?.id,
'bosch-mic-7502-z30b',
);
assert.equal(lookupModelSpec('Bosch MIC-7130-Px4')?.id, 'bosch-mic-7130-pb4');
assert.equal(lookupModelSpec('MIC-7522-Z30W')?.id, 'bosch-mic-7522-z30b');
// Its ANPR units are not in the table: unknown, never a guess.
assert.equal(lookupModelSpec('CSG-8800'), null);
// The MIC and AUTODOME starlight 7000 HD (code-less names, so the brand
// is needed) stay apart.
assert.equal(
lookupModelSpec('MIC IP starlight 7000 HD', 'Bosch')?.id,
'bosch-mic-7130-pb4',
);
assert.equal(
lookupModelSpec('AUTODOME IP starlight 7000 HD', 'Bosch')?.id,
'bosch-autodome-ip-starlight-7000-hd',
);
// The MIC datasheets state FOV without an axis, so no horizontal range.
assert.equal(horizontalFovRangeDeg(lookupModelSpec('MIC-7522-Z30B')), null);
});
test('Bosch MIC colour aliases resolve without accepting unknown variants', () => {
for (const [pattern, id] of [
['MIC-7130-P?4', 'bosch-mic-7130-pb4'],
['MIC-7502-Z30?', 'bosch-mic-7502-z30b'],
['MIC-7522-Z30?', 'bosch-mic-7522-z30b'],
]) {
for (const colour of ['B', 'G', 'W', 'x']) {
const model = pattern.replace('?', colour);
assert.equal(lookupModelSpec(model)?.id, id, model);
assert.equal(lookupModelSpec(`bosch ${model.toLowerCase()}`)?.id, id);
assert.equal(lookupModelSpec(model, 'Bosch')?.id, id);
assert.equal(lookupModelSpec(model, 'Axis'), null);
assert.equal(lookupModelSpec(`${model}-UNKNOWN`), null);
}
assert.equal(lookupModelSpec(pattern.replace('?', 'R')), null);
}
});
test('Bosch MIC HUD summary never names a colour the source did not', () => {
assert.equal(
specSummary(lookupModelSpec('MIC-7522-Z30W')),
'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ',
);
assert.equal(
specSummary(lookupModelSpec('Bosch MIC-7522-Z30x')),
'Bosch MIC-7522-Z30 · 1080p HD · COLOR · PTZ',
);
for (const pattern of ['MIC-7130-P?4', 'MIC-7502-Z30?', 'MIC-7522-Z30?']) {
for (const colour of ['B', 'G', 'W', 'x']) {
const spec = lookupModelSpec(pattern.replace('?', colour));
const summary = specSummary(spec);
for (const other of ['B', 'G', 'W', 'x']) {
assert.ok(!summary.includes(pattern.replace('?', other)), summary);
}
}
}
});
test('Bosch MIC catalog enrichment preserves estimated and explicit optics', () => {
for (const model of [
'Bosch MIC-7130-Px4',
'Bosch MIC-7502-Z30x',
'Bosch MIC-7522-Z30x',
]) {
for (const pose of [{}, { fovDeg: 44 }, { poseSource: 'curated' }]) {
const [camera] = browserCatalog().buildCatalogFromSources([
{ id: 'amsterdam-mic', lat: 52.37, lon: 4.9, model, ...pose },
]);
assert.ok(camera.spec, model);
assert.equal(camera.fovDeg, pose.fovDeg ?? 74, model);
assert.equal(camera.fovSource, undefined);
assert.equal(camera.specFovMinDeg, undefined);
assert.equal(camera.specFovMaxDeg, undefined);
assert.equal(horizontalFovRangeDeg(camera.spec), null);
assert.equal(fixedHorizontalFovDeg(camera.spec), null);
assert.equal(specFovCapabilityToken(camera), null);
assert.match(specSummary(camera.spec), /^Bosch MIC-.* · PTZ$/);
}
}
});
test('catalog never overwrites an explicit FOV or a curated pose', () => {
const [explicit, curated, cohu, pelco] =
browserCatalog().buildCatalogFromSources([
+88
View File
@@ -0,0 +1,88 @@
/**
* Drapes the Active Fires heat raster over the globe as one full-extent
* imagery tile on the active imagery host (globe or 3D-tiles), like the wind
* layer's scalar field. Owns the canvas and the imagery layer; nothing else
* in the fires layer touches imagery.
*
* `show` is only for the layer being on or off. The zoom hand-off fades
* `alpha` instead: Cesium unmaps a hidden imagery layer from the globe
* tiles and re-maps it when shown, so a show toggle at the band edge left
* a beat with neither markers nor heat. At alpha 0 the layer stays mapped
* and costs no draw.
*/
const defaultCanvas = (width, height) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
};
export function createHeatImagery({
cesium,
getHost,
createCanvas = defaultCanvas,
}) {
let raster = null;
let layer = null;
let collection = null;
let show = true;
let opacity = 1;
function detach() {
if (layer && collection) collection.remove(layer, true);
layer = null;
collection = null;
}
function attach() {
detach();
if (!raster) return;
const host = getHost?.() ?? { collection: null, kind: 'none' };
if (!host.collection) return;
const canvas = createCanvas(raster.width, raster.height);
const ctx = canvas.getContext('2d');
const image = ctx.createImageData(raster.width, raster.height);
image.data.set(raster.rgba);
ctx.putImageData(image, 0, 0);
const provider = new cesium.SingleTileImageryProvider({
url: canvas.toDataURL('image/png'),
tileWidth: raster.width,
tileHeight: raster.height,
rectangle: cesium.Rectangle.MAX_VALUE,
});
collection = host.collection;
layer = collection.addImageryProvider(provider);
layer.show = show;
layer.alpha = opacity;
collection.raiseToTop?.(layer);
}
return {
setRaster(next) {
raster = next;
attach();
},
/** True once a raster was handed over (painted or waiting for a host). */
hasRaster() {
return raster !== null;
},
setShow(value) {
show = Boolean(value);
if (layer) layer.show = show;
},
/** Hand-off fade, 0..1; the layer stays mapped at 0. */
setOpacity(value) {
opacity = Math.min(1, Math.max(0, Number(value) || 0));
if (layer) layer.alpha = opacity;
},
rehome() {
const host = getHost?.();
if ((host?.collection ?? null) !== collection) attach();
},
destroy() {
detach();
raster = null;
},
};
}
+128
View File
@@ -0,0 +1,128 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHeatImagery } from './heatImagery.js';
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
import { HEAT_RASTER } from './policy.js';
function harness() {
const layers = [];
const removed = [];
const collection = {
addImageryProvider(provider) {
const layer = { provider, show: true, alpha: 1 };
layers.push(layer);
return layer;
},
remove(layer, destroy) {
layers.splice(layers.indexOf(layer), 1);
removed.push({ layer, destroy });
},
raiseToTop() {},
};
let host = { collection, kind: 'globe' };
const cesium = {
Rectangle: { MAX_VALUE: { max: true } },
SingleTileImageryProvider: class {
constructor(options) {
this.options = options;
}
},
};
const createCanvas = (w, h) => ({
width: w,
height: h,
getContext: () => ({
createImageData: (cw, ch) => ({
data: new Uint8ClampedArray(cw * ch * 4),
}),
putImageData() {},
}),
toDataURL: () => 'data:image/png;base64,AAAA',
});
const imagery = createHeatImagery({
cesium,
getHost: () => host,
createCanvas,
});
return { imagery, layers, removed, collection, setHost: (h) => (host = h) };
}
const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) };
test('setRaster drapes one full-globe tile and replaces it on the next build', () => {
const h = harness();
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 1);
assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true });
assert.equal(h.layers[0].provider.options.tileWidth, 4);
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 1, 'never two heat layers');
assert.equal(h.removed[0].destroy, true);
});
test('setShow toggles without rebuilding', () => {
const h = harness();
h.imagery.setRaster(raster);
const layer = h.layers[0];
h.imagery.setShow(false);
assert.equal(layer.show, false);
h.imagery.setShow(true);
assert.equal(h.layers[0], layer);
assert.equal(layer.show, true);
});
test('setOpacity fades the same layer and survives a rebuild', () => {
const h = harness();
h.imagery.setRaster(raster);
const layer = h.layers[0];
h.imagery.setOpacity(0);
assert.equal(layer.alpha, 0);
assert.equal(layer.show, true, 'a faded layer stays mapped');
h.imagery.setOpacity(0.25);
assert.equal(h.layers[0], layer);
assert.equal(layer.alpha, 0.25);
h.imagery.setRaster(raster);
assert.equal(h.layers[0].alpha, 0.25, 'a rebuilt layer keeps the fade');
h.imagery.setOpacity(7);
assert.equal(h.layers[0].alpha, 1);
});
test('no imagery host keeps nothing draped; rehome brings it back', () => {
const h = harness();
h.setHost({ collection: null, kind: 'none' });
h.imagery.setRaster(raster);
assert.equal(h.layers.length, 0);
h.setHost({ collection: h.collection, kind: 'globe' });
h.imagery.rehome();
assert.equal(h.layers.length, 1);
});
test('destroy removes the layer', () => {
const h = harness();
h.imagery.setRaster(raster);
h.imagery.destroy();
assert.equal(h.layers.length, 0);
});
test('the shipped raster paints a lone weak fire as a distinct spot', () => {
// A hot cluster elsewhere sets the scale, as Africa does on real data.
const fires = [{ lat: 0, lon: 0, frp: 5 }];
for (let i = 0; i < 40; i++) {
fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 });
}
const r = buildHeatRaster(fires, HEAT_RASTER);
const y = Math.floor((90 / 180) * r.height);
const painted = [];
for (let x = 0; x < r.width; x++) {
const lon = ((x + 0.5) / r.width) * 360 - 180;
if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) {
painted.push(x);
}
}
const kmPerPx = 40075 / r.width;
assert.ok(painted.length > 0, 'the weak fire still shows');
assert.ok(
painted.length * kmPerPx <= 100,
`spot ${Math.round(painted.length * kmPerPx)} km wide`,
);
});
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLifecycle } from './lifecycle.js';
test('reenabling an empty filtered layer never reveals the retained pre-filter heat raster', async () => {
let shown = false;
const noop = () => {};
const layerState = {
_enabled: false,
_viewer: {},
_dataSource: {},
_fires: [],
_loading: true,
_pickIndexById: new Map(),
_heatImagery: {
setShow(v) {
shown = v;
},
},
};
const lifecycle = createLifecycle({
layerState,
services: {
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
sprites: { restoreSpriteOrderOnEnable: noop },
context: { clearSelectedEntityContextForLayer: noop },
},
config: { id: 'local-firms', overlayHost: { setVisible: noop } },
components: {
rendering: { applyHandoffFade: noop },
viewport: { installLodWatcher: noop, installMoveEndWatcher: noop },
selection: { installClickHandler: noop },
},
});
await lifecycle.methods.enable(layerState._viewer);
assert.equal(
shown,
false,
'empty current set must keep the old raster hidden while refresh is pending',
);
layerState._fires = [{ frp: null, confidence: null }];
await lifecycle.methods.enable(layerState._viewer);
assert.equal(shown, true, 'nonempty sets still resume their raster');
});
+28 -5
View File
@@ -1,5 +1,6 @@
import { adaptFirmsRecords } from '../../data/firmsAdapt.js';
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { filterFires } from '../../data/firmsIntensity.js';
import { msg } from '../../i18n/index.js';
export function createIngestion({
@@ -40,12 +41,13 @@ export function createIngestion({
layerState._stale = Boolean(payload?.stale);
const previousSelection = layerState._selectedFire;
layerState._selectedFire = null;
layerState._fires = adaptFirmsRecords(payload?.fires);
layerState._cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells
layerState._firesByFrp = [...layerState._fires].sort(
layerState._allFires = adaptFirmsRecords(payload?.fires);
// Sorted once per payload; applyFilter only filters, so a slider drag
// over 200k detections never re-sorts.
layerState._allFiresByFrp = [...layerState._allFires].sort(
(a, b) => b.frp - a.frp,
);
layerState._count = layerState._fires.length;
applyFilter();
// Data age, not response age: a stale proxy payload truthfully reads old.
layerState._lastUpdate = Number.isFinite(payload?.fetchedAt)
? payload.fetchedAt
@@ -65,6 +67,7 @@ export function createIngestion({
}
components.rendering.renderCurrentLod(true);
if (reselected) components.selection.selectFire(reselected, false);
layerState._rowControlsListener?.();
} catch (error) {
if (
request.signal.aborted ||
@@ -83,6 +86,26 @@ export function createIngestion({
}
}
/**
* Run the intensity filter over the loaded payload. This is the ONE place
* the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards,
* the heatmap, the context store, the top-fires query and the analyst
* records, so none of them can disagree about which fires exist. Cells and
* the heat raster are built per shown set, so their caches go with it.
*/
function applyFilter() {
layerState._fires = filterFires(layerState._allFires, layerState._filter);
layerState._cellCacheByGrid.clear();
layerState._heatRaster = null;
layerState._firesByFrp = filterFires(
layerState._allFiresByFrp,
layerState._filter,
);
layerState._count = layerState._fires.length;
layerState._totalCount = layerState._allFires.length;
}
/**
* Find the record in the freshly-loaded set matching a previous selection.
* Identity is the detection itself (lat/lon/acquisition time) — indices
@@ -109,5 +132,5 @@ export function createIngestion({
},
};
return { loadHeatmap, findMatchingFire, methods };
return { loadHeatmap, applyFilter, findMatchingFire, methods };
}
+30
View File
@@ -14,6 +14,20 @@ export function createLifecycle({
const { id, overlayHost } = config;
const methods = {
/**
* Shell services: `imageryHost()` resolves where draped imagery goes on
* the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The
* heatmap moves with it; without a host it drapes on viewer.imageryLayers.
* @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services
*/
attachShellServices(services) {
layerState._imageryHost =
typeof services?.imageryHost === 'function'
? services.imageryHost
: null;
layerState._heatImagery?.rehome();
},
init(viewer) {
if (layerState._destroyed) return;
layerState._viewer = viewer;
@@ -40,6 +54,10 @@ export function createLifecycle({
layerState._billboards.show = true;
}
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true);
// A filter can leave an old raster cached while the current set is empty.
layerState._heatImagery?.setShow(layerState._fires.length > 0);
// The camera can have crossed the hand-off while the layer was off.
components.rendering.applyHandoffFade(true);
components.viewport.installLodWatcher();
components.viewport.installMoveEndWatcher();
components.selection.installClickHandler();
@@ -64,6 +82,7 @@ export function createLifecycle({
components.selection.clearFireSelection();
if (layerState._dataSource) layerState._dataSource.show = false;
if (layerState._billboards) layerState._billboards.show = false;
layerState._heatImagery?.setShow(false);
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false);
clearSelectedEntityContextForLayer(id);
@@ -91,10 +110,21 @@ export function createLifecycle({
components.rendering.removeDetectionCollections(viewer);
components.selection.clearContextRegistrations();
clearSelectedEntityContextForLayer(id);
layerState._allFires = [];
layerState._allFiresByFrp = [];
layerState._fires = [];
layerState._firesByFrp = [];
layerState._cellCacheByGrid.clear();
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = null;
layerState._heatImagery?.destroy();
layerState._heatImagery = null;
layerState._heatRaster = null;
layerState._handoffHeat = -1;
layerState._imageryHost = null;
layerState._count = 0;
layerState._totalCount = 0;
layerState._rowControlsListener = null;
layerState._cellCount = 0;
layerState._lastUpdate = null;
layerState._keyRequired = false;
+43 -27
View File
@@ -6,6 +6,7 @@ import {
accentForSeverity,
formatFrpMw,
} from '../../data/firmsLabels.js';
import { FRP_RAMP, frpRampStop } from '../../data/firmsIntensity.js';
import {
VIEW_PADDING,
DETECTION_COLOR_STOPS,
@@ -119,27 +120,30 @@ export function createModel({
);
}
function heatColor(value, alpha) {
if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha);
if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha);
return Cesium.Color.YELLOW.withAlpha(alpha);
/**
* The ramp stop for a fire radiative power (stepped, weakest first).
* Intensity alone drives the color: confidence feeds the card text and the
* switch, brightness is unreliable (saturates at 367 K) — see HAL-675.
* @param {number} frp - Megawatts.
* @returns {{name: string, minFrp: number, color: Cesium.Color}}
*/
function frpColorStop(frp) {
const name = frpRampStop(frp).name;
return (
DETECTION_COLOR_STOPS.find((stop) => stop.name === name) ||
DETECTION_COLOR_STOPS[0]
);
}
/**
* Pick a sprite color stop from FRP + confidence, reusing the same
* yellow → orange → red thresholds as the aggregated heat cells.
* Pick a sprite color stop for one detection from its FRP on the shared ramp.
* @param {Object} fire - Detection record.
* @returns {{name: string, color: Cesium.Color}}
*/
function detectionColorStop(fire) {
const heat = Math.min(
1,
Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15,
);
if (heat > 0.72) return DETECTION_COLOR_STOPS[0];
if (heat > 0.42) return DETECTION_COLOR_STOPS[1];
return DETECTION_COLOR_STOPS[2];
return frpColorStop(fire?.frp);
}
/** FRP → core marker pixel size, clamped to 8..28px. */
@@ -227,6 +231,19 @@ export function createModel({
return fire.position;
}
/**
* The anchor already built for a detection, without the floor lookup;
* for the far marker band, where floors are never warmed and the height
* difference is invisible, so tens of thousands of markers skip a cache
* read each per rebuild. Builds one through {@link firePosition} once.
* @param {Object} fire - Detection record.
* @returns {Cesium.Cartesian3}
*/
function fireBuiltPosition(fire) {
return fire.position ?? firePosition(fire);
}
/**
* Occlusion-test anchor for one detection. Returns the render position
* outright for anchors comfortably above the ellipsoid (the common case) and
@@ -234,11 +251,11 @@ export function createModel({
* NEVER feeds rendering: the datum-correct anchor from {@link firePosition}
* is what the sprite and the card are drawn at.
* @param {Object} fire - Detection record.
* @param {Cesium.Cartesian3} [position] Its render anchor, if at hand.
* @returns {Cesium.Cartesian3}
*/
function fireCullPosition(fire) {
const position = firePosition(fire);
function fireCullPosition(fire, position = firePosition(fire)) {
if (fire.positionHeight >= CULL_LIFT_THRESHOLD_M) return position;
if (!fire.cullPosition) {
fire.cullPosition = Cesium.Cartesian3.fromDegrees(
@@ -398,8 +415,8 @@ export function createModel({
/**
* Card model for an aggregated heat cell, e.g. title "14 FIRES", detail
* "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized
* score is only known at renderCells time). Exported for unit tests.
* "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets
* it with {@link cellCardAccent}). Exported for unit tests.
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
* @param {number} nowMs - Current epoch milliseconds.
* @returns {Object} firmsLabels entry.
@@ -417,7 +434,7 @@ export function createModel({
position: candidate.position,
cullPosition: candidate.cullPosition || candidate.position,
gapPx: 10,
accent: candidate.accent || accentForSeverity('yellow'),
accent: candidate.accent || accentForSeverity(FRP_RAMP[0].name),
// The count picks the plural form of the language ("حريقان (2)",
// "14 حريقًا", "14 ПОЖАРОВ"); the card shows it in capitals like the
// other card titles.
@@ -464,16 +481,14 @@ export function createModel({
}
/**
* Heat-normalized cell score → severity accent, mirroring heatColor's
* red/orange/yellow thresholds.
* @param {number} normalized - 0..1 normalized heat score.
* Cell card accent: the cell's strongest fire on the FRP ramp, the same
* color its markers take once the camera is close enough to show them.
* @param {{maxFrp?: number}} cell - Aggregated cell.
* @returns {string} "r, g, b" accent string.
*/
function cellAccent(normalized) {
if (normalized > 0.72) return accentForSeverity('red');
if (normalized > 0.42) return accentForSeverity('orange');
return accentForSeverity('yellow');
function cellCardAccent(cell) {
return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name);
}
/** Coordinate line like "30.512°N 75.831°E". */
@@ -579,12 +594,13 @@ export function createModel({
boundsContainPoint,
cellIntersectsBounds,
heatScore,
heatColor,
frpColorStop,
detectionColorStop,
frpPixelSize,
sizeBucket,
glowSprite,
firePosition,
fireBuiltPosition,
fireCullPosition,
cellCullPosition,
applyHorizonCull,
@@ -593,7 +609,7 @@ export function createModel({
buildFireCard,
buildCellCard,
applyFirmsOverlayPolicy,
cellAccent,
cellCardAccent,
formatLatLon,
formatFrp,
formatAge,
+57 -13
View File
@@ -1,4 +1,5 @@
import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js';
import { FRP_RAMP } from '../../data/firmsIntensity.js';
import * as Cesium from 'cesium';
/** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */
@@ -6,9 +7,10 @@ import * as Cesium from 'cesium';
export const REFRESH_INTERVAL_MS = 600_000;
/**
* LOD bands keyed by camera height. `cells` bands render aggregated
* ground-clamped heat rectangles; `detections` bands (< ~750km plus the
* local band) render individual fire detections as glow-sprite billboards.
* LOD bands keyed by camera height. `heat` bands (above ~4,500 km) drape a
* Snap Map–style heatmap over the globe (firmsHeatRaster.js); their
* `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections`
* bands render individual fire detections as glow-sprite billboards.
* Labels in every band go through the shared screen-space greedy declutter
* (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs.
*/
@@ -17,24 +19,28 @@ export const LOD_LEVELS = [
{
id: 'global',
minHeight: 9000000,
mode: 'cells',
mode: 'heat',
gridDegrees: 2.0,
maxCells: 1800,
labelDistance: 12000000,
},
{
id: 'regional',
minHeight: 3000000,
mode: 'cells',
minHeight: 4500000,
mode: 'heat',
gridDegrees: 1.0,
maxCells: 3600,
labelDistance: 8500000,
},
{
// Up to 40,000 markers: at the 3,600 km end of the hand-off the padded
// view over Africa, the densest region, holds ~40,000 detections, and a
// tighter cap left the heatmap's weak spots dissolving into nothing.
// A rebuild of that many costs ~60 ms, only there.
id: 'local',
minHeight: 750000,
mode: 'detections',
maxDetections: 2500,
maxDetections: 40000,
labelDistance: 4500000,
},
{
@@ -46,6 +52,38 @@ export const LOD_LEVELS = [
},
];
/**
* Global heat raster for the `heat` bands: ≈0.09° (≈10 km) per pixel and a
* one-pixel Gaussian σ, so a lone fire is a spot about the size of its
* marker near the hand-off and the two cross-fade without a jump;
* neighbours merge only when they nearly touch (σ 3 on a 2048 grid smeared
* each fire over ~200 km). Built once per data refresh or filter change,
* never per camera move.
*/
export const HEAT_RASTER = Object.freeze({
width: 4096,
height: 2048,
sigmaPx: 1,
});
/**
* Camera heights, in metres, over which the heatmap and the markers
* cross-fade around the 4,500 km band edge: markers alone below `from`,
* heatmap alone above `to`. Wider than the ±10 % band hysteresis, so
* whichever band is active inside it, both layers are already built.
*/
export const HANDOFF_FADE = Object.freeze({ from: 3_600_000, to: 5_400_000 });
/**
* Trailing delay before a stale heatmap is rebuilt while one is already on
* screen: a slider drag fires many filter changes, and a build plus PNG
* encode costs a few hundred milliseconds. The first paint is immediate.
*/
export const HEAT_REBUILD_MS = 150;
export const LOD_CHECK_MS = 650;
/** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */
@@ -88,10 +126,16 @@ export const CULL_LIFT_THRESHOLD_M = 10;
export const CULL_LIFT_M = 12;
/** Color stops shared by cell heat fills and detection glow sprites. */
/**
* Color stops for detection glow sprites and card accents: the
* six-stop ember → white-hot FRP ramp (firmsIntensity.js), weakest first, as Cesium
* colors. Stop names key the glow-sprite cache and the card accents.
*/
export const DETECTION_COLOR_STOPS = [
{ name: 'red', color: Cesium.Color.RED },
{ name: 'orange', color: Cesium.Color.ORANGE },
{ name: 'yellow', color: Cesium.Color.YELLOW },
];
export const DETECTION_COLOR_STOPS = FRP_RAMP.map((stop) =>
Object.freeze({
name: stop.name,
minFrp: stop.minFrp,
color: Cesium.Color.fromCssColorString(stop.hex),
}),
);
+161 -2
View File
@@ -1,6 +1,18 @@
import { fireDetectionKey } from '../../data/firmsLabels.js';
import { t } from '../../i18n/index.js';
import { REFRESH_INTERVAL_MS } from './policy.js';
import {
FIRMS_CONFIDENCE_MODES,
FRP_SLIDER_TICKS,
firePassesFilter,
frpForSliderPosition,
normalizeFireFilter,
rampTrackGradient,
sliderPositionForFrp,
} from '../../data/firmsIntensity.js';
import { formatNumber, t } from '../../i18n/index.js';
import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js';
/** Slider resolution: whole-percent positions along the log axis. */
const SLIDER_STEPS = 1000;
export function createQueries({
layerState,
@@ -11,6 +23,25 @@ export function createQueries({
}) {
const { id, name, icon, source } = config;
/** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */
function frpLabel(frp) {
return formatNumber(frp, { maximumFractionDigits: 0 });
}
const confidenceLabels = () => ({
all: t('All', { context: 'fire confidence filter' }),
nominal: t('Nominal+', { context: 'fire confidence filter' }),
high: t('High', { context: 'fire confidence filter' }),
});
const confidenceTitles = () => ({
all: t(
'Every detection — many of the strongest fires are rated low confidence',
),
nominal: t('Nominal and high confidence detections'),
high: t('High confidence detections only'),
});
const methods = {
/** Return the selected detection as a plain record for application consumers. */
getSelectedInfo() {
@@ -51,6 +82,110 @@ export function createQueries({
// not this interval — is what protects the upstream FIRMS quota).
updateInterval: REFRESH_INTERVAL_MS,
/**
* The intensity filter as layer options (share token `w`, owner
* `local-firms` in layerState.js).
* @returns {{minFrp: number, confidence: string}}
*/
getParams() {
return { ...layerState._filter };
},
/**
* Apply a (partial) intensity filter. Invalid fields keep their current
* value. The filter runs in one place (ingestion.applyFilter) and the LOD
* rebuild re-derives markers, cells, cards and context registrations from
* the shown set; a selected fire that no longer passes is deselected.
* @param {{minFrp?: number, confidence?: string}} params
* @returns {boolean} True — the request is always accepted.
*/
setParams(params = {}) {
const next = normalizeFireFilter(params, layerState._filter);
if (
next.minFrp === layerState._filter.minFrp &&
next.confidence === layerState._filter.confidence
)
return true;
layerState._filter = next;
if (!layerState._allFires.length) return true;
components.ingestion.applyFilter();
if (
layerState._selectedFire &&
!firePassesFilter(layerState._selectedFire, next)
) {
components.selection.clearFireSelection();
}
if (layerState._enabled) components.rendering.renderCurrentLod(true);
layerState._rowControlsListener?.();
return true;
},
/**
* Row controls under the Active Fires row: the log-scale minimum-FRP
* slider whose track is painted with the intensity ramp (so it doubles as
* the legend), the All / Nominal+ / High confidence switch, and the
* "N of M shown" counter.
*/
getRowControls() {
const { minFrp, confidence } = layerState._filter;
const labels = confidenceLabels();
const titles = confidenceTitles();
const shown = layerState._count;
const total = layerState._totalCount;
return {
slider: {
id: 'min-frp',
label: t('Fire intensity (FRP)'),
ariaLabel: t('Minimum fire radiative power, megawatts'),
valueText:
minFrp > 0
? t('≥ {value} MW', { value: frpLabel(minFrp) })
: t('All', { context: 'fire intensity filter' }),
min: 0,
max: SLIDER_STEPS,
step: 1,
value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS),
track: rampTrackGradient(),
ticks: FRP_SLIDER_TICKS.map((frp) => ({
at: sliderPositionForFrp(frp),
label: frpLabel(frp),
})),
params: (value) => ({
minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS),
}),
},
segmented: {
id: 'confidence',
label: t('Confidence'),
ariaLabel: t('Detection confidence'),
options: FIRMS_CONFIDENCE_MODES.map((mode) => ({
id: `confidence-${mode}`,
label: labels[mode],
title: titles[mode],
active: mode === confidence,
params: { confidence: mode },
})),
},
info: total
? t('{shown} of {total} shown', {
shown: formatNumber(shown),
total: formatNumber(total),
})
: '',
};
},
/**
* Install the panel's "row controls changed" callback: a load or a filter
* change moves the counter, and nothing else repaints the row before the
* next scheduled refresh.
* @param {(() => void)|null} listener
*/
setRowControlsListener(listener) {
layerState._rowControlsListener =
typeof listener === 'function' ? listener : null;
},
/**
* Layer stats for the data panel. Degraded feed states surface through
* `error` (established qa-failstate pattern: a dead feed must never look
@@ -84,7 +219,9 @@ export function createQueries({
});
}
return {
// Shown (filtered) detections; `total` is the loaded payload.
count: layerState._count,
total: layerState._totalCount,
cells: layerState._cellCount,
lastUpdate: layerState._lastUpdate,
loading: layerState._loading,
@@ -172,12 +309,34 @@ export function createQueries({
return result;
},
/** Test seam: render one named LOD band against a near-global view. */
_renderLodForTest(lodId) {
components.rendering.renderBand(
LOD_LEVELS.find((level) => level.id === lodId),
null,
);
},
/** Test seam: one frame of the hand-off fade at the current camera. */
_applyHandoffFadeForTest() {
components.rendering.applyHandoffFade();
},
/** Test seam: entities drawn by the layer's data source. */
_entityCountForTest() {
return layerState._dataSource?.entities?.values?.length ?? 0;
},
/** Test seam that binds the production click path and indexes. */
_bindInteractionForTest(viewer, fires = []) {
layerState._viewer = viewer;
layerState._enabled = true;
layerState._allFires = fires;
layerState._allFiresByFrp = [...fires];
layerState._fires = fires;
layerState._firesByFrp = [...fires];
layerState._count = fires.length;
layerState._totalCount = fires.length;
layerState._pickIndexById.clear();
layerState._labelLodDistance = 1e7;
layerState._labelCandidates = fires.map((fire) => {
+191 -83
View File
@@ -1,7 +1,21 @@
import * as Cesium from 'cesium';
import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js';
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
import {
LOD_LEVELS,
CONTEXT_TOP_N,
HANDOFF_FADE,
HEAT_RASTER,
HEAT_REBUILD_MS,
} from './policy.js';
import { createHeatImagery } from './heatImagery.js';
/** The markers drawn under the far bands' heatmap for the hand-off fade. */
const HANDOFF_MARKER_LOD = LOD_LEVELS.find((lod) => lod.mode === 'detections');
/** Fade steps: a moving camera rewrites marker colors at most this often. */
const FADE_STEPS = 32;
export function createRendering({
layerState,
@@ -29,8 +43,11 @@ export function createRendering({
!layerState._dataSource ||
!layerState._fires.length ||
!layerState._viewer
)
) {
// A filter that leaves nothing shown must not leave the old heat behind.
if (!layerState._fires.length) layerState._heatImagery?.setShow(false);
return false;
}
const lodIndex = components.viewport.selectLodIndex(
components.viewport.cameraHeight(),
);
@@ -51,14 +68,54 @@ export function createRendering({
? components.model.paddedDegreeBounds(viewRect)
: null;
if (lod.mode === 'detections') {
renderDetections(lod, bounds);
} else {
renderCells(aggregateFires(lod, bounds), lod, bounds);
}
renderBand(lod, bounds);
return true;
}
/**
* Draw one band. Markers and heatmap stay built in every band; the
* hand-off only fades between them.
* @param {Object} lod - Band descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
*/
function renderBand(lod, bounds) {
prepareHeat(lod.mode === 'heat');
if (lod.mode === 'detections') renderDetections(lod, bounds);
else renderHeat(lod, bounds);
applyHandoffFade(true);
}
const fadeColor = new Cesium.Color();
/**
* Cross-fade between markers and heatmap by camera height (HANDOFF_FADE),
* on every rendered frame, so a fast zoom never outruns the throttled band
* check. Cheap when nothing changed: one height read and a compare.
* @param {boolean} [force] Reapply, e.g. to freshly added markers.
*/
function applyHandoffFade(force = false) {
const height = components.viewport.cameraHeight();
const t = Math.min(
1,
Math.max(
0,
(height - HANDOFF_FADE.from) / (HANDOFF_FADE.to - HANDOFF_FADE.from),
),
);
const heat = Math.round(t * t * (3 - 2 * t) * FADE_STEPS) / FADE_STEPS;
if (!force && heat === layerState._handoffHeat) return;
layerState._handoffHeat = heat;
layerState._heatImagery?.setOpacity(heat);
const billboards = layerState._billboards;
if (!billboards) return;
billboards.show = layerState._enabled && heat < 1;
const color = Cesium.Color.WHITE.withAlpha(1 - heat, fadeColor);
for (let i = 0; i < billboards.length; i += 1)
billboards.get(i).color = color;
}
/**
* Bin all detections into grid cells, then viewport-clip BEFORE the
* top-N cap so fires in view never lose their slot to higher-scoring
@@ -118,65 +175,96 @@ export function createRendering({
return clipped;
}
/** The heat imagery adapter, created on first use. */
function heatImagery() {
if (!layerState._heatImagery) {
layerState._heatImagery = createHeatImagery({
cesium: Cesium,
getHost: () =>
layerState._imageryHost?.() ?? {
collection: layerState._viewer?.imageryLayers ?? null,
kind: 'globe',
},
});
}
return layerState._heatImagery;
}
/** Build the heat raster from the shown set and drape it. */
function rebuildHeatRaster() {
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = null;
if (layerState._destroyed) return;
layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER);
heatImagery().setRaster(layerState._heatRaster);
}
/**
* Render aggregated heat cells (global/regional bands) as ground-clamped
* rectangles. Cell labels do NOT live on the entities anymore — they go
* through the same unclamped, screen-space-decluttered label pipeline as
* detections (see {@link rebuildAmbientLabels}).
* @param {Array<Object>} cells - Aggregated cells, heat-sorted descending.
* Make sure the draped heatmap matches the shown set. The first paint in a
* far band is immediate; other builds (filter drags, refreshes, the
* preload under the markers) trail by HEAT_REBUILD_MS and keep the
* previous heatmap on screen meanwhile.
* @param {boolean} now Build at once when nothing is draped yet.
*/
function ensureHeatRaster(now) {
if (layerState._heatRaster) return;
if (now && !heatImagery().hasRaster()) {
rebuildHeatRaster();
return;
}
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = setTimeout(
rebuildHeatRaster,
HEAT_REBUILD_MS,
);
}
/**
* Keep the heatmap draped on the current map stack and current with the
* shown set in every band; the hand-off fade decides how much of it shows.
* @param {boolean} now The camera is in a far band.
*/
function prepareHeat(now) {
const imagery = heatImagery();
// The map stack may have changed since the last render.
imagery.rehome();
ensureHeatRaster(now);
imagery.setShow(layerState._enabled);
}
/**
* Far bands (global/regional): a Snap Map–style heatmap draped over the
* globe, transparent where nothing burns, plus the decluttered "N FIRES"
* cards from the coarse aggregation. The nearest band's markers are kept
* built underneath for the hand-off fade and hidden once it completes;
* cards go through the same screen-space label pipeline as detections
* (see {@link rebuildAmbientLabels}).
* @param {Object} lod - Active LOD descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
*/
function renderCells(cells, lod, bounds) {
ensureDetectionCollections();
layerState._dataSource.entities.removeAll();
if (layerState._billboards) layerState._billboards.removeAll();
layerState._pickIndexById.clear();
layerState._cullPositions.length = 0;
layerState._cellCount = cells.length;
const maxScore = Math.max(1, ...cells.map(components.model.heatScore));
function renderHeat(lod, bounds) {
paintMarkers(HANDOFF_MARKER_LOD, bounds);
const cells = aggregateFires(lod, bounds);
layerState._cellCount = cells.length;
layerState._labelCandidates = [];
layerState._labelLodDistance = lod.labelDistance;
// Cells arrive heat-sorted, so candidate order doubles as label priority.
for (const cell of cells) {
const score = components.model.heatScore(cell);
const normalized = Math.min(1, Math.sqrt(score / maxScore));
const alpha = 0.16 + normalized * 0.5;
const color = components.model.heatColor(normalized, alpha);
const centerLon = cell.lonCell + lod.gridDegrees / 2;
const centerLat = cell.latCell + lod.gridDegrees / 2;
const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0);
layerState._dataSource.entities.add({
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(
cell.lonCell,
cell.latCell,
cell.lonCell + lod.gridDegrees,
cell.latCell + lod.gridDegrees,
),
material: new Cesium.ColorMaterialProperty(color),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
position,
properties: {
count: cell.count,
intensity: score,
maxFrp: cell.maxFrp,
night: cell.night,
},
});
// Cells arrive heat-sorted, so candidate order doubles as label priority.
// Accent is resolved here because `normalized` only exists in this walk.
layerState._labelCandidates.push({
position,
position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0),
cullPosition: components.model.cellCullPosition(centerLon, centerLat),
fire: null,
cell,
accent: components.model.cellAccent(normalized),
accent: components.model.cellCardAccent(cell),
});
}
@@ -202,9 +290,48 @@ export function createRendering({
*/
function renderDetections(lod, bounds) {
const markers = paintMarkers(lod, bounds);
if (!markers) return;
const candidates = markers.map((marker) => marker.fire);
layerState._cellCount = candidates.length;
// FRP-sorted walk: marker order doubles as label priority.
layerState._labelCandidates = markers;
layerState._labelLodDistance = lod.labelDistance;
components.cards.rebuildAmbientLabels();
components.selection.refreshContextRegistrations(
candidates.slice(0, CONTEXT_TOP_N),
);
if (lod.id === 'close') {
// Batched (chunked ≤200, sequential, session-cached) DEM warm for the
// rendered subset; fires are static so each coarse cell resolves once
// ever. Re-render ONLY when a floor actually landed — a failed resolve
// reports false, so this chain terminates instead of looping against a
// down proxy (the next camera-driven rebuild retries).
warmFireAnchorFloors(candidates).then((warmed) => {
if (!warmed || !layerState._enabled || !layerState._viewer) return;
const currentLod = LOD_LEVELS[layerState._currentLodIndex];
if (!currentLod || currentLod.mode !== 'detections') return;
renderCurrentLod(true);
});
}
}
/**
* The glow-sprite markers for a detections band: the strongest fires in
* view, capped at the band's maxDetections, horizon-culled.
* @param {Object} lod - Detections band descriptor.
* @param {?Object} bounds - Padded view bounds in degrees, or null.
* @returns {?Array<Object>} Label candidates for the fires drawn,
* strongest first; null before the sprite collection exists.
*/
function paintMarkers(lod, bounds) {
ensureDetectionCollections();
layerState._dataSource.entities.removeAll();
if (!layerState._billboards) return;
if (!layerState._billboards) return null;
layerState._billboards.removeAll();
layerState._pickIndexById.clear();
layerState._cullPositions.length = 0;
@@ -226,17 +353,20 @@ export function createRendering({
candidates = layerState._firesByFrp.slice(0, lod.maxDetections);
}
layerState._cellCount = candidates.length;
layerState._labelCandidates = [];
layerState._labelLodDistance = lod.labelDistance;
const markers = [];
// Only the close band warms terrain floors (renderDetections).
const anchor =
lod.id === 'close'
? components.model.firePosition
: components.model.fireBuiltPosition;
for (const fire of candidates) {
const coreSize = components.model.frpPixelSize(fire.frp);
const position = components.model.firePosition(fire);
const cullPosition = components.model.fireCullPosition(fire);
const position = anchor(fire);
const cullPosition = components.model.fireCullPosition(fire, position);
const pickId = `firms-${fire.index}`;
layerState._pickIndexById.set(pickId, fire);
layerState._cullPositions.push(cullPosition);
markers.push({ position, cullPosition, fire, cell: null });
layerState._billboards.add({
id: pickId,
position,
@@ -248,37 +378,13 @@ export function createRendering({
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
// FRP-sorted walk: candidate order doubles as label priority.
layerState._labelCandidates.push({
position,
cullPosition,
fire,
cell: null,
});
}
// Freshly-added sprites default to show=true; cull the far side now so a
// rebuild never flashes through-the-planet detections while the camera
// sits still (the moveEnd/preRender hooks only fire on camera motion).
refreshHorizonCulling();
components.cards.rebuildAmbientLabels();
components.selection.refreshContextRegistrations(
candidates.slice(0, CONTEXT_TOP_N),
);
if (lod.id === 'close') {
// Batched (chunked ≤200, sequential, session-cached) DEM warm for the
// rendered subset; fires are static so each coarse cell resolves once
// ever. Re-render ONLY when a floor actually landed — a failed resolve
// reports false, so this chain terminates instead of looping against a
// down proxy (the next camera-driven rebuild retries).
warmFireAnchorFloors(candidates).then((warmed) => {
if (!warmed || !layerState._enabled || !layerState._viewer) return;
const currentLod = LOD_LEVELS[layerState._currentLodIndex];
if (!currentLod || currentLod.mode !== 'detections') return;
renderCurrentLod(true);
});
}
return markers;
}
function ensureDetectionCollections() {
@@ -344,9 +450,11 @@ export function createRendering({
}
return {
renderCurrentLod,
renderBand,
aggregateFires,
renderCells,
renderHeat,
renderDetections,
applyHandoffFade,
ensureDetectionCollections,
removeDetectionCollections,
fireHorizonOccluder,
+51
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js';
export function createFirmsState({ services, config }) {
const layerState = {};
@@ -39,12 +40,39 @@ export function createFirmsState({ services, config }) {
layerState._error = null;
/** Every adapted detection in the current payload, before the filter. */
layerState._allFires = [];
/** `_allFires` sorted by FRP descending, once per payload. */
layerState._allFiresByFrp = [];
/**
* The detections that pass the intensity filter — the ONLY set rendering,
* selection, cells, the top-fires query and the analyst records ever walk.
*/
layerState._fires = [];
layerState._firesByFrp = [];
/** Shown (filtered) detection count. */
layerState._count = 0;
/** Loaded detection count before the filter ("N of M shown"). */
layerState._totalCount = 0;
/** Minimum FRP (MW) and confidence mode; see firmsIntensity.js. */
layerState._filter = { ...DEFAULT_FIRE_FILTER };
/** Panel callback: repaint the row controls (counter) after a load. */
layerState._rowControlsListener = null;
layerState._cellCount = 0;
layerState._lastUpdate = null;
@@ -103,6 +131,29 @@ export function createFirmsState({ services, config }) {
layerState._cellCacheByGrid = new Map();
/**
* Heat raster for the far bands (firmsHeatRaster.js); null = stale, rebuilt
* on the next far render. Cleared with the cell cache on every new shown set.
*/
layerState._heatRaster = null;
/** Pending debounced heat rebuild (see HEAT_REBUILD_MS). */
layerState._heatRebuildTimer = null;
/** Last applied hand-off fade (0 markers … 1 heatmap); -1 forces a reapply. */
layerState._handoffHeat = -1;
/** Imagery adapter (heatImagery.js), created on the first far render. */
layerState._heatImagery = null;
/** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */
layerState._imageryHost = null;
/** Camera idle snapshot so the throttled LOD check is ~free when parked. */
layerState._camSnapValid = false;
+3
View File
@@ -151,6 +151,9 @@ export function createViewport({
// on screen throughout it — so the horizon pass below must keep running
// while a fetch is in flight. Only the rebuild is gated on fresh data.
if (!layerState._enabled) return;
// Every frame, ahead of the throttle: a fast zoom crosses the whole
// hand-off between two band checks.
components.rendering.applyHandoffFade();
const now = performance.now();
if (now - layerState._lastLodCheck < LOD_CHECK_MS) return;
layerState._lastLodCheck = now;
+6 -1
View File
@@ -12,6 +12,11 @@ import {
} from '../../data/transitFeeds.js';
import { transitModeTier } from '../../data/transitPresetStyle.js';
/** Regions, not feeds: one region can carry several feeds (Rio has two). */
const TRANSIT_REGION_COUNT = new Set(
TRANSIT_ENABLED_FEEDS.map((feed) => feed.region),
).size;
/**
* The registry entry behind a vehicle. Live feeds answer first so a feed the
* session disabled stops supplying vocabulary; the registry is the fallback,
@@ -249,7 +254,7 @@ export function createQueries({ state, parts }) {
// English data (the analyst's empty note to the model); the voice
// card shows it through NOTE_PATTERNS (voice/resultDisplay.js).
coverage: state._altitudeGateOpen
? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available`
? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available`
: `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`,
};
}
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createQueries } from './queries.js';
import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js';
test('the empty-coverage note counts regions, not feeds', () => {
const rio = TRANSIT_ENABLED_FEEDS.filter(
(feed) => feed.region === 'Rio de Janeiro, RJ',
);
assert.equal(rio.length, 2, 'Rio is one region with two feeds');
const regions = new Set(TRANSIT_ENABLED_FEEDS.map((feed) => feed.region));
assert.ok(regions.size < TRANSIT_ENABLED_FEEDS.length);
const stats = createQueries({
state: {
_activeFeeds: new Map(),
_vehicles: new Map(),
_enabled: true,
_altitudeGateOpen: true,
},
parts: {},
}).methods.getStats();
assert.equal(stats.status, 'zoom-in');
assert.equal(
stats.coverage,
`No feed here yet · ${regions.size} regions available`,
);
});
+12
View File
@@ -409,3 +409,15 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy
assert.ok(layer.getStats().error, JSON.stringify(patch));
}
});
test('vessel stats name the provider the last snapshot reported', async () => {
const { layer } = setup({
label: 'Vessels',
async getSnapshot() {
return snapshot([observation('111')], { source: 'Datalastic' });
},
});
assert.equal(layer.getStats().source, 'Vessels');
await layer.update();
assert.equal(layer.getStats().source, 'Datalastic');
});
+2
View File
@@ -548,6 +548,8 @@ export function createQueries({
// backing off, say how long until the next attempt instead of leaving
// the user to guess whether anything is still happening.
retryInSec: aisRetryInSec(),
// Same field as flights and military, so Contacts names the provider.
source: layer.source,
};
},
};
+21 -1
View File
@@ -11,6 +11,9 @@ const INDEX_HTML = path.join(REPO_ROOT, 'index.html');
/** The glyph written as element text: `<span class="material-symbols-outlined">radar</span>`. */
const SPAN_TEXT =
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*</g;
/** A glyph chosen in a Vue template: `<span class="material-symbols-outlined">{{ a ? 'x' : 'y' }}</span>`. */
const SPAN_MUSTACHE =
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*\{\{([^}]{0,400})\}\}\s*</g;
/** A whole `textContent =` statement, across lines, so a multi-line ternary is read once. */
const TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs;
const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g;
@@ -64,7 +67,24 @@ function referencedGlyphs() {
};
for (const match of source.matchAll(SPAN_TEXT)) add(match[1]);
for (const match of source.matchAll(ICON_PROPERTY)) add(match[1]);
for (const match of source.matchAll(TEXT_ASSIGNMENT)) {
// A template glyph bound to a computed (`{{ entryIcon }}`) is whatever
// that computed returns.
for (const [, expression] of source.matchAll(SPAN_MUSTACHE)) {
const name = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(expression)?.[1];
if (!name) continue;
const body = new RegExp(
`\\b${name}\\s*=\\s*computed\\(\\(\\)\\s*=>\\s*\\{([\\s\\S]{0,600}?)\\n\\}\\);`,
).exec(source)?.[1];
for (const literal of body?.matchAll(
/\breturn\s+['"`]([a-z0-9_]{2,})['"`]/g,
) ?? [])
add(literal[1]);
}
const expressions = [
...source.matchAll(TEXT_ASSIGNMENT),
...source.matchAll(SPAN_MUSTACHE),
];
for (const match of expressions) {
const statement = match[1].replaceAll('?.', '.');
const assigned = statement.includes('?')
? statement.slice(statement.indexOf('?') + 1)
+1
View File
@@ -252,6 +252,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([
* written explicitly.
*/
export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({
'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }),
traffic: Object.freeze({ roadMode: null }),
'weather-lightning': Object.freeze({ opacity: 'strong' }),
'weather-radar': Object.freeze({ opacity: 'strong' }),
+60 -4
View File
@@ -4,6 +4,14 @@ import { suggestView } from '../views.js';
import { defineTool, ToolError } from '../catalog.js';
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
import {
FIRMS_CONFIDENCE_MODES,
MIN_FRP_MW_MAX,
MIN_FRP_MW_MIN,
firePassesFilter,
normalizeFireFilter,
} from '../../data/firmsIntensity.js';
import { normalizeConfidence } from '../../data/firmsAdapt.js';
function acquiredMsUtc(acqDate, acqTime) {
if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate))
@@ -87,10 +95,30 @@ export const getActiveFires = defineTool({
title: 'Active fires',
description:
'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' +
'area, most intense (fire radiative power) first.',
'area, most intense (fire radiative power) first. Optionally limited to a ' +
'minimum fire radiative power in megawatts and a confidence level — the ' +
"same filter as the Active Fires layer's intensity controls.",
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
properties: {
area: AREA_SCHEMA,
min_frp_mw: {
type: 'number',
minimum: MIN_FRP_MW_MIN,
maximum: MIN_FRP_MW_MAX,
description:
'Hide detections weaker than this fire radiative power (MW). ' +
'A typical detection is ~6 MW; 20 MW keeps the top tenth, 100 MW the top percent.',
},
confidence: {
type: 'string',
enum: [...FIRMS_CONFIDENCE_MODES],
description:
'"all" (default), "nominal" (nominal and high), or "high". Many of the ' +
'strongest fires are rated low confidence, so prefer "all" unless asked.',
},
limit: LIMIT_SCHEMA,
},
required: ['area'],
additionalProperties: false,
},
@@ -103,9 +131,31 @@ export const getActiveFires = defineTool({
'unavailable',
'Fire detections need a NASA FIRMS key configured for this server',
);
const filter = normalizeFireFilter({
minFrp: args.min_frp_mw,
confidence: args.confidence,
});
const fires = snapshot.fires
.filter((fire) => areaContains(area, fire))
.filter(
(fire) =>
areaContains(area, fire) &&
firePassesFilter(
{
frp: Number(fire.frp),
confidence: normalizeConfidence(fire.confidence),
},
filter,
),
)
.sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0));
const filterNotes = [
...(filter.minFrp > 0 ? [`≥ ${filter.minFrp} MW`] : []),
...(filter.confidence !== 'all'
? [
`${filter.confidence === 'high' ? 'high' : 'nominal or high'} confidence`,
]
: []),
];
const result = capRows(fires, args.limit);
// The proxy serves its last good snapshot when a refresh fails, and marks
// satellites whose download failed.
@@ -119,10 +169,16 @@ export const getActiveFires = defineTool({
];
return {
summary:
`${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` +
`${countNoun(fires.length, 'fire detection')}` +
(filterNotes.length ? ` (${filterNotes.join(', ')})` : '') +
` in ${area.label} in the last 24 hours` +
(notes.length ? ` (${notes.join('; ')}).` : '.'),
data: {
view: suggestView(services, { area, layers: ['local-firms'] }),
filter: {
min_frp_mw: filter.minFrp,
confidence: filter.confidence,
},
stale,
missing_sources: missing,
fetched_at: isoTime(snapshot.fetchedAt),
+36
View File
@@ -44,3 +44,39 @@ test('fire acquisition timestamps preserve valid leap days and UTC times', async
assert.equal(result.data.rows[0].acquired, expected);
}
});
const WEEK = [
{ lat: 24.1, lon: 121.1, frp: 864, confidence: 'l', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.2, lon: 121.2, frp: 120, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.3, lon: 121.3, frp: 20, confidence: 'n', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 24.4, lon: 121.4, frp: 2, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
{ lat: 30, lon: 100, frp: 500, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' },
];
const TAIWAN = { area: { bbox: [120, 23, 122, 25] } };
test('get_active_fires shows every detection by default and reports the filter it used', async () => {
const result = await catalogWith(WEEK).call('get_active_fires', TAIWAN);
assert.deepEqual(result.data.rows.map((row) => row.frp_mw), [864, 120, 20, 2]);
assert.deepEqual(result.data.filter, { min_frp_mw: 0, confidence: 'all' });
assert.match(result.summary, /^4 fire detections in /);
});
test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => {
const catalog = catalogWith(WEEK);
const strong = await catalog.call('get_active_fires', { ...TAIWAN, min_frp_mw: 20 });
assert.deepEqual(strong.data.rows.map((row) => row.frp_mw), [864, 120, 20]);
assert.match(strong.summary, /^3 fire detections \(≥ 20 MW\) in /);
const nominal = await catalog.call('get_active_fires', { ...TAIWAN, confidence: 'nominal' });
assert.deepEqual(nominal.data.rows.map((row) => row.frp_mw), [120, 20, 2], 'the 864 MW low-confidence fire drops');
assert.match(nominal.summary, /\(nominal or high confidence\)/);
const both = await catalog.call('get_active_fires', {
...TAIWAN,
min_frp_mw: 19.6,
confidence: 'high',
});
assert.deepEqual(both.data.rows.map((row) => row.frp_mw), [120]);
assert.deepEqual(both.data.filter, { min_frp_mw: 20, confidence: 'high' });
assert.match(both.summary, /\(≥ 20 MW, high confidence\)/);
});
+91
View File
@@ -0,0 +1,91 @@
// src/ui/cockpitBodyClasses.test.mjs — the classes the Cockpit puts on
// <body> must not also name an element's rule. A bare `.name { … }` written
// for a panel matches <body> the moment <body> carries `name`, and its
// pointer-events, position and overflow then apply to the whole page.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const UI_ROOT = fileURLToPath(new URL('.', import.meta.url));
const SRC_ROOT = path.resolve(UI_ROOT, '..');
const CONTROLLER = path.join(UI_ROOT, 'cockpitTrackingController.js');
/** Every stylesheet the app ships. */
function stylesheets(directory = SRC_ROOT) {
const files = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...stylesheets(absolute));
else if (entry.isFile() && entry.name.endsWith('.css'))
files.push(absolute);
}
return files;
}
/** A selector list split on its top-level commas, not those in `:not(a, b)`. */
function splitSelectorList(list) {
const parts = [];
let depth = 0;
let start = 0;
for (let i = 0; i < list.length; i += 1) {
if (list[i] === '(') depth += 1;
else if (list[i] === ')') depth -= 1;
else if (list[i] === ',' && depth === 0) {
parts.push(list.slice(start, i));
start = i + 1;
}
}
parts.push(list.slice(start));
return parts;
}
/** The classes the tracking controller adds to <body>. */
function cockpitBodyClasses() {
const source = readFileSync(CONTROLLER, 'utf8');
const names = [
...source.matchAll(/body\.classList\.add\(\s*'([a-z0-9-]+)'/g),
].map((match) => match[1]);
return [...new Set(names)];
}
test('the Cockpit adds its subject classes to <body>', () => {
const classes = cockpitBodyClasses();
for (const name of ['cockpit-mode']) {
assert.ok(classes.includes(name), `${name} is not added to <body>`);
}
});
test('no stylesheet rule matches a Cockpit body class on another element', () => {
const offenders = [];
for (const name of cockpitBodyClasses()) {
// `.name` as a whole class token, not as `.name-suffix`.
const token = new RegExp(`\\.${name}(?![\\w-])`);
for (const file of stylesheets()) {
const css = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
for (const [, selectors] of css.matchAll(/([^{}]+)\{/g)) {
for (const selector of splitSelectorList(selectors)) {
// Prettier wraps long `:not(\n .name\n)` arguments; unwrap them.
const compound = selector
.trim()
.replace(/\(\s+/g, '(')
.replace(/\s+\)/g, ')');
if (!token.test(compound)) continue;
// Allowed only on <body> itself: `body.name` or `body:not(.name)`.
const onBody = new RegExp(
`(^|[\\s>+~])body[^\\s>+~]*\\.${name}(?![\\w-])`,
);
if (onBody.test(compound)) continue;
offenders.push(`${path.relative(SRC_ROOT, file)}: ${compound}`);
}
}
}
}
assert.deepEqual(
offenders,
[],
'Selectors that match a Cockpit body class without `body.`: ' +
offenders.join('; '),
);
});
+26 -5
View File
@@ -19,6 +19,14 @@ import {
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
} from './cockpitPresentation.js';
/** The aircraft eye: just ahead of and above the reported position. */
const AIRCRAFT_CAMERA_PROFILE = Object.freeze({
forwardM: COCKPIT_FORWARD_OFFSET_M,
upM: COCKPIT_UP_OFFSET_M,
pitchDeg: COCKPIT_VIEW_PITCH_DEG,
minClearanceM: COCKPIT_MIN_GROUND_CLEARANCE_M,
});
export function update() {
if (this.destroyed) return false;
if (!this.active) {
@@ -75,7 +83,20 @@ export function update() {
COCKPIT_HEADING_SLEW_DPS * dtSec,
);
}
placeCamera.call(this, target, info, nowMs, dtSec, AIRCRAFT_CAMERA_PROFILE);
}
/**
* Place the camera for one update: advance the inertial anchor, clamp it and
* the eye against the rendered surface, and look along the slewed heading.
* @param {Cesium.Cartesian3} target Authoritative subject position.
* @param {object} info Subject info.
* @param {number} nowMs Frame time.
* @param {number} dtSec Seconds since the previous camera update.
* @param {{forwardM: number, upM: number, pitchDeg: number,
* minClearanceM: number}} profile Eye offsets for the subject kind.
*/
function placeCamera(target, info, nowMs, dtSec, profile) {
if (!this.cockpitAnchorValid) {
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
this.cockpitAnchorValid = true;
@@ -89,7 +110,7 @@ export function update() {
// 15/30-second interpolation and per-frame cache without exposing its
// sample-boundary corrections to the camera.
const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
const pitchRad = Cesium.Math.toRadians(profile.pitchDeg);
const speedMps = Number.isFinite(info.velocityMps)
? Math.max(0, info.velocityMps)
: 0;
@@ -248,7 +269,7 @@ export function update() {
const minimumAnchorHeightM = cockpitGroundSafeHeight(
anchorCartographic.height,
cockpitFloorM,
COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M,
profile.minClearanceM - profile.upM,
);
if (minimumAnchorHeightM !== anchorCartographic.height) {
anchorCartographic.height = minimumAnchorHeightM;
@@ -297,7 +318,7 @@ export function update() {
Cesium.Cartesian3.multiplyByScalar(
this.scratchHorizontal,
COCKPIT_FORWARD_OFFSET_M,
profile.forwardM,
this.scratchCamera,
);
Cesium.Cartesian3.add(
@@ -309,7 +330,7 @@ export function update() {
Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
Cesium.Cartesian3.multiplyByScalar(
this.scratchTarget,
COCKPIT_UP_OFFSET_M,
profile.upM,
this.scratchTarget,
);
Cesium.Cartesian3.add(
@@ -338,7 +359,7 @@ export function update() {
const safeHeightM = cockpitGroundSafeHeight(
cameraCartographic.height,
cockpitFloorM,
COCKPIT_MIN_GROUND_CLEARANCE_M,
profile.minClearanceM,
);
if (safeHeightM !== cameraCartographic.height) {
cameraCartographic.height = safeHeightM;
+47 -9
View File
@@ -1,6 +1,7 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { formatNumber, t, tn } from '../i18n/index.js';
import { sourceStatusLabel } from './layerNames.js';
import { unitLabel } from './unitLabels.js';
import {
bearingBetweenCoordinates,
@@ -24,6 +25,27 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
});
/**
* Which air feed the aircraft Cockpit's surrounding picture is missing. Entry
* needs only the ridden aircraft's own layer, so the other one may be off;
* the panels then say the picture is partial rather than present it as
* complete. Null with both feeds on, or when the owner cannot tell.
* @returns {?{title: string, detail: string}}
*/
export function readPartialPicture() {
const isLayerEnabled = this.services.isLayerEnabled;
if (typeof isLayerEnabled !== 'function') return null;
const militaryOff = !isLayerEnabled('military');
const flightsOff = !isLayerEnabled('flights');
if (!militaryOff && !flightsOff) return null;
return {
title: militaryOff
? t('MILITARY FEED OFF · PARTIAL PICTURE')
: t('FLIGHTS FEED OFF · PARTIAL PICTURE'),
detail: t('NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR'),
};
}
/** Hand a fresh copy of the Contact card to the view. */
export function publishContextView() {
const view = this.contextView;
@@ -52,7 +74,11 @@ export function updateContext(info, heading) {
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
const trackedId = info.icao24 || info.id;
const readout = resolveCockpitContextReadout({ snapshot, info });
const partial = readPartialPicture.call(this);
if (!readout.visible) {
// Contacts just went off: the rows its snapshot built stop being
// measured, so they go before the standby rows replace them.
if (this.presence.context) this.dropContextSnapshotSignals?.();
this.showContextCard(false);
this.contextLayoutStamp = null;
this.pushCockpitSignal(
@@ -61,6 +87,14 @@ export function updateContext(info, heading) {
t('CONTEXT STANDBY'),
t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
);
if (partial)
this.pushCockpitSignal(
'feed-status',
'warning',
partial.title,
partial.detail,
);
else this.dropCockpitSignal?.('feed-status');
return;
}
@@ -110,6 +144,7 @@ export function updateContext(info, heading) {
if (shown) {
shown.count = cohort.count === null ? '?' : String(cohort.count);
shown.unknown = cohort.relationship === 'UNKNOWN';
shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus);
}
if (cohort.count === null) unknownCount += 1;
for (const item of cohort.nearest) nearest.push({ ...item, cohort });
@@ -171,19 +206,22 @@ export function updateContext(info, heading) {
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
});
}
view.uncertainty = unknownCount
? tn(
unknownCount,
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
)
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
// A feed that is off outranks unknown inputs: the picture is partial.
view.uncertainty = partial
? partial.title
: unknownCount
? tn(
unknownCount,
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
)
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
view.updated = Number.isFinite(snapshot.evaluatedAt)
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
: '--:--:--Z';
view.state = unknownCount ? 'uncertain' : 'current';
view.state = unknownCount || partial ? 'uncertain' : 'current';
this.publishContextView();
this.updateCockpitSignals(snapshot, unknownCount);
this.updateCockpitSignals(snapshot, unknownCount, partial);
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
this.contextLayoutStamp = snapshot.evaluatedAt;
this.scheduleContextLayout();
+164
View File
@@ -12,6 +12,13 @@ import {
updateContext,
} from './cockpitContext.js';
import { createContextState } from './cockpitContextState.js';
import {
dropCockpitSignal,
dropContextSnapshotSignals,
publishCockpitSignals,
pushCockpitSignal,
updateCockpitSignals,
} from './cockpitSignals.js';
import { syncWeatherToggle } from './cockpitInstruments.js';
import { setContextCollapsed } from './cockpitLayout.js';
import { useCockpitStore } from './stores/cockpit.js';
@@ -96,6 +103,13 @@ test('before the controller presents, the card shows the state it starts in', (t
),
['—', '—', '—', '—'],
);
// No provider is named before a snapshot reports one.
assert.deepEqual(
[...document.querySelectorAll('[data-context-cohort] small')].map(
(source) => source.textContent,
),
['—', '—', '—', '—'],
);
assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE');
assert.equal(
$('#cockpit-context-nearest-label').hasAttribute('aria-label'),
@@ -159,6 +173,43 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) =>
assert.equal(layouts.length, 1);
});
test('each count names the provider its snapshot reports, with its feed state', async (t) => {
const reported = snapshot();
reported.cohorts[0].source = 'adsb.lol';
reported.cohorts[0].sourceStatus = 'fallback';
reported.cohorts[1].source = 'Plane Finder';
reported.cohorts.push(
{
id: 'ais-live-vessels',
label: 'AIS vessels',
source: 'Datalastic',
sourceStatus: 'stale',
count: 3,
relationship: 'OBSERVED',
nearest: [],
},
{
id: 'military-installations',
label: 'Mapped installations',
source: 'OpenStreetMap',
sourceStatus: null,
count: 0,
relationship: 'MAPPED',
nearest: [],
},
);
const { $, owner } = fixture(t, reported);
updateContext.call(owner, INFO, 0);
await nextTick();
const source = (id) => $(`[data-context-cohort="${id}"] small`);
assert.equal(source('flights').textContent, 'adsb.lol · FALLBACK');
assert.equal(source('military').textContent, 'Plane Finder');
assert.equal(source('ais-live-vessels').textContent, 'Datalastic · STALE');
assert.equal(source('military-installations').textContent, 'OpenStreetMap');
// The tile ellipsizes; the full name stays readable on hover.
assert.equal(source('flights').title, 'adsb.lol · FALLBACK');
});
test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => {
const { $, text, owner, layouts } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
@@ -204,6 +255,119 @@ test('without a snapshot the card stands by, hidden', async (t) => {
assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY');
});
test('a feed that is off makes the card and Live Signals say the picture is partial', async (t) => {
// Every input known, so only the feed that is off can make it partial.
const { $, text, owner } = fixture(
t,
snapshot({
cohorts: [
{
id: 'flights',
label: 'Flights',
count: 12,
relationship: 'OBSERVED',
nearest: [{ id: 'N1', distanceM: 4200 }],
},
],
}),
);
const rebuilt = [];
owner.updateCockpitSignals = (...args) => rebuilt.push(args);
owner.services.isLayerEnabled = () => true;
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal($('#cockpit-context').dataset.state, 'current');
assert.equal(rebuilt.at(-1)[2], null, 'both feeds on: nothing to add');
owner.services.isLayerEnabled = (layerId) => layerId !== 'military';
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal($('#cockpit-context').dataset.state, 'uncertain');
assert.equal(
text('#cockpit-context-uncertainty'),
'MILITARY FEED OFF · PARTIAL PICTURE',
);
assert.deepEqual(rebuilt.at(-1)[2], {
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
});
});
test('without Contacts, a feed that is off adds a signal until it is back', async (t) => {
const { owner, signals } = fixture(t, null);
const dropped = [];
owner.dropCockpitSignal = (key) => dropped.push(key);
// Riding a military aircraft with Live Flights off.
owner.services.isLayerEnabled = (layerId) => layerId === 'military';
updateContext.call(owner, { ...INFO, layerId: 'military' }, 0);
assert.equal(signals.at(-2)[2], 'CONTEXT STANDBY');
assert.deepEqual(signals.at(-1), [
'feed-status',
'warning',
'FLIGHTS FEED OFF · PARTIAL PICTURE',
'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
]);
owner.services.isLayerEnabled = () => true;
updateContext.call(owner, { ...INFO, layerId: 'military' }, 0);
assert.deepEqual(dropped, ['feed-status']);
});
/** The fixture's owner with the real Live Signals presenters. */
function withSignals(owner) {
const published = [];
Object.assign(owner, {
signalItems: [],
signalSignatures: new Map(),
publishSignals: (rows) => published.push(rows),
publishCockpitSignals,
pushCockpitSignal,
dropCockpitSignal,
dropContextSnapshotSignals,
updateCockpitSignals,
});
return () => published.at(-1).map((row) => row.title);
}
test('Contacts going off clears its contact rows and leaves standby', async (t) => {
const { owner } = fixture(t, snapshot());
const rows = withSignals(owner);
owner.services.isLayerEnabled = () => true;
updateContext.call(owner, INFO, 0);
assert.ok(
owner.signalItems.some((item) => item.key.startsWith('flight:')),
'Contacts fills the rows',
);
assert.ok(owner.signalItems.some((item) => item.key === 'input-status'));
// Both feeds stay on; only Contacts goes.
owner.snapshot = null;
updateContext.call(owner, INFO, 0);
assert.deepEqual(rows(), ['CONTEXT STANDBY']);
updateContext.call(owner, INFO, 0);
assert.deepEqual(rows(), ['CONTEXT STANDBY'], 'standby holds still');
});
test('Contacts going off with a feed off keeps the feed row beside standby', async (t) => {
const { owner } = fixture(t, snapshot());
const rows = withSignals(owner);
owner.services.isLayerEnabled = (layerId) => layerId !== 'military';
updateContext.call(owner, INFO, 0);
owner.snapshot = null;
updateContext.call(owner, INFO, 0);
assert.deepEqual(rows(), [
'MILITARY FEED OFF · PARTIAL PICTURE',
'CONTEXT STANDBY',
]);
});
test('standby without Contacts keeps the rows the session already shows', async (t) => {
const { owner } = fixture(t, null);
const rows = withSignals(owner);
owner.services.isLayerEnabled = () => true;
owner.pushCockpitSignal('track', 'track', 'TRACK ACQUIRED', 'UAL123');
updateContext.call(owner, INFO, 0);
assert.deepEqual(rows(), ['CONTEXT STANDBY', 'TRACK ACQUIRED']);
});
test('the weather toggle renders its state and the action it offers', async (t) => {
const { $, text, owner } = fixture(t);
syncWeatherToggle.call(owner, true);
+4 -1
View File
@@ -25,7 +25,10 @@ export function createContextState() {
canPrevious: true,
canNext: true,
cohorts: Object.fromEntries(
COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]),
COCKPIT_CONTEXT_COHORTS.map((id) => [
id,
{ count: '—', unknown: false, source: '—' },
]),
),
nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null },
distance: { text: '—', ariaLabel: null },
+40 -3
View File
@@ -1,6 +1,8 @@
/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */
import {
readAircraftInfo,
readSubjectLayerId,
resolveEntryKind,
dispatchCockpitModeChanged,
toggleTrackedTr3b,
syncTr3bToggle,
@@ -48,6 +50,8 @@ import {
relocalizeBrief,
} from './cockpitBriefing.js';
import {
dropCockpitSignal,
dropContextSnapshotSignals,
handleSignalClick,
publishCockpitSignals,
pushCockpitSignal,
@@ -60,7 +64,11 @@ import {
syncContextLayout,
syncSignalLayout,
} from './cockpitLayout.js';
import { onKeyDown } from './cockpitInput.js';
import {
onKeyDown,
onSceneDoubleClick,
routeSceneDoubleClick,
} from './cockpitInput.js';
import * as Cesium from 'cesium';
export class CockpitViewController {
@@ -75,6 +83,7 @@ export class CockpitViewController {
isUtilityDisclosureOpen = null,
onEntered = null,
onExited = null,
onRide = null,
restoreTrackingFrame = null,
publish = {},
} = {},
@@ -114,6 +123,7 @@ export class CockpitViewController {
this.onCameraTakeover = onCameraTakeover;
this.onEntered = onEntered;
this.onExited = onExited;
this.onRide = onRide;
this.restoreTrackingFrame =
typeof restoreTrackingFrame === 'function'
? restoreTrackingFrame
@@ -205,6 +215,13 @@ export class CockpitViewController {
}),
);
this._listen(this.entry, 'click', () => this.enter());
// Double-clicking the followed track is a third way in.
this._listenerRemovers.push(routeSceneDoubleClick.call(this));
// The readout's entry renders inside the Intel HUD, which may mount after
// this controller, so its clicks are delegated.
this._listen(document, 'click', (event) => {
if (event.target?.closest?.('#cockpit-entry-readout')) this.enter();
});
this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b());
this._listen(this.mapViewButton, 'click', () => this.exit());
this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1));
@@ -272,6 +289,12 @@ export class CockpitViewController {
readAircraftInfo() {
return readAircraftInfo.call(this);
}
readSubjectLayerId() {
return readSubjectLayerId.call(this);
}
resolveEntryKind() {
return resolveEntryKind.call(this);
}
dispatchCockpitModeChanged(active, info) {
return dispatchCockpitModeChanged.call(this, active, info);
}
@@ -371,8 +394,19 @@ export class CockpitViewController {
pushCockpitSignal(key, tone, title, detail, target) {
return pushCockpitSignal.call(this, key, tone, title, detail, target);
}
updateCockpitSignals(snapshot, unknownCount) {
return updateCockpitSignals.call(this, snapshot, unknownCount);
dropCockpitSignal(key) {
return dropCockpitSignal.call(this, key);
}
dropContextSnapshotSignals() {
return dropContextSnapshotSignals.call(this);
}
updateCockpitSignals(snapshot, unknownCount, partialPicture) {
return updateCockpitSignals.call(
this,
snapshot,
unknownCount,
partialPicture,
);
}
scheduleContextLayout() {
return scheduleContextLayout.call(this);
@@ -392,6 +426,9 @@ export class CockpitViewController {
onKeyDown(event) {
return onKeyDown.call(this, event);
}
onSceneDoubleClick(position) {
return onSceneDoubleClick.call(this, position);
}
handleSignalClick(event) {
return handleSignalClick.call(this, event);
+74 -21
View File
@@ -1,6 +1,9 @@
import { nextTick } from 'vue';
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
import { cockpitEntryAllowed } from '../contextModePolicy.js';
import {
COCKPIT_AIRCRAFT_LAYER_IDS,
cockpitEntryAllowed,
} from '../contextModePolicy.js';
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
import { enterCockpitWithTracking } from '../cockpitTracking.js';
import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js';
@@ -72,23 +75,29 @@ export class CockpitCoordinator {
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
// The Contact card and Live Signals say which feeds are off.
isLayerEnabled: (layerId) => this._isLayerEnabled(layerId),
},
onVisionChange: (mode, active, options) =>
this._setCockpitVision(mode, active, options),
getInitialVisionMode: () => cockpitVisionModeForStyle(this.activeStyle),
onCameraTakeover: () =>
this._stampNavigation({ cancelPendingSelection: false }),
isEntryAllowed: () =>
cockpitEntryAllowed({
contextMode: this._contextMode,
// Only the tracked aircraft's own layer gates entry (see the policy).
isEntryAllowed: () => {
const subjectLayerId =
this.cockpitView?.readAircraftInfo?.()?.layerId ?? null;
return cockpitEntryAllowed({
subjectLayerId,
subjectLayerEnabled: this._isLayerEnabled(subjectLayerId),
contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
}),
});
},
publish: this.publishCockpit,
isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(),
onEntered: () => this.enterPanels(),
onExited: () => this.exitPanels(),
onRide: (layerId) => this._adoptRiddenLayer(layerId),
restoreTrackingFrame: (entity) => {
const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(
':',
@@ -115,6 +124,44 @@ export class CockpitCoordinator {
get activeStyle() {
return this.readActiveStyle();
}
/** Whether a data layer is enabled; false for no layer. */
_isLayerEnabled(layerId) {
return Boolean(layerId) && !!this._dataManager?.isEnabled(layerId);
}
/**
* Make the ridden aircraft's layer the user's own while Contacts is on.
* Contacts may have turned that layer on, and it releases the layers it
* owns when it goes off; the Cockpit must outlive Contacts, so riding an
* aircraft adopts its layer as a direct selection does. Contacts then stops
* owning it and the Context restore keeps it (session `userAdded`).
* @param {string|null} layerId The ridden aircraft's layer.
*/
_adoptRiddenLayer(layerId) {
if (!COCKPIT_AIRCRAFT_LAYER_IDS.includes(layerId)) return;
if (!this._isLayerEnabled('military-awareness')) return;
this._dataManager?.adoptLayerVisibility?.(layerId, true, {
origin: 'user',
adoptedFromSelection: true,
});
}
/** Whether either aircraft layer is on, so there is an aircraft to ride. */
get _aircraftLayerEnabled() {
return COCKPIT_AIRCRAFT_LAYER_IDS.some((layerId) =>
this._isLayerEnabled(layerId),
);
}
/**
* Why voice entry must refuse before it touches tracking, or null. The
* tracked aircraft's own layer is checked again by the entry itself.
* @returns {string|null}
*/
_cockpitEntryRefusal() {
if (this._contextModeChanging)
return 'Context is still changing — try Cockpit again in a moment';
if (!this._aircraftLayerEnabled)
return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit';
return null;
}
getCockpitState() {
const { militaryAwarenessLayer } = this.services;
const snapshot = militaryAwarenessLayer.getContextSnapshot?.();
@@ -129,19 +176,24 @@ export class CockpitCoordinator {
const entryAllowed =
!active &&
Boolean(gateOpen && info && this.viewer?.trackedEntity?.position);
// Entry needs only the ridden aircraft's own layer: with one tracked,
// that layer; with none, any aircraft layer to track one from.
const subjectLayerOff = info
? !this._isLayerEnabled(info.layerId)
: !this._aircraftLayerEnabled;
return {
active,
entryAllowed,
// Why entry is unavailable, so a refusal can be explained rather than
// guessed at.
// guessed at. Contacts is not required, so it is never the reason.
entryBlockedReason:
entryAllowed || active
? null
: !gateOpen
? this._contextModeChanging
? 'contacts-starting'
: 'contacts-inactive'
: 'no-tracked-aircraft',
: this._contextModeChanging
? 'contacts-starting'
: subjectLayerOff
? 'subject-layer-off'
: 'no-tracked-aircraft',
visionMode: this.cockpitView?.visionMode || null,
subject: info
? {
@@ -260,17 +312,18 @@ export class CockpitCoordinator {
};
}
if (normalized === 'enter') {
// Entry is gated exactly as the manual entry chip is. Attempting it while
// the gate is shut produced the half-entered look the operator reported
// (a plane anchored under the camera with no cockpit around it), so
// refuse with the reason instead of trying.
if (!this.cockpitView.isEntryAllowed?.()) {
// Attempting entry while the gate is shut produced the half-entered look
// the operator reported (a plane anchored under the camera with no
// cockpit around it), so refuse with the reason instead of trying. The
// aircraft may not be tracked yet (a selection or a requested layer is
// adopted below), so this checks what can be known now; the entry
// itself then applies the manual chip's gate to the aircraft it rides.
const refusal = this._cockpitEntryRefusal();
if (refusal) {
return {
ok: false,
action: 'control_cockpit',
error: this._contextModeChanging
? 'Contacts is still starting up — try Cockpit again in a moment'
: 'Contacts must be active to enter Cockpit — say "open contacts" first',
error: refusal,
state: this.getCockpitState(),
};
}
+467
View File
@@ -0,0 +1,467 @@
// src/ui/cockpitEntryGate.test.mjs — first person needs only the layer of
// the vehicle it rides: Contacts and the other feeds may be off before or
// during a session, turning the ridden layer off ends it, Contacts ending
// keeps the ridden layer it had turned on, and the voice gate and state say
// so without claiming Contacts is required. Over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, defineComponent, h, nextTick } from 'vue';
import { DataLayerManager } from '../data/manager.js';
import { createDependencies } from '../layers/awareness/dependencies.js';
import { CockpitCoordinator } from './cockpitCoordinator.js';
import { ContextControls } from './contextControls.js';
import { _handleContextLayerChange } from './contextLayerChanges.js';
import { useCockpitStore } from './stores/cockpit.js';
import { useHudStore } from './stores/hud.js';
import ContextView from './views/ContextView.vue';
import IntelHudView from './views/IntelHudView.vue';
const CIVIL = Object.freeze({
icao24: 'abc123',
callsign: 'UAL123',
track: 92,
latitude: 30,
longitude: -97,
});
const MILITARY = Object.freeze({
...CIVIL,
icao24: 'ae0001',
callsign: 'RCH1',
});
function fixture(
t,
{
enabled = [],
tracked = null,
contextMode = null,
changing = false,
dataManager: suppliedManager = null,
readContext = null,
} = {},
) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(
defineComponent(() => () => [h(ContextView), h(IntelHudView)]),
).use(pinia);
app.mount(host);
const store = useCockpitStore(pinia);
const layers = new Set(enabled);
const dataManager = suppliedManager || {
isEnabled: (layerId) => layers.has(layerId),
};
// A layer that is off has no tracked aircraft, as the flight layers clear
// tracking on disable.
const trackedFrom = (layerId) => () =>
tracked?.layerId === layerId && dataManager.isEnabled(layerId)
? tracked.info
: null;
const entityFor = (subject) => ({
position: { getValue: () => null },
show: true,
gevTrackedId: subject ? `${subject.layerId}:${subject.info.icao24}` : '',
});
const entities = new Set();
const entity = entityFor(tracked);
entities.add(entity);
const listeners = () => ({ addEventListener: () => () => {} });
const viewer = {
trackedEntity: tracked ? entity : undefined,
clock: { currentTime: null },
camera: { cancelFlight() {}, frustum: { fov: 1, aspectRatio: 1 } },
entities: { contains: (candidate) => entities.has(candidate) },
scene: {
preUpdate: listeners(),
screenSpaceCameraController: { enableInputs: true },
},
trackedEntityChanged: listeners(),
};
const context = { mode: contextMode, changing };
const coordinator = new CockpitCoordinator({
viewer,
services: {
flightsLayer: { getTrackedInfo: trackedFrom('flights') },
militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') },
militaryAwarenessLayer: { getContextSnapshot: () => null },
isTr3b: () => false,
holdContinuousRender() {},
releaseContinuousRender() {},
},
elements: {},
operations: {
publishCockpit: { presence: (view) => store.publishPresence(view) },
_stampNavigation() {},
getAircraftTrackingTarget: () =>
tracked ? { layerId: tracked.layerId, id: tracked.info.icao24 } : null,
},
readDataManager: () => dataManager,
readContext: readContext || (() => context),
readActiveStyle: () => 'normal',
readUtilityDisclosureOpen: () => false,
enterPanels() {},
exitPanels() {},
});
const cockpit = coordinator.cockpitView;
// The HUD, briefing and signals have their own tests.
Object.assign(cockpit, {
updateHud() {},
setVisionMode() {},
showBriefPage() {},
startBriefRotation() {},
stopBriefRotation() {},
pushCockpitSignal() {},
scheduleContextLayout() {},
clearPredictiveRoute() {},
});
t.after(() => {
coordinator.stop();
app.unmount();
document.body.classList.remove('cockpit-mode');
});
/** Turn a layer off and tell the Context owner, as the data manager does. */
const disable = (layerId) => {
layers.delete(layerId);
_handleContextLayerChange.call(
{
cockpitView: cockpit,
_dataManager: dataManager,
_contextMode: null,
_contextModeChanging: false,
_contextSessionSnapshot: null,
_contextRestoreState: null,
_syncContextModeButtons() {},
},
{ type: 'visibility', layerId, enabled: false, origin: 'user' },
);
};
/** Track another aircraft, as Contacts navigation does mid-session. */
const retrack = (subject) => {
tracked = subject;
viewer.trackedEntity = entityFor(subject);
entities.add(viewer.trackedEntity);
};
const $ = (selector) => document.querySelector(selector);
return { $, coordinator, cockpit, store, layers, disable, retrack, pinia };
}
test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['flights'],
tracked: { layerId: 'flights', info: CIVIL },
});
const state = coordinator.getCockpitState();
assert.equal(state.entryAllowed, true);
assert.equal(state.entryBlockedReason, null);
assert.equal(cockpit.enter(), true);
});
test('a military aircraft enters with Military Flights alone', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['military'],
tracked: { layerId: 'military', info: MILITARY },
});
assert.equal(coordinator.getCockpitState().entryAllowed, true);
assert.equal(cockpit.enter(), true);
});
test('a Context change in flight still blocks entry, and says so', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['flights', 'military'],
tracked: { layerId: 'flights', info: CIVIL },
contextMode: 'flights',
changing: true,
});
const state = coordinator.getCockpitState();
assert.equal(state.entryAllowed, false);
assert.equal(state.entryBlockedReason, 'contacts-starting');
assert.equal(cockpit.enter(), false);
const voice = coordinator.controlCockpit('enter');
assert.equal(voice.ok, false);
assert.match(voice.error, /Context is still changing/);
assert.equal(cockpit.active, false);
});
test('with no aircraft layer on, entry is refused for that reason, never for Contacts', (t) => {
const { coordinator, cockpit } = fixture(t);
const state = coordinator.getCockpitState();
assert.equal(state.entryBlockedReason, 'subject-layer-off');
const voice = coordinator.controlCockpit('enter');
assert.equal(voice.ok, false);
assert.match(voice.error, /No aircraft layer is on/);
assert.doesNotMatch(voice.error, /contacts/i);
assert.equal(voice.state.entryBlockedReason, 'subject-layer-off');
assert.equal(cockpit.active, false);
});
test('with an aircraft layer on but nothing tracked, the reason is the missing aircraft', (t) => {
const { coordinator } = fixture(t, { enabled: ['military'] });
assert.equal(
coordinator.getCockpitState().entryBlockedReason,
'no-tracked-aircraft',
);
});
test('voice enters without Contacts when the ridden layer is on', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['flights'],
tracked: { layerId: 'flights', info: CIVIL },
});
const result = coordinator.controlCockpit('enter');
assert.equal(result.ok, true, result.error);
assert.equal(cockpit.active, true);
});
test('other layers may be turned off during a session; the ridden layer ends it', (t) => {
const { cockpit, disable } = fixture(t, {
enabled: ['flights', 'military', 'ais-live-vessels'],
tracked: { layerId: 'flights', info: CIVIL },
});
assert.equal(cockpit.enter(), true);
disable('military');
disable('ais-live-vessels');
assert.equal(cockpit.active, true, 'other feeds are free to go off');
disable('flights');
assert.equal(cockpit.active, false, 'the ridden layer going off ends it');
});
test('the readout offers the same entry, labelled by kind, and enters on click', async (t) => {
const { $, cockpit, pinia } = fixture(t, {
enabled: ['flights'],
tracked: { layerId: 'flights', info: CIVIL },
});
useHudStore(pinia);
const readoutEntry = $('#cockpit-entry-readout');
assert.equal(readoutEntry.hidden, true, 'hidden until entry is available');
cockpit.syncEntry();
await nextTick();
assert.equal(readoutEntry.hidden, false);
assert.match(readoutEntry.textContent, /flight/);
assert.match(readoutEntry.textContent, /COCKPIT/);
assert.equal(
$('#cockpit-entry').hidden,
false,
'the Contacts entry follows the same presence',
);
readoutEntry.click();
await nextTick();
assert.equal(cockpit.active, true);
assert.equal(readoutEntry.hidden, true, 'inside, there is nothing to enter');
});
const AIR_AND_SEA = [
'flights',
'military',
'ais-live-vessels',
'military-installations',
];
/** A source layer that settles at once, as the data manager drives it. */
function sourceLayer(id) {
return {
id,
name: id,
icon: '',
source: 'test',
updateInterval: -1,
init() {},
enable() {},
disable() {},
update() {},
getStats: () => ({ count: 0, lastUpdate: null }),
};
}
/**
* Contacts as the manager sees it: a coordinator whose real dependency
* ownership (src/layers/awareness/dependencies.js) turns on the air and sea
* layers it finds off and releases them when it goes off.
*/
function contactsCoordinator() {
const state = {
dataManager: null,
enabled: false,
passive: true,
activationId: 0,
ownedDependencies: new Set(),
subject: null,
autoFocusRetryPending: false,
};
const dependencies = createDependencies({
state,
services: {},
parts: { subject: {}, focus: {} },
source: null,
});
return {
id: 'military-awareness',
name: 'Contacts',
icon: '',
source: 'test',
showInTogglePanel: false,
updateInterval: -1,
attachDataManager(dataManager) {
state.dataManager = dataManager;
dependencies.watchAdoptions(dataManager);
},
setParams(params = {}) {
if (typeof params.passive === 'boolean') state.passive = params.passive;
},
getParams: () => ({ passive: state.passive }),
init() {},
enable() {
state.enabled = true;
if (!state.passive) return dependencies.enableDependencies();
return true;
},
disable() {
state.enabled = false;
state.passive = true;
return dependencies.releaseOwnedDependencies(++state.activationId);
},
update() {},
getStats: () => ({ count: 0, lastUpdate: null }),
};
}
/**
* The Cockpit beside the real Context owner and data manager, so a Contacts
* exit runs its whole restore: the coordinator's releases, then the snapshot.
*/
async function liveFixture(t, { enabled = [], tracked = null } = {}) {
const manager = new DataLayerManager({});
for (const layerId of AIR_AND_SEA) manager.register(sourceLayer(layerId));
const coordinatorModule = contactsCoordinator();
manager.register(coordinatorModule);
coordinatorModule.attachDataManager(manager);
for (const layerId of enabled) await manager.setEnabled(layerId, true);
let cockpit = null;
const controls = new ContextControls({
elements: {},
installations: {},
actions: {
getCockpit: () => cockpit,
claimVisualAuthority() {},
showToast() {},
setClearBusy() {},
syncDetection() {},
scheduleLayout() {},
refreshRadio() {},
setPanelCollapsed() {},
},
});
controls.connect(manager);
t.after(() => {
controls.stop();
controls.disconnect();
});
const base = fixture(t, {
tracked,
dataManager: manager,
readContext: () => controls.getContextModeState(),
});
cockpit = base.cockpit;
/** Let every Context reaction the last change started settle. */
const settle = async () => {
for (let turn = 0; turn < 5; turn += 1) {
await controls._waitForContextLayerSettlement();
await new Promise((resolve) => setImmediate(resolve));
}
};
return { ...base, manager, controls, settle };
}
const on = (manager) =>
[...AIR_AND_SEA, 'military-awareness'].filter((layerId) =>
manager.isEnabled(layerId),
);
test('Contacts ending in a Cockpit session keeps the ridden layer it had turned on', async (t) => {
for (const [label, endContacts] of [
[
'Military Flights off',
({ manager }) =>
manager.setEnabled('military', false, { origin: 'user' }),
],
[
'Contacts off by its own layer',
({ manager }) =>
manager.setEnabled('military-awareness', false, { origin: 'user' }),
],
[
'Contacts off from its chooser',
({ controls }) => controls._selectContextMode(null),
],
]) {
await t.test(label, async (t) => {
// Live Flights is off on the map; Contacts turns it on as its own.
const live = await liveFixture(t, {
tracked: { layerId: 'flights', info: CIVIL },
});
const { manager, controls, cockpit, settle } = live;
assert.equal(await controls._selectContextMode('flights'), true);
await settle();
assert.deepEqual(on(manager), [...AIR_AND_SEA, 'military-awareness']);
assert.equal(cockpit.enter(), true);
await endContacts(live);
await settle();
assert.equal(controls.getContextModeState().mode, null, 'Contacts ended');
assert.equal(cockpit.active, true, 'the Cockpit rides on');
assert.deepEqual(
on(manager),
['flights'],
'only the ridden layer outlives the pre-Contacts restore',
);
});
}
});
test('a ride handed to an aircraft on a layer Contacts turned on keeps that layer', async (t) => {
const live = await liveFixture(t, {
enabled: ['flights'],
tracked: { layerId: 'flights', info: CIVIL },
});
const { manager, controls, cockpit, retrack, settle } = live;
assert.equal(await controls._selectContextMode('flights'), true);
await settle();
assert.equal(cockpit.enter(), true);
// NEXT hands the view to a military aircraft; Contacts turned its layer on.
retrack({ layerId: 'military', info: MILITARY });
assert.equal(cockpit._adoptTrackedEntity(performance.now()), true);
assert.equal(cockpit.readSubjectLayerId(), 'military');
await manager.setEnabled('ais-live-vessels', false, { origin: 'user' });
await settle();
assert.equal(controls.getContextModeState().mode, null);
assert.equal(cockpit.active, true);
assert.deepEqual(on(manager), ['flights', 'military']);
});
test('the ridden layer still ends the Cockpit while Contacts is on', async (t) => {
const live = await liveFixture(t, {
tracked: { layerId: 'flights', info: CIVIL },
});
const { manager, controls, cockpit, settle } = live;
assert.equal(await controls._selectContextMode('flights'), true);
await settle();
assert.equal(cockpit.enter(), true);
await manager.setEnabled('flights', false, { origin: 'user' });
await settle();
assert.equal(cockpit.active, false);
assert.equal(manager.isEnabled('flights'), false);
});
test('without a Cockpit, ending Contacts restores the layers it found', async (t) => {
const live = await liveFixture(t, {
tracked: { layerId: 'flights', info: CIVIL },
});
const { manager, controls, settle } = live;
assert.equal(await controls._selectContextMode('flights'), true);
await settle();
await manager.setEnabled('military', false, { origin: 'user' });
await settle();
assert.equal(controls.getContextModeState().mode, null);
assert.deepEqual(on(manager), [], 'Live Flights goes back off');
});
+94
View File
@@ -1,5 +1,99 @@
/** Route Cockpit keys while preserving the active disclosure and form-control owner. */
import * as Cesium from 'cesium';
import { isPointerFree } from '../data/inputOwnership.js';
/** How far from the followed track a double-click may land, CSS pixels. */
export const SUBJECT_DOUBLE_CLICK_RADIUS_PX = 32;
/**
* Whether a double-click landed on the followed track's mark.
* @param {{x: number, y: number}|null} click Canvas position of the click.
* @param {{x: number, y: number}|null|undefined} subject Canvas position of
* the track, or nothing when it is off screen.
* @param {number} [radiusPx]
*/
export function doubleClickHitsSubject(
click,
subject,
radiusPx = SUBJECT_DOUBLE_CLICK_RADIUS_PX,
) {
if (!click || !subject) return false;
const distance = Math.hypot(click.x - subject.x, click.y - subject.y);
return Number.isFinite(distance) && distance <= radiusPx;
}
/** Where the track `kind` would ride is drawn now, as a world position. */
function subjectWorldPosition(kind) {
if (kind !== 'aircraft') return null;
return (
this.viewer.trackedEntity?.position?.getValue?.(
this.viewer.clock?.currentTime,
) ?? null
);
}
/** The followed track's canvas position now, or null off screen. */
function subjectCanvasPosition() {
const kind = this.resolveEntryKind();
const world = kind ? subjectWorldPosition.call(this, kind) : null;
const scene = this.viewer.scene;
if (!world || typeof scene?.cartesianToCanvasCoordinates !== 'function')
return null;
return (
scene.cartesianToCanvasCoordinates(world, new Cesium.Cartesian2()) ?? null
);
}
/**
* Enter first person when the followed track itself is double-clicked: the
* same entry, and the same gates, as C and the entry buttons. A
* double-click anywhere else, or one a tool owns (drawing ends a shape with
* one), is left alone.
* @param {{x: number, y: number}|null|undefined} position Canvas position.
* @returns {boolean} True when it landed on the followed track, entered or
* refused by the gate: the caller does nothing else with it.
*/
export function onSceneDoubleClick(position) {
if (this.active || this.destroyed || !isPointerFree()) return false;
if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y))
return false;
if (!doubleClickHitsSubject(position, subjectCanvasPosition.call(this)))
return false;
this.enter();
return true;
}
/**
* Take the scene's double-click through the Cockpit first. Cesium's Viewer
* installs its own (pickAndTrackObject), which untracks whenever the pick is
* not an Entity: on a followed aircraft's model, a primitive, it would drop
* the very track the double-click means to ride. A double-click on the
* followed track is the Cockpit's; any other goes on to Cesium's.
* @returns {() => void} Puts Cesium's handler back, unless another owner
* (the draw tool borrows it) has replaced ours since.
*/
export function routeSceneDoubleClick() {
const handler = this.viewer.screenSpaceEventHandler;
if (typeof handler?.setInputAction !== 'function') return () => {};
const type = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
const stock = handler.getInputAction?.(type) ?? null;
const routed = (event) => {
if (this.onSceneDoubleClick(event?.position)) return;
stock?.(event);
};
handler.setInputAction(routed, type);
return () => {
if (handler.isDestroyed?.() || handler.getInputAction?.(type) !== routed)
return;
if (stock) handler.setInputAction(stock, type);
else handler.removeInputAction(type);
};
}
/**
* The Cockpit's capture-phase keydown: Escape and C.
* @param {KeyboardEvent} event
*/
export function onKeyDown(event) {
if (this.destroyed) return false;
if (event.repeat || event.isComposing) return;
+18
View File
@@ -144,3 +144,21 @@ export function formatCockpitWindDirection(value) {
const degrees = roundHeadingDegrees(value);
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
}
/**
* The Cockpit entry for each subject it can ride: an aircraft's cockpit.
* Both entry buttons (Contacts and the Intel HUD readout) show it.
*/
const COCKPIT_ENTRY_FACES = Object.freeze({
aircraft: Object.freeze({ icon: 'flight', label: msg('COCKPIT') }),
});
/**
* The entry's glyph and localized label for the subject it would ride.
* @param {'aircraft'|null|undefined} kind
* @returns {{icon: string, label: string}}
*/
export function cockpitEntryFace(kind) {
const face = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft;
return { icon: face.icon, label: t(face.label) };
}
+75 -11
View File
@@ -62,7 +62,52 @@ export function pushCockpitSignal(key, tone, title, detail, target = null) {
this.publishCockpitSignals();
}
export function updateCockpitSignals(snapshot, unknownCount) {
/**
* Take a status row off the list, as when the feed it warned about is back.
* @param {string} key The row's key.
*/
export function dropCockpitSignal(key) {
this.signalSignatures.delete(key);
const kept = this.signalItems.filter((item) => item.key !== key);
if (kept.length === this.signalItems.length) return;
this.signalItems = kept;
this.publishCockpitSignals();
}
/** Whether a row is one a Contacts snapshot builds, with its status rows. */
const isContextSnapshotRow = (key) =>
key.startsWith('flight:') ||
['input-status', 'feed-status', 'context-status'].includes(key);
/**
* Take off every row a Contacts snapshot built, as when Contacts goes off:
* its contacts, distances and CURRENT would otherwise read as live. Rows the
* session made without Contacts, such as TRACK ACQUIRED, stay.
*/
export function dropContextSnapshotSignals() {
for (const key of [...this.signalSignatures.keys()])
if (isContextSnapshotRow(key)) this.signalSignatures.delete(key);
const kept = this.signalItems.filter(
(item) => !isContextSnapshotRow(item.key),
);
if (kept.length === this.signalItems.length) return;
this.signalItems = kept;
this.publishCockpitSignals();
}
/**
* Rebuild the rows from a Contacts snapshot: the nearest flights, a row for
* unknown inputs and, first, a row naming an air feed that is off.
* @param {object} snapshot `militaryAwareness.getContextSnapshot()` result.
* @param {number} unknownCount Cohorts whose count is unknown.
* @param {?{title: string, detail: string}} [partialPicture] The feed-off
* row (see `readPartialPicture`), or null with every air feed on.
*/
export function updateCockpitSignals(
snapshot,
unknownCount,
partialPicture = null,
) {
const previous = new Map(this.signalItems.map((item) => [item.key, item]));
const contacts = [];
const subject = snapshot.subject;
@@ -110,7 +155,9 @@ export function updateCockpitSignals(snapshot, unknownCount) {
}
}
contacts.sort((a, b) => a.distanceM - b.distanceM);
const nextItems = contacts.slice(0, 5).map((item) => ({
// A feed-off row leads, so the list never reads as the whole picture.
const reserved = partialPicture ? 1 : 0;
const nextItems = contacts.slice(0, 5 - reserved).map((item) => ({
...item,
timestamp:
previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
@@ -120,18 +167,35 @@ export function updateCockpitSignals(snapshot, unknownCount) {
.filter((cohort) => cohort.count === null)
.map((cohort) => cohort.source)
.join(' · ');
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
key: 'input-status',
nextItems.splice(
4 - reserved,
Math.max(0, nextItems.length - 4 + reserved),
{
key: 'input-status',
tone: 'warning',
title: tn(
unknownCount,
'{count} INPUT UNKNOWN',
'{count} INPUTS UNKNOWN',
),
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
target: null,
timestamp:
previous.get('input-status')?.timestamp ||
snapshot.evaluatedAt ||
Date.now(),
},
);
}
if (partialPicture) {
nextItems.unshift({
key: 'feed-status',
tone: 'warning',
title: tn(
unknownCount,
'{count} INPUT UNKNOWN',
'{count} INPUTS UNKNOWN',
),
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
title: partialPicture.title,
detail: partialPicture.detail,
target: null,
timestamp:
previous.get('input-status')?.timestamp ||
previous.get('feed-status')?.timestamp ||
snapshot.evaluatedAt ||
Date.now(),
});
+31 -2
View File
@@ -17,6 +17,30 @@ export function readAircraftInfo() {
});
}
/**
* The layer the active view rides: the aircraft's own layer. It is read from
* the tracked entity's id once the layer has dropped its tracking, so a layer
* turned off still names itself.
* @returns {'flights'|'military'|null}
*/
export function readSubjectLayerId() {
if (!this.active) return null;
const layerId = this.readAircraftInfo()?.layerId;
if (layerId) return layerId;
const [prefix] = String(this.trackedEntity?.gevTrackedId || '').split(':');
return ['flights', 'military'].includes(prefix) ? prefix : null;
}
/**
* The subject entry would take: a Cesium-tracked aircraft.
* @returns {'aircraft'|null}
*/
export function resolveEntryKind() {
return this.readAircraftInfo() && this.viewer.trackedEntity?.position
? 'aircraft'
: null;
}
export function dispatchCockpitModeChanged(active, info = null) {
const subjectId = active
? String(info?.icao24 || '')
@@ -131,6 +155,7 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
this.lastContextUpdateMs = 0;
this.lastCameraUpdateMs = 0;
this.contextNavigationDeadlineMs = 0;
this.onRide?.(info.layerId);
this.dispatchCockpitModeChanged(true, info);
return true;
}
@@ -206,6 +231,7 @@ export function enter() {
this.mapViewButton?.focus({ preventScroll: true });
});
this.onEntered?.();
this.onRide?.(info.layerId);
this.dispatchCockpitModeChanged(true, info);
return true;
}
@@ -247,9 +273,12 @@ export function exit({ restoreTracking = true } = {}) {
this.restoreTrackingFrame(entity);
}
this.syncEntry();
// Back to whichever entry was used: the Contacts button or the readout's.
const readoutEntry = document.getElementById('cockpit-entry-readout');
const restoreTarget =
this.entryFocusOrigin === this.entry
? this.entry
this.entryFocusOrigin === this.entry ||
(readoutEntry && this.entryFocusOrigin === readoutEntry)
? this.entryFocusOrigin
: this.entry || this.entryFocusOrigin;
this.entryFocusOrigin = null;
// The entry shows when Vue flushes the presence; read it after.
+6 -10
View File
@@ -1,7 +1,6 @@
import { t, translateKnown } from '../i18n/index.js';
import {
SPACE_MISSIONS_LAYER_BLOCK_REASON,
cockpitEntryAllowed,
isExplicitUserIntentOrigin,
parseContextLayerBlockReason,
recordContextRestoreExplicitChange,
@@ -245,15 +244,12 @@ export function _handleContextLayerChange(change) {
);
}
}
if (
this.cockpitView?.active &&
!cockpitEntryAllowed({
contextMode: this._contextMode,
contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
})
) {
// Turning off the layer the view rides ends it; every other layer may be
// turned on or off freely during a session.
const riddenLayerId = this.cockpitView?.active
? this.cockpitView.readSubjectLayerId?.()
: null;
if (riddenLayerId && !this._dataManager?.isEnabled(riddenLayerId)) {
this.cockpitView.exit({ restoreTracking: false });
}
this._syncContextModeButtons();
+6 -3
View File
@@ -18,7 +18,10 @@ export async function _selectContextMode(
this._contextModeChanging = true;
this._syncContextModeButtons();
try {
if (mode !== 'flights' && this.cockpitView?.active) {
// Cockpit does not need Contacts, so leaving it keeps the view; the
// restore below ends it only by turning off the layer it rides. Another
// mode takes the camera, so switching to one ends it.
if (mode && mode !== 'flights' && this.cockpitView?.active) {
this.cockpitView.exit({ restoreTracking: false });
}
if (!mode) {
@@ -220,8 +223,8 @@ export async function _deactivateContextForLayerChange({
this._contextMode = null;
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
if (this.cockpitView?.active)
this.cockpitView.exit({ restoreTracking: false });
// A layer turned off ends Contacts but not the Cockpit: the view ends only
// when the layer it rides goes off (see _handleContextLayerChange).
this._syncContextModeButtons();
try {
await this._restoreContextSession({ notificationToken });
+15
View File
@@ -117,3 +117,18 @@ test('a radio group moves from the focused option, an arrows group from the sele
assert.deepEqual(arrows.emitted('update:modelValue'), [['a']]);
arrows.unmount();
});
test("an option's own tip wins; an icon option without one is titled by its label", () => {
const wrapper = mountGroup({
options: [
{ value: 'a', label: 'Alpha', attrs: { title: 'Alpha tip' } },
{ value: 'b', label: 'Beta', icon: 'star' },
{ value: 'c', label: 'Gamma' },
],
});
const titles = wrapper
.findAll('button')
.map((b) => b.attributes('title') ?? null);
assert.deepEqual(titles, ['Alpha tip', 'Beta', null]);
wrapper.unmount();
});
+3 -2
View File
@@ -8,7 +8,8 @@ import { computed } from 'vue';
// End). Each option may carry `attrs` (an id, data attributes, a tip), and
// Core's classes sit beside the kit's: `gev-segmented`, `gev-segment` and the
// selected option's `activeClass`. An option with an `icon` shows the glyph,
// and its label becomes the button's accessible name and tooltip.
// and its label becomes the button's accessible name and tooltip, unless its
// `attrs` carry a tip of their own.
const props = defineProps({
modelValue: { type: String, default: undefined },
/** `{value, label, icon?, disabled?, attrs?}`, labels already translated. */
@@ -119,7 +120,7 @@ function onKey(event) {
]"
:aria-checked="String(option.value === props.modelValue)"
:aria-label="option.icon ? option.label : undefined"
:title="option.icon ? option.label : undefined"
:title="option.attrs?.title ?? (option.icon ? option.label : undefined)"
:disabled="optionOff(option)"
:tabindex="tabindex(index)"
@click="choose(index)"
+6
View File
@@ -141,6 +141,12 @@ export class LayerBindings {
});
this._weatherShellModules.push(layer);
}
// Active Fires drapes its far-zoom heatmap on the same imagery host.
const fires = this._dataManager?.layers?.get('local-firms')?.module;
if (typeof fires?.attachShellServices === 'function') {
fires.attachShellServices({ imageryHost: this.services.imageryHost });
this._weatherShellModules.push(fires);
}
}
_persistAwarenessSelection(event, cleared = false) {
+14
View File
@@ -141,3 +141,17 @@ export function sourceLabel(source) {
? source.split(' · ').map(translateKnown).join(' · ')
: translateKnown(source);
}
/**
* A provider name with the feed state it answered in, so a fallback or stale
* snapshot does not read as the live primary feed.
* @param {string} source English source name.
* @param {'fallback'|'stale'|null} status Feed state from `layerFeedState`.
* @returns {string}
*/
export function sourceStatusLabel(source, status) {
const name = sourceLabel(source);
if (status === 'fallback') return `${name} · ${t('FALLBACK')}`;
if (status === 'stale') return `${name} · ${t('STALE')}`;
return name;
}
+17
View File
@@ -7,6 +7,7 @@ import {
LAYER_NAME_KEYS,
PANEL_LABEL_KEYS,
sourceLabel,
sourceStatusLabel,
} from './layerNames.js';
// Layers whose module name is a proper name and stays as it is in every
@@ -51,3 +52,19 @@ for (const locale of ['es', 'ru']) {
}
});
}
test('a provider name carries the fallback or stale state it answered in', () => {
assert.equal(sourceStatusLabel('Plane Finder', null), 'Plane Finder');
assert.equal(sourceStatusLabel('adsb.lol', 'fallback'), 'adsb.lol · FALLBACK');
assert.equal(sourceStatusLabel('OpenSky Network', 'stale'), 'OpenSky Network · STALE');
setLocale('ru');
try {
assert.equal(
sourceStatusLabel('adsb.lol', 'fallback'),
`adsb.lol · ${t('FALLBACK')}`,
);
assert.notEqual(t('FALLBACK'), 'FALLBACK');
} finally {
setLocale('en');
}
});
+35 -5
View File
@@ -442,6 +442,8 @@ export class LayerPanel {
this._selectChip(layerId, chipId, generation),
onListItem: (layerId, itemId) =>
this._selectListItem(layerId, itemId, generation),
onSlider: (layerId, sliderId, value) =>
this._moveSlider(layerId, sliderId, value, generation),
}),
id === null ? this._toggleContainer : this._groupContainers.get(id),
);
@@ -471,7 +473,8 @@ export class LayerPanel {
}
/**
* A layer's row chips, color legend, info line and ordered list. The block
* A layer's row chips, one-of-many switch, slider, color legend, info line
* and ordered list. The block
* is hidden while the layer is off (or while a dependency owner has
* surrendered it), so a quiet row stays quiet. Chips and list items are
* keyed, so the refresh a chip's own click triggers keeps keyboard focus.
@@ -495,11 +498,20 @@ export class LayerPanel {
: `${item.label} ${formatCount(item.count)}`,
}));
const infoText = controls?.info ? String(controls.info) : '';
const segmented = controls?.segmented || null;
const slider = controls?.slider || null;
// The info line, once shown, stays in place and hides when empty.
if (infoText) row.info = true;
return {
hidden: chips.length === 0 && legend.length === 0 && !infoText,
hidden:
chips.length === 0 &&
legend.length === 0 &&
!infoText &&
!segmented &&
!slider,
chips,
segmented,
slider,
legend,
info: row.info
? { text: infoText, title: controls?.infoTitle || '' }
@@ -537,15 +549,33 @@ export class LayerPanel {
// listeners, so a click from a released generation is dropped.
_selectChip(id, chipId, generation = this._generation) {
if (this._destroyed || this._generation !== generation) return;
const chip = this._rowControlsFor(id)?.chips?.find(
(entry) => entry.id === chipId,
);
// A switch option carries the same fields as a chip.
const live = this._rowControlsFor(id);
const chip = [
...(live?.chips || []),
...(live?.segmented?.options || []),
].find((entry) => entry.id === chipId);
if (!chip || chip.disabled || !this.isEnabled(id)) return;
if (typeof chip.onClick === 'function') chip.onClick();
else if (chip.params)
this.setLayerParams(id, chip.params, { origin: 'user' });
}
// A row slider (e.g. the Active Fires minimum-FRP filter) maps its position
// to params through the live descriptor on every tick.
_moveSlider(id, sliderId, value, generation = this._generation) {
if (this._destroyed || this._generation !== generation) return;
const slider = this._rowControlsFor(id)?.slider;
if (
!slider ||
slider.id !== sliderId ||
typeof slider.params !== 'function' ||
!this.isEnabled(id)
)
return;
this.setLayerParams(id, slider.params(value), { origin: 'user' });
}
_selectListItem(id, itemId, generation = this._generation) {
if (this._destroyed || this._generation !== generation) return;
const item = this._rowControlsFor(id)?.list?.items?.find(
+13
View File
@@ -1,6 +1,8 @@
<script setup>
import RailChips from '../rail/RailChips.vue';
import RailRowList from '../rail/RailRowList.vue';
import RowSlider from './RowSlider.vue';
import RowSwitch from './RowSwitch.vue';
import RowText from './RowText.vue';
import { vClassToggle } from '../rail/classToggle.js';
@@ -12,6 +14,7 @@ defineProps({
onToggle: { type: Function, default: () => {} },
onChip: { type: Function, default: () => {} },
onListItem: { type: Function, default: () => {} },
onSlider: { type: Function, default: () => {} },
});
</script>
@@ -55,6 +58,16 @@ defineProps({
><RailChips
:chips="item.controls.chips"
:on-select="(chip) => onChip(item.id, chip.id)"
/><RowSwitch
v-if="item.controls.segmented"
:segmented="item.controls.segmented"
:on-select="(optionId) => onChip(item.id, optionId)"
/><RowSlider
v-if="item.controls.slider"
:slider="item.controls.slider"
:on-input="
(value) => onSlider(item.id, item.controls.slider.id, value)
"
/><span
v-for="(entry, index) in item.controls.legend"
:key="index"
+44
View File
@@ -0,0 +1,44 @@
<script setup>
import UiSlider from '../kit/UiSlider.js';
// A row's quantitative slider (e.g. the Active Fires minimum-FRP filter).
// `track` is a CSS background painting the runnable track, so a color ramp
// doubles as the legend; each tick sits at `at` (0..1) along it. The input
// holds its position while focused, so a refresh from its own tick never
// moves the thumb under the pointer.
defineProps({
slider: { type: Object, required: true },
onInput: { type: Function, default: () => {} },
});
</script>
<template>
<div class="data-toggle-slider" :data-slider-id="slider.id"
><div class="data-toggle-slider-head"
><span class="data-toggle-slider-label">{{ slider.label }}</span
><span class="data-toggle-slider-value gev-slider-value">{{
slider.valueText
}}</span></div
><UiSlider
class="data-toggle-slider-input"
:data-slider-id="slider.id"
:min="slider.min"
:max="slider.max"
:step="slider.step ?? 1"
:model-value="slider.value"
hold
:aria-label="slider.ariaLabel || undefined"
:aria-valuetext="slider.valueText || undefined"
:style="slider.track ? { '--slider-track': slider.track } : undefined"
@update:model-value="onInput"
/><div class="data-toggle-slider-ticks"
><span
v-for="tick in slider.ticks || []"
:key="tick.at"
class="data-toggle-slider-tick"
:style="{ '--at': `${(tick.at * 100).toFixed(2)}%` }"
>{{ tick.label }}</span
></div
></div
>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup>
import UiSegmented from '../kit/UiSegmented.vue';
// A row's one-of-many switch (e.g. Active Fires confidence). Options carry
// the same fields as chips, so the owner resolves a choice the way it
// resolves a chip; `label` is an optional caption beside the group.
defineProps({
segmented: { type: Object, required: true },
onSelect: { type: Function, default: () => {} },
});
const options = (segmented) =>
(segmented.options || []).map((option) => ({
value: option.id,
label: option.label,
disabled: Boolean(option.disabled),
attrs: { 'data-chip-id': option.id, title: option.title || undefined },
}));
const activeId = (segmented) =>
(segmented.options || []).find((option) => option.active)?.id;
</script>
<template>
<span
v-if="segmented.label"
class="data-toggle-segmented-label"
:data-segmented-caption-for="segmented.id"
>{{ segmented.label }}</span
><UiSegmented
class="data-toggle-segmented"
:data-segmented-id="segmented.id"
:model-value="activeId(segmented)"
:options="options(segmented)"
:label="segmented.ariaLabel || segmented.label || ''"
item-class="data-toggle-segment"
@update:model-value="onSelect"
/>
</template>
+116 -1
View File
@@ -39,8 +39,123 @@ test('the count-line class toggles without dropping the voice highlight', () =>
assert.equal(element.classList.contains('gev-voice-focus'), true);
render(h(LayerRows, { items: [row(false)] }), container);
assert.equal(element.classList.contains('data-toggle-row--count-line'), false);
assert.equal(
element.classList.contains('data-toggle-row--count-line'),
false,
);
assert.equal(element.classList.contains('gev-voice-focus'), true);
assert.equal(element.classList.contains('data-toggle-row'), true);
render(null, container);
});
const fireRow = (value, active = 'all') => ({
...row(false),
key: 'fires',
id: 'fires',
controls: {
hidden: false,
chips: [],
segmented: {
id: 'confidence',
label: 'Confidence',
ariaLabel: 'Detection confidence',
options: ['all', 'high'].map((mode) => ({
id: `confidence-${mode}`,
label: mode.toUpperCase(),
title: `${mode} title`,
active: mode === active,
})),
},
slider: {
id: 'min-frp',
label: 'Fire intensity (FRP)',
ariaLabel: 'Minimum fire radiative power, megawatts',
valueText: value ? `≥ ${value} MW` : 'All',
min: 0,
max: 1000,
step: 1,
value,
track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)',
ticks: [
{ at: 0, label: '0' },
{ at: 0.5, label: '30' },
],
},
legend: [],
info: null,
list: null,
},
});
test('a row switch and slider render their markup and report choices', () => {
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
const chips = [];
const moves = [];
const props = {
onChip: (id, chipId) => chips.push([id, chipId]),
onSlider: (id, sliderId, value) => moves.push([id, sliderId, value]),
};
render(h(LayerRows, { items: [fireRow(0)], ...props }), container);
const caption = container.querySelector('.data-toggle-segmented-label');
assert.equal(caption.textContent, 'Confidence');
const group = container.querySelector('.gev-segmented.data-toggle-segmented');
assert.equal(group.getAttribute('role'), 'radiogroup');
assert.equal(group.getAttribute('aria-label'), 'Detection confidence');
const segments = [
...group.querySelectorAll('.gev-segment.data-toggle-segment'),
];
assert.deepEqual(
segments.map((button) => button.dataset.chipId),
['confidence-all', 'confidence-high'],
);
assert.equal(segments[0].classList.contains('active'), true);
assert.equal(segments[1].title, 'high title');
segments[1].click();
assert.deepEqual(chips, [['fires', 'confidence-high']]);
const block = container.querySelector('.data-toggle-slider');
assert.equal(block.dataset.sliderId, 'min-frp');
assert.equal(
block.querySelector('.data-toggle-slider-label').textContent,
'Fire intensity (FRP)',
);
assert.equal(
block.querySelector('.data-toggle-slider-value').textContent,
'All',
);
const input = block.querySelector('input.data-toggle-slider-input');
assert.equal(input.classList.contains('gev-quantitative-slider'), true);
assert.equal(input.type, 'range');
assert.equal(input.max, '1000');
assert.equal(
input.getAttribute('aria-label'),
'Minimum fire radiative power, megawatts',
);
assert.equal(input.getAttribute('aria-valuetext'), 'All');
assert.match(
input.style.getPropertyValue('--slider-track'),
/^linear-gradient/,
);
const ticks = [...block.querySelectorAll('.data-toggle-slider-tick')];
assert.deepEqual(
ticks.map((tick) => tick.textContent),
['0', '30'],
);
assert.equal(ticks[1].style.getPropertyValue('--at'), '50.00%');
input.value = '120';
input.dispatchEvent(new Event('input', { bubbles: true }));
assert.deepEqual(moves, [['fires', 'min-frp', 120]]);
// The refresh that tick triggers keeps the same nodes and updates the text.
render(h(LayerRows, { items: [fireRow(120, 'high')], ...props }), container);
assert.equal(
container.querySelector('input.data-toggle-slider-input'),
input,
);
assert.equal(input.getAttribute('aria-valuetext'), '≥ 120 MW');
assert.equal(segments[1].classList.contains('active'), true);
render(null, container);
});
+30
View File
@@ -33,6 +33,36 @@
color: #00dcff;
font-size: 18px;
}
/* The same entry under the selected vehicle's Intel HUD readout. The HUD
lets clicks through, so the button takes them back. */
#intel-hud #cockpit-entry-readout {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--hud-border);
border-radius: 6px;
background: rgba(2, 12, 18, 0.72);
font-size: 10px;
letter-spacing: 0.14em;
pointer-events: auto;
cursor: pointer;
}
#intel-hud #cockpit-entry-readout[hidden] {
display: none;
}
#intel-hud #cockpit-entry-readout:hover,
#intel-hud #cockpit-entry-readout:focus-visible {
border-color: var(--hud-color);
outline: none;
}
#intel-hud #cockpit-entry-readout .material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-size: 16px;
text-transform: none;
}
#cockpit-entry kbd {
padding: 3px 5px;
border: 1px solid rgba(220, 238, 255, 0.25);
+112
View File
@@ -1042,6 +1042,118 @@ html:not([lang='en']) .data-row-list-text {
padding-top: 3px;
overflow-wrap: anywhere;
}
/* Optional per-layer one-of-many switch (Active Fires confidence). The shared
.gev-segmented surface, sized to the chip row. */
.data-toggle-controls .data-toggle-segmented {
flex-basis: 100%;
max-width: 220px;
}
.data-toggle-segmented-label {
flex-basis: 100%;
padding-top: 2px;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.2px;
color: var(--text-dim);
text-transform: uppercase;
}
.data-toggle-controls .data-toggle-segment {
min-height: 20px;
padding: 3px 6px;
font-size: 8px;
letter-spacing: 0.8px;
}
/* Optional per-layer quantitative slider (Active Fires minimum FRP). The
track is painted by the layer (`--slider-track`, a stepped color ramp), so
the slider doubles as the marker legend; ticks sit under their thresholds. */
.data-toggle-slider {
flex-basis: 100%;
display: grid;
gap: 2px;
padding-top: 2px;
}
.data-toggle-slider-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.2px;
color: var(--text-dim);
text-transform: uppercase;
}
.data-toggle-slider-value {
min-width: 0;
font-size: 9px;
letter-spacing: 0.5px;
text-transform: none;
}
.data-toggle-slider-input {
height: 14px;
}
.data-toggle-slider-input::-webkit-slider-runnable-track {
height: 5px;
border-radius: 3px;
background: var(--slider-track, rgba(255, 255, 255, 0.08));
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
}
.data-toggle-slider-input::-moz-range-track,
.data-toggle-slider-input::-moz-range-progress {
height: 5px;
border-radius: 3px;
background: var(--slider-track, rgba(255, 255, 255, 0.08));
}
.data-toggle-slider-input::-webkit-slider-thumb {
width: 11px;
height: 11px;
margin-top: -3px;
background: var(--text-primary);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.6),
0 0 6px rgba(255, 255, 255, 0.35);
}
.data-toggle-slider-input::-moz-range-thumb {
width: 11px;
height: 11px;
background: var(--text-primary);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.6),
0 0 6px rgba(255, 255, 255, 0.35);
}
.data-toggle-slider-ticks {
position: relative;
height: 10px;
margin: 0 5px;
font-family: var(--font-mono);
font-size: 7px;
letter-spacing: 0.4px;
color: var(--text-dim);
}
.data-toggle-slider-tick {
position: absolute;
top: 0;
left: var(--at);
transform: translateX(-50%);
white-space: nowrap;
}
.data-toggle-slider-tick:first-child {
transform: none;
}
/* ── Local RTL-SDR card (FM + Local ADS-B receiver) ── */
.sdr-radio-card {
display: grid;
+5 -2
View File
@@ -14,11 +14,14 @@ import MissionDetail from './panels/MissionDetail.vue';
import MissionRoster from './panels/MissionRoster.vue';
import RadioPanel from './panels/RadioPanel.vue';
import UiButton from '../kit/UiButton.vue';
import { cockpitEntryFace } from '../cockpitPresentation.js';
// ContextControls presents the mode switch and the nearby sites search and
// publishes them; until it does, the panel shows its initial markup.
const { view: ctx } = storeToRefs(useContextStore());
const { presence: cockpit } = storeToRefs(useCockpitStore());
// The entry rides the tracked aircraft (cockpitEntryFace).
const entry = computed(() => cockpitEntryFace('aircraft'));
// While the Bhote Koshi event is on, its panel is the rail's only content.
const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore());
// The Scene runtime chip SceneControls presents while a scene plays.
@@ -56,8 +59,8 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" :hidden="!ctx?.flightsActive">
<div class="global-context-actions" :aria-label="t('Contact Context actions')">
<button id="cockpit-entry" type="button" :hidden="!cockpit?.entryAvailable">
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span>{{ t('COCKPIT') }}</span>
<span class="material-symbols-outlined" aria-hidden="true">{{ entry.icon }}</span>
<span>{{ entry.label }}</span>
</button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)">{{ t('SEARCH NEARBY SITES') }}</button>
<UiButton id="tr3b-toggle" class="tr3b-toggle" :pressed="!!cockpit?.tr3b?.converted" :hidden="!cockpit?.tr3b" :aria-label="t('Reclassify tracked contact as TR-3B')" :title="cockpit?.tr3b?.converted ? t('Restore real aircraft') : t('Reclassify as TR-3B')">🛸</UiButton>
+12
View File
@@ -2,6 +2,8 @@
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useHudStore } from '../stores/hud.js';
import { useCockpitStore } from '../stores/cockpit.js';
import { cockpitEntryFace } from '../cockpitPresentation.js';
// The readouts, visibility, layout variant and colors IntelHUD publishes.
const { hud } = storeToRefs(useHudStore());
@@ -10,6 +12,12 @@ const { hud } = storeToRefs(useHudStore());
// right after IntelHUD is built.
const EMPTY = Object.freeze({ vessel: {}, sensor: [], recShown: null });
const view = computed(() => hud.value ?? EMPTY);
// The selected vehicle's readout offers the same entry as the Contacts
// button, so first person needs no Contacts panel. The Cockpit controller
// answers its clicks.
const { presence: cockpit } = storeToRefs(useCockpitStore());
// The entry rides the tracked aircraft (cockpitEntryFace).
const entry = computed(() => cockpitEntryFace('aircraft'));
</script>
<template>
@@ -46,6 +54,10 @@ const view = computed(() => hud.value ?? EMPTY);
<div class="hud-readout"><div id="hud-gsd">{{ view.gsd }}</div></div>
<div class="hud-readout"><div id="hud-alt">{{ view.alt }}</div></div>
<div class="hud-readout"><div id="hud-ais-vessel" :class="['hud-ais-vessel', { active: view.vessel.active }]">{{ view.vessel.text }}</div></div>
<button id="cockpit-entry-readout" type="button" :hidden="!cockpit?.entryAvailable">
<span class="material-symbols-outlined" aria-hidden="true">{{ entry.icon }}</span>
<span>{{ entry.label }}</span>
</button>
</div>
<div class="hud-bracket">┘</div>
</div>
+4 -4
View File
@@ -49,10 +49,10 @@ const weatherLabel = computed(() =>
<span class="cockpit-context-qualifier">{{ t('CONTEXT ONLY') }}</span>
</header>
<div class="cockpit-context-cohorts" role="list" :aria-label="t('Nearby cohort counts')">
<div v-class-toggle="{ unknown: c.cohorts.flights.unknown }" role="listitem" data-context-cohort="flights"><span>{{ t('FLT') }}</span><strong>{{ c.cohorts.flights.count }}</strong><small translate="no">OpenSky</small></div>
<div v-class-toggle="{ unknown: c.cohorts.military.unknown }" role="listitem" data-context-cohort="military"><span>{{ t('MIL') }}</span><strong>{{ c.cohorts.military.count }}</strong><small translate="no">adsb.lol</small></div>
<div v-class-toggle="{ unknown: c.cohorts['ais-live-vessels'].unknown }" role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>{{ c.cohorts['ais-live-vessels'].count }}</strong><small translate="no">AISStream</small></div>
<div v-class-toggle="{ unknown: c.cohorts['military-installations'].unknown }" role="listitem" data-context-cohort="military-installations"><span>{{ t('SITE') }}</span><strong>{{ c.cohorts['military-installations'].count }}</strong><small translate="no">OpenStreetMap</small></div>
<div v-class-toggle="{ unknown: c.cohorts.flights.unknown }" role="listitem" data-context-cohort="flights"><span>{{ t('FLT') }}</span><strong>{{ c.cohorts.flights.count }}</strong><small translate="no" :title="c.cohorts.flights.source">{{ c.cohorts.flights.source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts.military.unknown }" role="listitem" data-context-cohort="military"><span>{{ t('MIL') }}</span><strong>{{ c.cohorts.military.count }}</strong><small translate="no" :title="c.cohorts.military.source">{{ c.cohorts.military.source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts['ais-live-vessels'].unknown }" role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>{{ c.cohorts['ais-live-vessels'].count }}</strong><small translate="no" :title="c.cohorts['ais-live-vessels'].source">{{ c.cohorts['ais-live-vessels'].source }}</small></div>
<div v-class-toggle="{ unknown: c.cohorts['military-installations'].unknown }" role="listitem" data-context-cohort="military-installations"><span>{{ t('SITE') }}</span><strong>{{ c.cohorts['military-installations'].count }}</strong><small translate="no" :title="c.cohorts['military-installations'].source">{{ c.cohorts['military-installations'].source }}</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" v-class-toggle="{ unknown: c.direction.unknown }" class="material-symbols-outlined" :style="directionStyle" aria-hidden="true">navigation</span>
+3 -2
View File
@@ -55,6 +55,7 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Voice geometry adds resolve_area, find_imagery, osm_query, the analyst
// area scopes and the osm-places layer; natural-region candidates and the
// tightened area tool wording follow review.
// control_cockpit states the own-layer entry rule (Contacts not required).
// show_street_level (map, source, photo) is the only change on top of main.
SHOW_STREET_LEVEL_PAYLOAD_DIGEST,
);
@@ -74,12 +75,12 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
.digest('hex');
assert.equal(
shipped,
'd0c5ad733670972713a54cb10676c42b7c6e78834bfff52b473c0e8a45e742c3',
'9522ff6f6d640bede81c51cea6b9601439b08becc5bc20b7355aec24feb17e8d',
);
});
const SHOW_STREET_LEVEL_PAYLOAD_DIGEST =
'3ffa77ea3f4a1c9a14eddf4963c4498d6d32330414818a3ef0f24e744878e5aa';
'da21e84ba5c3831bd822464edd490e639e549607af26cd64ffbb0e31b60c32e3';
test('descriptions customize wording without changing immutable shared arguments', () => {
const descriptions = {
+32 -10
View File
@@ -1,6 +1,7 @@
import { bindChatAttachments } from './chatAttachments.js';
import { bindChatPreferences } from './chatPreferences.js';
import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js';
import { isStoppable } from './session.js';
import { t } from '../i18n/index.js';
/**
@@ -94,7 +95,9 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) {
: '';
function open({ focus = true } = {}) {
leaveChatSettings();
if (!isTyped() && session.isActive()) session.stop();
// Chat replaces voice, a stale voice error included, so the dock stops
// showing it.
if (!isTyped() && isStoppable(session)) session.stop();
onOpen?.();
ui.update('chat', { open: true, minimized: false, rows: 2, authNote });
sync();
@@ -389,15 +392,7 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) {
}
const frame = ui.element('chatPanel')?.getBoundingClientRect?.();
close();
ui.update('voicePanel', {
context: 'chat',
...(frame?.width && {
frameWidth: frame.width,
frameHeight: frame.height,
}),
});
ui.update('chat', { backVisible: true });
sync();
showChatSettings(frame);
void ui.focus(['chatBack']);
}),
// The panel clears its context as it closes, so chat tracks its own.
@@ -413,10 +408,37 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) {
}),
];
sync();
// Settings shown from chat, in chat's frame when it has one.
function showChatSettings(frame) {
ui.update('voicePanel', {
context: 'chat',
...(frame?.width && {
frameWidth: frame.width,
frameHeight: frame.height,
}),
});
ui.update('chat', { backVisible: true });
sync();
}
// What a provider switch carries to the next mount: chat's Settings, never
// the conversation. The provider choice lives only in Settings, so chat
// itself is never open during a switch.
function presentation() {
if (!inChatSettings()) return null;
const { frameWidth: width, frameHeight: height } = ui.state.voicePanel;
return { view: 'settings', frame: width ? { width, height } : null };
}
// Restored without moving focus: the switch keeps it on the provider choice.
function restorePresentation(state) {
if (disposed || state?.view !== 'settings') return;
showChatSettings(state.frame);
}
return {
close,
minimize,
restore: open,
presentation,
restorePresentation,
get minimized() {
return chat().open && chat().minimized;
},
+32
View File
@@ -364,3 +364,35 @@ test('the chat gear is named for the settings it opens', async (t) => {
'Settings',
);
});
test('a provider switch carries chat Settings to the next mount', async (t) => {
const mount = () => {
const ui = createTestVoiceUi({ panel: true });
const chat = bindChatControl({ ui, session: typedSession() });
t.after(() => chat.destroy());
return { ui, chat };
};
// Chat's Settings: the next mount shows Settings in chat's frame again.
const before = mount();
before.ui.emit('chat-button');
before.ui.update('voicePanel', { hidden: false });
before.ui.emit('settings-button');
before.ui.update('voicePanel', { frameWidth: 320, frameHeight: 480 });
const settings = before.chat.presentation();
assert.deepEqual(settings, {
view: 'settings',
frame: { width: 320, height: 480 },
});
const after = mount();
after.chat.restorePresentation(settings);
assert.equal(after.ui.state.voicePanel.context, 'chat');
assert.equal(after.ui.state.voicePanel.frameWidth, 320);
assert.equal(after.ui.state.voicePanel.frameHeight, 480);
assert.equal(after.ui.state.chat.backVisible, true);
assert.equal(after.ui.state.root.assistantMode, 'chat');
// Chat itself, or no chat at all, carries nothing.
const open = mount();
open.ui.emit('chat-button');
assert.equal(open.chat.presentation(), null);
assert.equal(mount().chat.presentation(), null);
});
+6 -6
View File
@@ -22,6 +22,10 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) {
msg('Reset to globe view'),
msg('Show earthquakes'),
].map((text) => t(text));
// Arabic short vowels and shadda are optional in writing: typing "تتبع"
// still finds "تتبّع".
const fold = (text) =>
text.toLowerCase().replace(/[\u064B-\u065F\u0670]/g, '');
try {
const stored = JSON.parse(localStorage.getItem(key));
if (typeof stored?.quick === 'boolean') quick = stored.quick;
@@ -31,18 +35,14 @@ export function bindChatPreferences({ ui, getDraft, setDraft }) {
function render() {
const draft = getDraft();
if (historyIndex >= 0 && draft !== recent[historyIndex]) resetHistory();
const query = draft.trim().toLowerCase();
const query = fold(draft.trim());
const groups = [];
if (query) {
const candidates = [...recent, ...(quick ? quickCommands() : [])];
groups.push({
label: t('Suggestions'),
items: [...new Set(candidates)]
.filter(
(text) =>
text.toLowerCase().includes(query) &&
text.toLowerCase() !== query,
)
.filter((text) => fold(text).includes(query) && fold(text) !== query)
.slice(0, 4),
});
} else {
+19
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { bindChatPreferences } from './chatPreferences.js';
import { createTestVoiceUi } from '../testSupport/voiceUi.mjs';
@@ -99,3 +100,21 @@ test('editing a recalled message ends recall, and sending resets it', (t) => {
press('ArrowUp');
assert.equal(field.value, 'Show live flights now');
});
test('autocomplete ignores Arabic short vowels and shadda', (t) => {
setLocale('ar');
t.after(() => setLocale('en'));
const ui = createTestVoiceUi();
let draft = '';
const prefs = bindChatPreferences({ ui, getDraft: () => draft });
t.after(() => prefs.destroy());
const suggested = (text) => {
draft = text;
prefs.render();
return ui.state.chat.suggestions.flatMap((group) => group.items);
};
// The quick command is written "تتبّع", with a shadda.
assert.deepEqual(suggested('تتبع'), ['اعرض تتبّع الرحلات المباشر']);
prefs.remember('انتقل إلى طوكيو');
assert.deepEqual(suggested('طُوكيو'), ['انتقل إلى طوكيو']);
});
+71
View File
@@ -1298,6 +1298,77 @@ test('undocking while a tray is open brings the panel back as its bar', async (t
assert.equal($('#gev-chat-panel').dataset.placement, 'compact');
});
test('opening chat or closing the voice panel clears a stale voice error', async (t) => {
for (const leave of ['chat', 'close']) {
const f = mounted(t, { view: true });
await f.commands.session.start();
f.commands.voicePanel.select('preview');
f.instances[0].hooks.emit({
type: 'state',
state: 'error',
detail: 'Lost',
});
await f.settle();
assert.equal($('#gev-voice-control').dataset.status, 'error');
assert.equal(f.commands.session.isActive(), false);
if (leave === 'chat') $('#gev-chat-button').click();
else $('#gev-voice-settings-close').click();
await f.settle();
assert.equal(f.commands.session.state, 'idle', leave);
assert.notEqual($('#gev-voice-control').dataset.status, 'error', leave);
}
});
test('switching AI Provider from chat Settings keeps chat Settings on the new mount', async (t) => {
const view = mountVoiceView(t);
const host = createVoicePanelHost({ storage: null });
const instances = [];
const commands = createProviderCommands(
{
runner: async () => ({}),
dataManager: { layers: new Map() },
supportedProviders: ['openai', 'gemini'],
},
{
bind: (options) =>
bind({
...options,
panelHost: host,
createControl: (control) =>
createTestVoiceUi({ ...control, pinia: view.pinia }),
}),
factories: { openai: factory(instances), gemini: factory(instances) },
storage: null,
},
);
t.after(() => {
commands.destroy();
host.destroy();
});
await view.settle();
$('#gev-chat-button').click();
await view.settle();
$('#gev-chat-panel').getBoundingClientRect = () => ({
width: 320,
height: 480,
});
$('#gev-voice-settings-button').click();
await view.settle();
const old = commands.ui;
assert.equal(old.state.voicePanel.context, 'chat');
old.emit('provider-change', 'gemini');
await view.settle();
const { ui } = commands;
assert.notEqual(ui, old);
assert.equal(ui.state.voicePanel.context, 'chat');
assert.equal(ui.state.voicePanel.frameWidth, 320);
assert.equal(ui.state.voicePanel.frameHeight, 480);
assert.equal(ui.state.chat.backVisible, true);
assert.equal(ui.state.root.assistantMode, 'chat');
assert.equal($('#gev-chat-back').hidden, false);
assert.equal(active(), 'gev-voice-provider');
});
test('the voice panel close turns voice off', async (t) => {
const f = mounted(t, { view: true });
await f.commands.session.start();
+5 -2
View File
@@ -64,6 +64,8 @@ export function createProviderCommands(options, { bind, factories, storage }) {
const textOnly = Boolean(
current.session.adapter.getStartOptions?.().textOnly,
);
// Settings opened from chat stay chat's Settings.
const presentation = current.chatPresentation?.() ?? null;
const generation = ++mountGeneration;
voiceEnabled = false;
current.session.destroy();
@@ -80,7 +82,7 @@ export function createProviderCommands(options, { bind, factories, storage }) {
} catch {
/* Storage is optional. */
}
mount();
mount(presentation);
if (resume && !disposed && provider === next)
void current.session.start({
pushToTalk,
@@ -142,7 +144,7 @@ export function createProviderCommands(options, { bind, factories, storage }) {
return typeof value === 'function' ? value.bind(current) : value;
},
});
function mount() {
function mount(presentation = null) {
voiceEnabled = false;
const generation = ++mountGeneration;
current = bind({
@@ -158,6 +160,7 @@ export function createProviderCommands(options, { bind, factories, storage }) {
handleSessionEvent(event);
},
createSession: factories[provider],
presentation,
});
window.__gevVoiceCommands = facade;
}
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more