feat(air-quality): add the Air quality layer to the Weather group

Reworks #568 to the shape asked for in review.

Generalized from "AQHI" to a provider-backed Air quality layer, with ECCC's
AQHI as the first registered provider. Readings are carried as
{ provider, scale, value, band } rather than a bare number, because
air-quality indices are not interchangeable: AQHI runs 1-10+ on health risk,
the US AQI runs 0-500 on concentration breakpoints, and Europe's EAQI is a
five-step category. Naming the index a value belongs to is what lets another
network be added without inventing a shared numeric meaning. Coverage is
stated as "Canada only" on the provider, the layer row and the card.

Adds the WEATHER panel readout (readout: true) carrying the band legend and
the worst readings currently in view, with getRowControls/setRowControlsListener
following the wind and cyclone cards. An unresolvable view rectangle degrades
to the worst readings overall rather than to a blank card, and a rectangle
crossing the antimeridian is handled.

update() now takes the earthquakes layer's abort-and-guard shape: it aborts any
in-flight request, passes the signal through to the source, and refuses to
touch the scene when the request was superseded, aborted, or the layer was
disabled while it was outstanding. disable() and destroy() abort too. Tests
cover the late-response, superseded-refresh and disabled-update paths.

Rebased on main: the source is registered in src/sources/reference.js, and the
allocation profile was re-measured against the current worker — median 3,900
B/frame, max 4,114, 85.7 B/candidate, inside the shared 154 ceiling, budgeted
at 5,200 for 26% headroom. The earlier figures no longer held.

Freshness is now judged on the observation time rather than on when the last
response arrived. lastUpdate only says the feed answered; a station that keeps
returning a reading from four hours ago would otherwise be narrated as live by
the HUD and the voice provenance envelope. getStats() reports observedAt and
stale, so layerFeedState() calls the layer stale past two missed hourly
publications, and degraded when a refresh fails over readings that are still
good.

The share-link token is 0, which is what layer-token:next allocates. Taking it
moves the ledger fixtures that had hard-coded 0 as the free digit and 3 as the
one after; those shift to 3 and 4. The pinned equality between the reservation
ledger and LEGACY_LAYER_STATE_TOKENS also had to become "the closed legacy set
plus air-quality", since that set is documented as never growing. Happy to
re-pin this differently if you would rather the fixtures derive the provisional
digits from nextLayerStateToken() so the next layer does not move them again.

The layer and its source are registered as ./layers/air-quality and
./layers/air-quality/source, each with its own boundary group, following the
perimeters pair. check:boundaries requires the package exports and the group
exports to be the same set, so a layer that ships a source needs both.
This commit is contained in:
rileygramlich committed 2026-10-02 15:12:37 -06:00
1 parent e7707d9a0f
commit 8887ca47eb
26 files changed
+1730 -41

No files matched your search

+35 -28
View File
@@ -122,13 +122,20 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 28);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29);
// The legacy assignments are a closed compatibility set that never grows, so
// the ledger is exactly that set plus every token reserved since.
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{ ...LEGACY_LAYER_STATE_TOKENS },
{ ...LEGACY_LAYER_STATE_TOKENS, 'air-quality': '0' },
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
() =>
parseLayerStateTokenReservations(
// Dropping any legacy row must fail. This used to slice off row 0,
// which is no longer a legacy reservation.
reservationRows.filter(([id]) => id !== 'ais-live-vessels'),
),
/Missing or changed legacy/,
);
assert.throws(
@@ -145,9 +152,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '0']])
parseLayerStateTokenReservations([...reservationRows, ['future', '3']])
.future,
'0',
'3',
);
assert.throws(
() =>
@@ -165,14 +172,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '0']] },
{ rows: [...reservationRows, ['retired-layer', '3']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '3');
assert.equal(nextLayerStateToken(published), '4');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '3',
newcomer: '4',
}),
true,
);
@@ -182,7 +189,7 @@ test('published retired digits and competing PRs advance the merge-time allocati
...published,
competing: '0',
}),
/next free token 3/,
/next free token 4/,
);
assert.throws(
() =>
@@ -196,8 +203,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (cwd) => {
test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -218,8 +225,8 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '0']],
[{ id: 'pr-b', token: '0' }],
[...reservationRows, ['pr-b', '3']],
[{ id: 'pr-b', token: '3' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -227,10 +234,10 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '0'], ['pr-b', '0']],
[...reservationRows, ['pr-a', '3'], ['pr-b', '3']],
[
{ id: 'pr-a', token: '0' },
{ id: 'pr-b', token: '0' },
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '3' },
],
);
const staleRebase = runCheck();
@@ -238,16 +245,16 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '3');
const validBaselineRows = [...reservationRows, ['pr-a', '0']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '0' }]);
assert.equal(nextLayerStateToken(published), '4');
const validBaselineRows = [...reservationRows, ['pr-a', '3']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 3/);
assert.match(helper.stdout, /pr-b: 4/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -255,20 +262,20 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '0'], ['pr-b', '3']],
[...reservationRows, ['pr-a', '3'], ['pr-b', '4']],
[
{ id: 'pr-a', token: '0' },
{ id: 'pr-b', token: '3' },
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '4' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /29 published, 1 new/);
assert.match(corrected.stdout, /30 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'03456789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -320,7 +327,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'03456789'].map((digit) => [
const priorDigits = [...'3456789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -357,7 +364,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 67);
assert.equal(params.get('l').length, 69);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -375,7 +382,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 29);
assert.equal(Object.keys(published).length, 30);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>