Merge upstream/main: air-quality layer (AQHI) meets i18n

Upstream's #568 adds the Air quality layer to the Weather group (ECCC
AQHI, Canada only) with a band legend, worst-first list and voice
support.

- package-boundaries: both sides' entries unioned.
- layerPanel/weatherPanel tests: both sides' new tests kept; the
  weatherPanel fixture follows upstream (legendCategorical: true, the
  'list' block id) while our localized-history (K-6) test stays.
- air-quality strings marked: band labels and the provider label as
  msg() data, the readings-list aria-label through t(); es/ru entries
  added (+6 keys each).
- The synthetic air-quality allocation-benchmark titles follow the
  file's own convention (// i18n-ignore) like TRK/VESSEL/CAMERA.

Also verified the auto-merged weatherPanel.js against upstream: ORDER
gains 'air-quality', OBSERVED stays slice(2, 5), and the data-driven
legendCategorical path is upstream's.

npm run i18n: complete (2559), no unmarked strings. npm test: only the
pre-existing environmental OPENAI_API_KEY leak fails. Build passes.
This commit is contained in:
Meir Michanie committed 2026-10-15 23:16:24 +02:00
commit f3da0916d9
38 files changed
+2061 -62

No files matched your search

+76 -3
View File
@@ -26,6 +26,12 @@ import {
TRACKED_OVERLAY_SOURCE_OPTIONS,
} from '../data/trackedReadout.js';
import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js';
import {
AIR_QUALITY_OVERLAY_COHORT_LIMIT,
AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
AIR_QUALITY_OVERLAY_SOURCE_ID,
createAirQualityOverlayEntry,
} from '../layers/airQuality/model.js';
import {
CCTV_OVERLAY_SOURCE_ID,
createCctvThumbnailOverlayEntry,
@@ -1110,6 +1116,69 @@ function buildSubmarineCablesWorkload(count) {
);
}
/**
* The air-quality station cohort at full size.
*
* Measured standalone (the submarine-cable precedent) rather than stacked on
* the Phase-5 host: air-quality stations are sparse and, with the single
* currently-registered provider, continental — so the question a budget has to
* answer is what its own cohort costs, not what it adds to a worldwide worst
* case it never shares a viewport with. The entry shape is the same static
* `ambient-label` the earthquake layer publishes, so the per-candidate ceiling
* is the meaningful gate.
*/
function buildAirQualityWorkload(count) {
if (count !== AIR_QUALITY_OVERLAY_COHORT_LIMIT) {
throw new Error(
`air-quality requires ${AIR_QUALITY_OVERLAY_COHORT_LIMIT} entries`,
);
}
const workload = {
entries: [],
positions: [],
drifts: [],
registrations: [],
};
const columns = Math.max(1, Math.ceil(Math.sqrt((count * 16) / 9)));
const rows = Math.max(1, Math.ceil(count / columns));
const spanX = columns > 1 ? 1.68 / (columns - 1) : 0;
const spanY = rows > 1 ? 1.56 / (rows - 1) : 0;
const stations = [];
for (let index = 0; index < count; index++) {
const column = index % columns;
const row = Math.floor(index / columns);
const baseX = -0.84 + column * spanX;
const baseY = -0.78 + row * spanY;
const position = new Cesium.Cartesian3(baseX, baseY, 0);
workload.positions.push(position);
workload.drifts.push({
baseX,
baseY,
phase: index * 0.27,
rate: 0.32 + (index % 6) * 0.04,
});
const entry = createAirQualityOverlayEntry({
id: `air-${index}`,
position,
// i18n-ignore: synthetic allocation-benchmark fixture, not shown as UI
title: `Station ${index} ${1 + (index % 10)}`,
accent: '#4cc9f0',
value: 1 + (index % 10),
});
entry.horizonCull = false;
stations.push(entry);
}
workload.registrations.push({
sourceId: AIR_QUALITY_OVERLAY_SOURCE_ID,
entries: stations,
options: {
cohortLimit: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
collisionCapacity: AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
},
});
return workload;
}
function buildPhase6DetectionWorkload(count) {
const random = makeRandom(0xd37ec710);
const positions = [];
@@ -1215,9 +1284,13 @@ function main() {
? buildPhase5RocketMissionWorkload(ENTRY_COUNT)
: PROFILE === 'all-live-radio'
? buildAllLiveRadioWorkload(ENTRY_COUNT)
: PROFILE === 'submarine-cables'
? buildSubmarineCablesWorkload(ENTRY_COUNT)
: buildWorkload(ENTRY_COUNT);
: PROFILE === 'air-quality'
? buildAirQualityWorkload(ENTRY_COUNT)
: PROFILE === 'submarine-cables'
? buildSubmarineCablesWorkload(
ENTRY_COUNT,
)
: buildWorkload(ENTRY_COUNT);
const { entries, positions, drifts } = workload;
const solveIntervalMs = Number(process.env.GEV_ALLOC_SOLVE_MS) || 125;
const detectionActive = !!workload.detectionLayer;