feat(weather): add globe wind, observed weather and cyclone context (#617)

Wind (NOAA GFS and ECMWF IFS: animated particles, colour field, a reading at map centre), observed weather (NOAA MRMS radar, GOES and NESDIS infrared clouds, lightning density) on one shared history timeline, NHC and CPHC cyclone advisories with tracks and cones, and a WEATHER panel in the right rail. On 3D Tiles the observed products render as raised shells with a sharper window around the view.

Co-authored-by: Gustavo Beneduzi <[email protected]>
Co-authored-by: Daniel Slay <[email protected]>
Co-authored-by: Rehaan Delmotra <[email protected]>
Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-22 17:45:39 -05:00
1 parent f01b6a5d84
commit 0251c74109
180 files changed
+30645 -434

No files matched your search

+7
View File
@@ -1,3 +1,4 @@
import { resolveImageryHost } from '../layers/weather/imageryHost.js';
import { ShellFacade } from './shellFacade.js';
import { AircraftDisplay } from './aircraftDisplay.js';
import { LayerBindings } from './layerBindings.js';
@@ -230,6 +231,11 @@ export class StyleManager extends ShellFacade {
this._layerBindings = new LayerBindings({
viewer,
services: {
imageryHost: () =>
resolveImageryHost({
viewer,
tileset: mapStackController?.getImageryHostTileset?.(),
}),
cachedGroundFloor: services.cachedGroundFloor,
warmGroundFloor: services.warmGroundFloor,
cctvLayer: services.cctvLayer,
@@ -891,6 +897,7 @@ export class StyleManager extends ShellFacade {
_cctvFocusBtn: this._cctvFocusBtn,
_cctvFrame: this._cctvFrame,
_cctvFrameWrap: this._cctvFrameWrap,
_cctvVideo: this._cctvVideo,
_cctvMeta: this._cctvMeta,
_cctvNearestBtn: this._cctvNearestBtn,
_cctvNextBtn: this._cctvNextBtn,
+15
View File
@@ -38,6 +38,19 @@ export class CctvControls {
this._calibrationEdit = null;
this._actionGeneration = 0;
this._initCctvPanel();
if (this._cctvVideo && typeof MutationObserver !== 'undefined') {
this._videoVisibilityObserver = new MutationObserver(() =>
this._renderCctvState(this._cctvState),
);
if (this._cctvPanel)
this._videoVisibilityObserver.observe(this._cctvPanel, {
attributes: true,
attributeFilter: ['class', 'hidden'],
});
this.listen(document, 'visibilitychange', () =>
this._renderCctvState(this._cctvState),
);
}
}
listen(target, type, handler, options = {}) {
target?.addEventListener(type, handler, {
@@ -99,6 +112,8 @@ export class CctvControls {
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this._cctvVideoSurface?.stop();
this._videoVisibilityObserver?.disconnect();
this._actionGeneration++;
this.listeners.abort();
this._cctvUnsubscribe?.();
+24 -1
View File
@@ -1,3 +1,4 @@
import { createCctvVideoSurface } from './cctvVideo.js';
export function _calBadgeLabel(badge) {
switch (badge) {
case 'calibrated':
@@ -164,7 +165,27 @@ export function _renderCctvState(state) {
}
}
if (this._cctvFrame) {
const liveIntent = enabled && !!activeCamera?.isVideo;
if (this._cctvVideo) {
this._cctvVideo.hidden = !liveIntent;
if (this._cctvFrame) this._cctvFrame.hidden = liveIntent;
const visible =
liveIntent &&
!document.hidden &&
!this._cctvPanel?.classList.contains('collapsed');
if (!visible || this._cctvVideoCameraId !== activeId) {
this._cctvVideoSurface?.stop();
this._cctvVideoSurface = null;
}
this._cctvVideoCameraId = activeId;
if (visible && !this._cctvVideoSurface) {
this._cctvVideoSurface = createCctvVideoSurface(this._cctvVideo, () =>
this.cctv.getActiveVideoElement?.(),
);
}
}
if (this._cctvFrame && !liveIntent) {
const nextSrc = enabled ? activeCamera?.frameUrl : null;
const nextCameraId = enabled ? activeCamera?.id || '' : '';
const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId;
@@ -183,6 +204,8 @@ export function _renderCctvState(state) {
if (!nextSrc) {
this._clearCctvFrame();
}
} else if (liveIntent) {
this._clearCctvFrame();
}
this._syncCctvSourceBadge(activeCamera, enabled);
+59
View File
@@ -0,0 +1,59 @@
/** Paint a second surface from the projection decoder, bounded to 640px/15 fps. */
export function createCctvVideoSurface(
canvas,
getVideo,
{
requestFrame = requestAnimationFrame,
cancelFrame = cancelAnimationFrame,
} = {},
) {
const ctx = canvas.getContext('2d');
let handle = 0;
let stopped = false;
let previous = null;
let previousTime = -1;
let paintedAt = -Infinity;
const paint = (now) => {
if (stopped) return;
const video = getVideo();
if (video !== previous) {
ctx?.clearRect(0, 0, canvas.width, canvas.height);
previous = video;
previousTime = -1;
}
if (
ctx &&
video?.readyState >= 2 &&
video.videoWidth > 0 &&
video.videoHeight > 0 &&
now - paintedAt >= 1000 / 15 &&
video.currentTime !== previousTime
) {
const width = Math.min(640, video.videoWidth);
const height = Math.max(
1,
Math.round((width * video.videoHeight) / video.videoWidth),
);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
try {
ctx.drawImage(video, 0, 0, width, height);
previousTime = video.currentTime;
paintedAt = now;
} catch {
/* A resolution/decode transition retries on the next frame. */
}
}
handle = requestFrame(paint);
};
handle = requestFrame(paint);
return {
stop() {
stopped = true;
cancelFrame(handle);
ctx?.clearRect(0, 0, canvas.width, canvas.height);
},
};
}
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCctvVideoSurface } from './cctvVideo.js';
test('second surface uses shared decoder, caps draws, clears switch and cancels teardown', () => {
let callback;
let draws = 0;
let clears = 0;
let cancelled = 0;
const canvas = {
width: 1,
height: 1,
getContext: () => ({ drawImage: () => draws++, clearRect: () => clears++ }),
};
let v = {
readyState: 2,
videoWidth: 1920,
videoHeight: 1080,
currentTime: 1,
};
const surface = createCctvVideoSurface(canvas, () => v, {
requestFrame: (fn) => {
callback = fn;
return 1;
},
cancelFrame: () => cancelled++,
});
callback(0);
callback(20);
callback(80);
assert.equal(draws, 1);
assert.equal(canvas.width, 640);
v = { ...v };
callback(160);
assert.equal(draws, 2);
assert.equal(clears, 2);
surface.stop();
callback(200);
assert.equal(cancelled, 1);
assert.equal(draws, 2);
});
+42
View File
@@ -0,0 +1,42 @@
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
const attribute = (node, key, value) => {
if (node.getAttribute(key) !== value) node.setAttribute(key, value);
};
/** Keyed chips shared by layer rows and rail cards; the caller owns dispatch. */
export function syncChipGroup(container, chips = [], { before = null } = {}) {
if (!container) return;
const document = container.ownerDocument ?? globalThis.document;
const stale = new Map();
for (const node of [...container.children])
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
const ordered = [];
for (const chip of chips) {
let button = stale.get(chip.id);
stale.delete(chip.id);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.dataset.chipId = chip.id;
}
const state = chip.state || (chip.active ? 'active' : 'idle');
set(
button,
'className',
`data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`,
);
set(button, 'textContent', chip.label);
set(button, 'title', chip.title || '');
set(button, 'disabled', Boolean(chip.disabled));
attribute(button, 'aria-pressed', String(Boolean(chip.active)));
attribute(button, 'aria-busy', String(Boolean(chip.busy)));
ordered.push(button);
}
for (const node of stale.values()) node.remove();
ordered.forEach((node, index) => {
if (container.children[index] !== node)
container.insertBefore(node, container.children[index] || before);
});
}
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { syncChipGroup } from './chipGroup.js';
import { railFixture } from './railTestFixture.mjs';
test('keyed chips retain focus, reflect state, reorder and remove without redundant writes', () => {
let writes = 0;
const f = railFixture(() => writes++);
const a = { id: 'a', label: 'GFS', active: true };
const b = { id: 'b', label: 'ECMWF' };
const legend = f.document.createElement('span');
f.container.appendChild(legend);
syncChipGroup(f.container, [a, b], { before: legend });
const first = f.container.children[0];
first.focus();
const next = [{ ...a, label: 'Changed', busy: true, disabled: true }, b];
syncChipGroup(f.container, next);
assert.equal(f.container.children[0], first);
assert.equal(f.document.activeElement, first);
assert.equal(first.disabled, true);
assert.equal(first.getAttribute('aria-busy'), 'true');
assert.equal(first.getAttribute('aria-pressed'), 'true');
writes = 0;
syncChipGroup(f.container, next);
assert.equal(writes, 0);
syncChipGroup(f.container, [b, a]);
assert.equal(f.container.children[1], first);
assert.equal(f.container.children[2], legend);
syncChipGroup(f.container, [a]);
assert.deepEqual(f.container.children, [first, legend]);
syncChipGroup(f.container, []);
assert.deepEqual(f.container.children, [legend]);
});
+25
View File
@@ -33,6 +33,7 @@ export class LayerBindings {
this._disposed = false;
this._dataManager = null;
this._directionsShellModule = null;
this._weatherShellModules = [];
this._cctvRequestFocusHandler = null;
this._removeCctvRequestFocusListener = null;
this._worldRequestFocusHandler = null;
@@ -116,6 +117,28 @@ export class LayerBindings {
});
}
_connectWeatherCamera() {
for (const layer of this._weatherShellModules)
layer.attachShellServices?.(null);
this._weatherShellModules = [];
for (const id of [
'wind',
'weather-radar',
'weather-satellite',
'weather-lightning',
'weather-cyclones',
]) {
const layer = this._dataManager?.layers?.get(id)?.module;
if (typeof layer?.attachShellServices !== 'function') continue;
layer.attachShellServices({
runNavigation: (navigate) =>
this.runImmediateNavigation('weather', navigate),
imageryHost: this.services.imageryHost,
});
this._weatherShellModules.push(layer);
}
}
_persistAwarenessSelection(event, cleared = false) {
if (!this._dataManager) return;
const origin = String(event?.detail?.origin || 'programmatic');
@@ -212,6 +235,7 @@ export class LayerBindings {
this._cctvControls.connect();
this._radioControls.connect();
this._connectDirectionsCamera();
this._connectWeatherCamera();
if (!this._awarenessSelectedHandler) {
this._awarenessSelectedHandler = (event) =>
this._persistAwarenessSelection(event, false);
@@ -263,5 +287,6 @@ export class LayerBindings {
this._directionsShellModule?.attachShellServices?.(null);
this._directionsShellModule = null;
this._dataManager = null;
this._connectWeatherCamera();
}
}
+166 -139
View File
@@ -1,7 +1,10 @@
import { syncChipGroup } from './chipGroup.js';
import { syncRowList } from './rowList.js';
import { layerFeedState } from '../data/feedState.js';
export { layerFeedState } from '../data/feedState.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { keySetupRequirement } from '../keySetupCore.mjs';
import { createWeatherPanel } from './weatherPanel.js';
const FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
@@ -43,6 +46,16 @@ const PANEL_GROUPS = [
label: 'Events',
ids: ['rocket-launches', 'earthquakes', 'local-firms'],
},
{
label: 'Weather',
ids: [
'wind',
'weather-radar',
'weather-satellite',
'weather-lightning',
'weather-cyclones',
],
},
{
label: 'Utilities',
ids: ['directions', 'radio'],
@@ -98,7 +111,9 @@ export class LayerPanel {
hasRowControls,
subscribeRowControls,
onHiddenRefresh = () => {},
weatherClock,
}) {
this.weatherClock = weatherClock;
this.getAll = getLayers;
this.isEnabled = isEnabled;
this.setEnabled = setEnabled;
@@ -110,11 +125,19 @@ export class LayerPanel {
this._generation = 0;
this._removers = [];
this._destroyed = false;
this._cancelRowControlsRefresh = null;
}
mount(container) {
if (this._destroyed) return;
this._releaseBindings();
this._toggleContainer = container;
this._weatherPanel?.destroy();
this._weatherPanel = createWeatherPanel({
clock: this.weatherClock,
container:
container?.ownerDocument?.getElementById?.('weather-panel-body'),
setLayerParams: this.setLayerParams,
});
this._renderToggles();
}
_bind(element, type, listener) {
@@ -123,12 +146,16 @@ export class LayerPanel {
}
_releaseBindings() {
this._generation++;
this._cancelRowControlsRefresh?.();
this._cancelRowControlsRefresh = null;
for (const remove of this._removers.splice(0)) remove();
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
this._releaseBindings();
this._weatherPanel?.destroy();
this._weatherPanel = null;
this._toggleContainer = null;
}
_renderToggles() {
@@ -210,7 +237,8 @@ export class LayerPanel {
}
});
right.appendChild(count);
const readout = Boolean(this._rowControlsFor(layer.id)?.readout);
if (!readout) right.appendChild(count);
right.appendChild(toggle);
topRow.appendChild(left);
topRow.appendChild(right);
@@ -230,46 +258,94 @@ export class LayerPanel {
// that can also fail) pushes a re-render through this; nothing else
// would repaint the row before its next scheduled refresh.
const unsubscribe = this.subscribeRowControls(layer.id, () =>
this._refreshTogglePanel(),
this._scheduleRowControlsRefresh(),
);
if (unsubscribe) this._removers.push(unsubscribe);
const controls = document.createElement('div');
controls.className = 'data-toggle-controls';
this._bind(controls, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// Re-read the live descriptor rather than trusting the rendered
// chip, so a stale row can never apply an inverted toggle.
const chip = this._rowControlsFor(layer.id)?.chips?.find(
(entry) => entry.id === button.dataset.chipId,
);
if (!chip || chip.disabled || !this.isEnabled(layer.id)) return;
if (typeof chip.onClick === 'function') chip.onClick();
else if (chip.params)
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
// An ordered list below the chips, for a layer whose row carries a
// sequence (turn-by-turn directions). Its own delegated listener, its
// own container — the chip row stays a chip row.
const list = document.createElement('ol');
list.className = 'data-row-list';
list.hidden = true;
this._bind(list, 'click', (event) => {
const button = event.target?.closest?.('.data-row-list-item');
if (!button || button.disabled) return;
const item = this._rowControlsFor(layer.id)?.list?.items?.find(
(entry) => entry.id === button.dataset.listItemId,
);
if (item?.params)
this.setLayerParams(layer.id, item.params, { origin: 'user' });
});
row.appendChild(list);
this._syncRowControls(controls, layer, list);
if (!readout) {
const controls = document.createElement('div');
controls.className = 'data-toggle-controls';
this._bind(controls, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// Re-read the live descriptor rather than trusting the rendered
// chip, so a stale row can never apply an inverted toggle.
const chip = this._rowControlsFor(layer.id)?.chips?.find(
(entry) => entry.id === button.dataset.chipId,
);
if (!chip || chip.disabled || !this.isEnabled(layer.id)) return;
if (typeof chip.onClick === 'function') chip.onClick();
else if (chip.params)
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
// An ordered list below the chips, for a layer whose row carries a
// sequence (turn-by-turn directions). Its own delegated listener, its
// own container — the chip row stays a chip row.
const list = document.createElement('ol');
list.className = 'data-row-list';
list.hidden = true;
this._bind(list, 'click', (event) => {
const button = event.target?.closest?.('.data-row-list-item');
if (!button || button.disabled) return;
const item = this._rowControlsFor(layer.id)?.list?.items?.find(
(entry) => entry.id === button.dataset.listItemId,
);
if (item?.params)
this.setLayerParams(layer.id, item.params, { origin: 'user' });
});
row.appendChild(list);
this._syncRowControls(controls, layer, list);
}
}
this._toggleContainer.appendChild(row);
}
this._refreshWeatherPanel();
}
_scheduleRowControlsRefresh() {
if (this._destroyed || this._cancelRowControlsRefresh) return;
const refresh = () => {
this._cancelRowControlsRefresh = null;
if (!this._destroyed) this._refreshTogglePanel();
};
if (typeof requestAnimationFrame === 'function') {
const frame = requestAnimationFrame(refresh);
this._cancelRowControlsRefresh = () => cancelAnimationFrame(frame);
} else {
const timer = setTimeout(refresh, 0);
this._cancelRowControlsRefresh = () => clearTimeout(timer);
}
}
/** Synchronously flush a pending row-controls refresh, including in tests. */
_flushRowControlsRefresh() {
if (this._destroyed || !this._cancelRowControlsRefresh) return;
this._cancelRowControlsRefresh();
this._cancelRowControlsRefresh = null;
this._refreshTogglePanel();
}
_refreshWeatherPanel() {
this._weatherPanel?.update(
this.getAll()
.filter(
(layer) =>
layer.enabled &&
[
'wind',
'weather-radar',
'weather-satellite',
'weather-lightning',
'weather-cyclones',
].includes(layer.id),
)
.map((layer) => ({
id: layer.id,
icon: layer.icon,
...this._rowControlsFor(layer.id),
})),
);
}
/** Qualify a loaded count when it does not mean items currently on screen. */
@@ -287,8 +363,8 @@ export class LayerPanel {
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than
* rebuilt: this runs on every panel refresh — including the one the chip's
* own click triggers — and replacing the node would drop keyboard focus
* mid-interaction. Legend entries hold no focus and no listeners, so they
* are replaced freely.
* mid-interaction. Legend entries are rebuilt only when their content changes;
* the info node is retained across refreshes.
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
* @param {object} layer Registered layer entry.
* @param {HTMLElement|null} [listContainer] The row's `.data-row-list` node.
@@ -296,53 +372,65 @@ export class LayerPanel {
_syncRowControls(container, layer, listContainer = null) {
if (!container) return;
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
if (controls?.readout) {
container.remove();
listContainer?.remove();
return;
}
const chips = controls?.chips || [];
const legend = controls?.legend || [];
const infoText = controls?.info;
this._syncRowList(listContainer, controls?.list || null);
container.hidden = chips.length === 0 && legend.length === 0;
container.hidden = chips.length === 0 && legend.length === 0 && !infoText;
for (const node of [...container.children]) {
if (
String(node.className).split(/\s+/).includes('data-toggle-legend-item')
)
node.remove();
}
const info = container._rowControlsInfo || null;
const firstLegend = container.querySelector('.data-toggle-legend-item');
const stale = new Map();
for (const node of [...container.children]) {
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
}
syncChipGroup(container, chips, { before: firstLegend || info });
for (const chip of chips) {
let button = stale.get(chip.id);
stale.delete(chip.id);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.dataset.chipId = chip.id;
container.appendChild(button);
const legendSignature = JSON.stringify(
legend.map(({ label, color, count, blurb }) => [
label,
color,
count,
blurb,
]),
);
if (container._legendSignature !== legendSignature) {
for (const node of [...container.children]) {
if (node.className === 'data-toggle-legend-item') node.remove();
}
const state = chip.state || (chip.active ? 'active' : 'idle');
button.className = `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`;
if (button.textContent !== chip.label) button.textContent = chip.label;
button.title = chip.title || '';
button.disabled = Boolean(chip.disabled);
button.setAttribute('aria-pressed', chip.active ? 'true' : 'false');
button.setAttribute('aria-busy', chip.busy ? 'true' : 'false');
for (const item of legend) {
const entry = document.createElement('span');
entry.className = 'data-toggle-legend-item';
if (item.blurb) entry.title = item.blurb;
const swatch = document.createElement('span');
swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color;
const text = document.createElement('span');
text.textContent =
item.count == null
? String(item.label)
: `${item.label} ${this._formatCount(item.count)}`;
entry.append(swatch, text);
container.insertBefore(entry, info);
}
container._legendSignature = legendSignature;
}
for (const node of stale.values()) node.remove();
for (const item of legend) {
const entry = document.createElement('span');
entry.className = 'data-toggle-legend-item';
if (item.blurb) entry.title = item.blurb;
const swatch = document.createElement('span');
swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color;
const text = document.createElement('span');
text.textContent = `${item.label} ${this._formatCount(item.count)}`;
entry.append(swatch, text);
container.appendChild(entry);
if (infoText || info) {
const node = info || document.createElement('div');
if (!info) {
container.appendChild(node);
container._rowControlsInfo = node;
}
const className = 'data-toggle-controls-info';
if (node.className !== className) node.className = className;
const text = infoText ? String(infoText) : '';
const title = controls?.infoTitle || '';
if (node.textContent !== text) node.textContent = text;
if (node.title !== title) node.title = title;
const hidden = !text;
if (node.hidden !== hidden) node.hidden = hidden;
}
}
@@ -359,69 +447,7 @@ export class LayerPanel {
* @param {{ariaLabel?: string, items?: Array<object>}|null} list Descriptor.
*/
_syncRowList(container, list) {
if (!container) return;
const items = list?.items || [];
container.hidden = items.length === 0;
if (list?.ariaLabel) container.setAttribute('aria-label', list.ariaLabel);
const stale = new Map();
for (const node of [...container.children]) {
if (node.dataset?.listItemId) stale.set(node.dataset.listItemId, node);
}
let previous = null;
let activeButton = null;
for (const item of items) {
let entry = stale.get(item.id);
stale.delete(item.id);
let button;
if (!entry) {
entry = document.createElement('li');
entry.dataset.listItemId = item.id;
button = document.createElement('button');
button.type = 'button';
button.className = 'data-row-list-item';
button.dataset.listItemId = item.id;
const lead = document.createElement('span');
lead.className = 'data-row-list-lead';
const text = document.createElement('span');
text.className = 'data-row-list-text';
button.append(lead, text);
entry.appendChild(button);
} else {
button = entry.querySelector('.data-row-list-item');
}
// Keep DOM order in step with descriptor order without rebuilding.
const anchor = previous ? previous.nextSibling : container.firstChild;
if (entry !== anchor) container.insertBefore(entry, anchor);
previous = entry;
if (!button) continue;
const lead = button.querySelector('.data-row-list-lead');
const text = button.querySelector('.data-row-list-text');
const leadText = String(item.lead ?? '');
const bodyText = String(item.text ?? '');
if (lead && lead.textContent !== leadText) lead.textContent = leadText;
if (text && text.textContent !== bodyText) text.textContent = bodyText;
button.disabled = Boolean(item.disabled);
button.classList.toggle('note', Boolean(item.disabled));
button.classList.toggle('active', Boolean(item.active));
button.classList.toggle('current', Boolean(item.current));
button.setAttribute('aria-current', item.current ? 'step' : 'false');
button.setAttribute('aria-pressed', item.active ? 'true' : 'false');
button.title = bodyText;
if (item.current) activeButton = button;
}
for (const node of stale.values()) node.remove();
// Follow the flight, but never steal a scroll the reader is making
// themselves: only when the step actually changed.
if (
activeButton &&
container.dataset.currentId !== activeButton.dataset.listItemId
) {
container.dataset.currentId = activeButton.dataset.listItemId;
activeButton.scrollIntoView?.({ block: 'nearest' });
} else if (!activeButton) {
delete container.dataset.currentId;
}
syncRowList(container, list);
}
_refreshTogglePanel() {
@@ -459,6 +485,7 @@ export class LayerPanel {
row.querySelector('.data-row-list'),
);
}
this._refreshWeatherPanel();
}
_buildMetaText(layer) {
+85
View File
@@ -89,3 +89,88 @@ test('partial feed controls distinguish incomplete records from stale data and o
panel._syncToggleButton(button, layer);
assert.equal(button.textContent, 'OFF');
});
test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const { railFixture } = await import('./railTestFixture.mjs');
const f = railFixture();
const previousDocument = globalThis.document;
globalThis.document = f.document;
const ids = [
'wind',
'weather-radar',
'weather-satellite',
'weather-lightning',
'weather-cyclones',
'other',
];
let refresh;
const layers = ids.map((id) => ({
id,
name: id,
icon: '*',
source: 'Source',
enabled: true,
showInTogglePanel: true,
stats: {},
}));
const calls = [];
const panel = new LayerPanel({
getLayers: () => layers,
isEnabled: () => true,
setEnabled: (...args) => calls.push(args),
setLayerParams: (...args) => calls.push(args),
hasRowControls: () => true,
subscribeRowControls: (_id, callback) => {
refresh = callback;
},
getRowControls: (id) => ({
readout: id !== 'other',
summary: { status: 'Loading' },
info: 'Detailed time',
chips: [{ id: 'config', label: 'Soft', params: { opacity: 'light' } }],
legend: [{ label: 'Rain', color: '#abcdef' }],
list: {
items: [{ id: 'storm', text: 'Storm', params: { focus: true } }],
},
}),
});
try {
panel.mount(f.container);
for (const id of ids.slice(0, -1)) {
const row = f.find((n) => n.dataset.layerId === id);
assert.equal(row.children.length, 2);
assert.ok(row.querySelector('.data-name'));
assert.ok(row.querySelector('.data-toggle-meta'));
for (const cls of [
'data-toggle-chip',
'data-count',
'data-toggle-controls',
'data-toggle-controls-status',
'data-row-list',
])
assert.equal(row.querySelector(`.${cls}`), null);
row.querySelector('.data-toggle-btn').click();
}
assert.deepEqual(
calls.map(([id]) => id),
ids.slice(0, -1),
);
const ordinary = f.find((n) => n.dataset.layerId === 'other');
assert.equal(
ordinary.querySelector('.data-toggle-controls-info').textContent,
'Detailed time',
);
assert.ok(ordinary.querySelector('.data-toggle-legend-item'));
ordinary.querySelector('.data-toggle-chip').click();
assert.deepEqual(calls.at(-1), [
'other',
{ opacity: 'light' },
{ origin: 'user' },
]);
assert.equal(typeof refresh, 'function');
} finally {
panel.destroy();
globalThis.document = previousDocument;
}
});
+3
View File
@@ -11,6 +11,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
{ id: 'location-bar', pinnable: true },
{ id: 'data-panel' },
{ id: 'cctv-panel' },
{ id: 'weather-panel' },
{ id: 'radio-panel' },
{ id: 'scene-panel' },
{ id: 'global-context-panel' },
@@ -21,6 +22,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
'data-panel',
'cctv-panel',
'weather-panel',
'scene-panel',
'pp-toggles',
'global-context-panel',
@@ -281,6 +283,7 @@ export class PanelChrome {
const isRightRail = [
'pp-toggles',
'cctv-panel',
'weather-panel',
'global-context-panel',
].includes(panelEl?.id);
const collapsed = panelEl.classList.contains('collapsed');
+9 -9
View File
@@ -94,6 +94,7 @@ export class PanelLayoutController {
this._rightPanelStack = document.getElementById('right-context-rail');
this._ppToggles = document.getElementById('pp-toggles');
this._cctvPanel = document.getElementById('cctv-panel');
this._weatherPanel = document.getElementById('weather-panel');
this._sliderPanel = document.getElementById('param-slider-panel');
this._detectionBtn = document.getElementById('detection-toggle');
}
@@ -194,15 +195,13 @@ export class PanelLayoutController {
this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title');
stack.prepend(this._ppToggles);
const globalContextPanel = document.getElementById('global-context-panel');
if (this._cctvPanel) {
this._cctvPanel.style.removeProperty('top');
this._cctvPanel.style.removeProperty('right');
this._cctvPanel.style.removeProperty('bottom');
this._cctvPanel.style.removeProperty('left');
this._cctvPanel.style.removeProperty('z-index');
this._cctvPanel.classList.remove('panel-draggable', 'panel-dragging');
stack.insertBefore(this._cctvPanel, globalContextPanel);
this._syncPanelCollapseButton(this._cctvPanel);
for (const panel of [this._cctvPanel, this._weatherPanel]) {
if (!panel) continue;
for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
panel.style.removeProperty(property);
panel.classList.remove('panel-draggable', 'panel-dragging');
stack.insertBefore(panel, globalContextPanel);
this._syncPanelCollapseButton(panel);
}
if (this._sliderPanel) {
this._sliderPanel.style.removeProperty('top');
@@ -222,6 +221,7 @@ export class PanelLayoutController {
for (const panel of [
this._ppToggles,
this._cctvPanel,
this._weatherPanel,
globalContextPanel,
]) {
if (panel) this._rightStackResizeObserver.observe(panel);
+8
View File
@@ -122,6 +122,14 @@ export class PanelPositionControls {
// choice still wins in both directions, so anyone who opens it keeps it.
if (panelId === 'pp-toggles' && stored === null) collapsed = true;
panelEl.classList.toggle('collapsed', collapsed);
// Bodies that expand themselves on first appearance must not override a
// choice the user (or a share link) already made.
if (panelEl.dataset)
panelEl.dataset.collapsedPreference = !allowStored
? 'share'
: stored === null
? 'default'
: 'stored';
this._syncPanelCollapseButton(panelEl);
}
+217
View File
@@ -212,6 +212,34 @@ test('right layout uses keyboard focus when there is no preferred panel', () =>
assert.equal(f.second.classList.contains('collapsed'), false);
});
for (const collapsed of [true, false]) {
test(`right layout ignores a hidden ${collapsed ? 'collapsed' : 'expanded'} panel`, () => {
const baseline = fixture('right');
baseline.stack.children = [baseline.first];
baseline.expand(baseline.first, 600);
baseline.run();
const f = fixture('right');
f.expand(f.first, 600);
f.second.id = 'weather-panel';
f.second.hidden = true;
if (!collapsed) f.expand(f.second, 900);
f.options.preferredPanelId = 'weather-panel';
f.run();
assert.deepEqual(f.stack.dataset, baseline.stack.dataset);
assert.equal(
f.first.style.getPropertyValue('--right-panel-allocated-height'),
baseline.first.style.getPropertyValue('--right-panel-allocated-height'),
);
assert.equal(f.second.classList.contains('collapsed'), collapsed);
assert.deepEqual(f.second.writes, []);
assert.deepEqual(f.collapsed, []);
assert.equal(f.retries(), 0);
assert.equal(f.stack.classList.contains('layout-focus'), false);
});
}
test('right layout retains Display allocation during measurement and caps restored scroll', () => {
const f = fixture('right');
f.first.id = 'pp-toggles';
@@ -271,6 +299,195 @@ test('natural height includes visible content, margins and wrapper chrome, exclu
);
});
// Reproduce the allocation-dependent readings seen beside the weather card.
// Outside measurement, CCTV is 391 px in focus and 629 px in normal mode;
// Removing only the allocation leaves focus CSS active: its next reading is
// 422 px, so the old decision exits focus and then re-enters on the 629 px read.
function thrashingRightRail({ focused = false, weatherOpen = false } = {}) {
const f = fixture('right');
f.options.windowRef.innerHeight = 920;
f.options.leftStack.rect.top = 439.6;
f.stack.computed.rowGap = '8px';
const display = element('pp-toggles', { collapsed: true });
const cctv = element('cctv-panel', { height: 629 });
const weather = element('weather-panel', {
height: 444,
collapsed: !weatherOpen,
});
const context = element('global-context-panel', { collapsed: true });
if (!weatherOpen) weather.classList.add('layout-auto-collapsed');
f.stack.children = [display, cctv, weather, context];
f.stack.classList.toggle('layout-focus', focused);
f.options.preferredPanelId = cctv.id;
f.options.displayPanel = display;
const allocation = '--right-panel-allocated-height';
const measuring = () =>
f.stack.getAttribute('data-rail-measuring') !== undefined;
for (const panel of f.stack.children) {
panel.parentElement = f.stack;
panel.intrinsicHeight = panel.rect.height;
panel.style.setProperty(allocation, '391px');
panel.getBoundingClientRect = () => {
let height;
if (panel.classList.contains('collapsed')) {
height =
measuring() || f.stack.classList.contains('layout-focus') ? 44 : 0;
} else if (measuring()) {
height = panel.intrinsicHeight;
} else if (f.stack.classList.contains('layout-focus')) {
height = 391;
} else {
height = panel.intrinsicHeight;
}
return { ...panel.rect, height, bottom: panel.rect.top + height };
};
Object.defineProperty(panel, 'scrollHeight', {
get: () => {
if (measuring())
return panel.classList.contains('collapsed')
? 44
: panel.intrinsicHeight;
if (panel.classList.contains('collapsed'))
return panel.getBoundingClientRect().height;
return f.stack.classList.contains('layout-focus') &&
panel.style.getPropertyValue(allocation)
? 603
: 422;
},
});
}
return { ...f, cctv, weather };
}
for (const focused of [false, true]) {
test(`right rail settles mode-dependent CCTV and weather heights within two passes from ${focused ? 'focus' : 'normal'}`, () => {
const f = thrashingRightRail({ focused });
assert.equal(f.cctv.getBoundingClientRect().height, focused ? 391 : 629);
assert.equal(f.weather.getBoundingClientRect().height, focused ? 44 : 0);
const modes = [];
const needs = [];
for (let pass = 0; pass < 10; pass++) {
f.run();
modes.push(f.stack.classList.contains('layout-focus'));
needs.push(f.stack.dataset.requiredHeight);
assert.equal(f.stack.dataset.availableHeight, '443.6');
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined);
}
assert.deepEqual(modes.slice(1), Array(9).fill(true));
assert.deepEqual(needs, Array(10).fill('681.0'));
assert.equal(f.retries(), 0);
assert.equal(f.cctv.writes.filter(([op]) => op === 'remove').length, 0);
});
}
test('right rail keeps focus through 30 px content growth inside the hysteresis band', () => {
const f = thrashingRightRail();
f.run();
// Header + gap contribute 52 px: need moves 420 -> 450 -> 420.
for (const height of [368, 398, 368, 398, 368]) {
f.cctv.intrinsicHeight = height;
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), true);
}
f.cctv.intrinsicHeight = 335; // Need 387 < 443.6 - 55.2.
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
f.cctv.intrinsicHeight = 365;
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
});
test('right rail enters focus only above available height and leaves below the full deadband', () => {
const f = thrashingRightRail();
f.options.windowRef.innerHeight = 1000;
f.options.leftStack.rect.top = 500;
f.cctv.intrinsicHeight = 408; // Need equals available (460 px).
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
f.cctv.intrinsicHeight = 409;
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), true);
f.cctv.intrinsicHeight = 348; // Need equals the 400 px exit boundary.
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), true);
f.cctv.intrinsicHeight = 347;
f.run();
assert.equal(f.stack.classList.contains('layout-focus'), false);
});
test('right rail auto-collapse requests only one retry and settles within two passes', () => {
const f = thrashingRightRail({ weatherOpen: true });
const modes = [];
for (let pass = 0; pass < 10; pass++) {
f.run();
modes.push(f.stack.classList.contains('layout-focus'));
}
assert.deepEqual(f.collapsed, ['weather-panel']);
assert.equal(f.retries(), 1);
assert.deepEqual(modes.slice(1), Array(9).fill(true));
});
test('right rail retry cannot enqueue another retry even if disclosure changes before it runs', () => {
const f = thrashingRightRail({ weatherOpen: true });
f.run();
assert.equal(f.retries(), 1);
f.weather.classList.remove('collapsed', 'layout-auto-collapsed');
f.run();
assert.equal(f.retries(), 1);
assert.equal(f.stack.dataset.layoutMode, 'focus');
});
test('right rail restores presentation even when intrinsic measurement throws', () => {
const f = thrashingRightRail();
f.cctv.getBoundingClientRect = () => {
throw new Error('measurement failed');
};
assert.throws(() => f.run(), /measurement failed/);
assert.equal(f.stack.getAttribute('data-rail-measuring'), undefined);
});
test('right rail measuring pass keeps an opted-in scroller position', () => {
const f = fixture('right');
f.expand(f.first, 300);
let scrollTop = 240;
let writes = 0;
let clamp = true;
const body = {
get scrollTop() {
return scrollTop;
},
set scrollTop(value) {
writes++;
scrollTop = value;
},
};
f.stack.querySelectorAll = (selector) =>
selector === '[data-rail-scroller]' ? [body] : [];
const setAttribute = f.stack.setAttribute;
f.stack.setAttribute = (name, value) => {
setAttribute(name, value);
// The lifted max-height removes the overflow and clamps the offset.
if (clamp && name === 'data-rail-measuring') scrollTop = 0;
};
f.run();
assert.equal(scrollTop, 240);
assert.equal(writes, 1);
clamp = false;
f.run();
assert.equal(writes, 1, 'an unclamped offset is not rewritten');
});
for (const variant of ['minimal', 'full']) {
test(`right rail does not retry collapse that the ${variant} HUD immediately restores`, () => {
const f = fixture('right', { hud: { visible: true, variant } });
f.expand(f.first, 900);
f.expand(f.second, 900);
for (let pass = 0; pass < 10; pass++) f.run();
assert.equal(f.retries(), 0);
assert.equal(f.first.classList.contains('collapsed'), false);
assert.equal(f.second.classList.contains('collapsed'), false);
});
}
// ── Narrow-screen rail overflow pin ──────────────────────────────────────────
// At ≤720px both panel stacks become scroll containers (overflow-y: auto),
// which also makes their overflow-x compute to auto. Each panel's decorative
+295
View File
@@ -0,0 +1,295 @@
import { syncChipGroup } from './chipGroup.js';
import { syncRowList } from './rowList.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
const attribute = (node, key, value) => {
if (node.getAttribute(key) !== value) node.setAttribute(key, value);
};
const order = (parent, nodes) =>
nodes.forEach((node, i) => {
if (parent.children[i] !== node)
parent.insertBefore(node, parent.children[i] || null);
});
/** Optional, ordered blocks without disclosure state. All controls are keyed. */
export function createRailCardBlocks({ container, cardId, onParams }) {
const document = container.ownerDocument;
const blocks = new Map();
const make = (tag, className, parent) => {
const node = document.createElement(tag);
node.className = className;
parent.appendChild(node);
return node;
};
const dispatch = (props) => {
if (!props || props.disabled) return;
if (props.params) onParams(cardId, props.params);
else props.onClick?.();
};
const dispose = (block) => {
block.removeClick?.();
block.children?.destroy();
block.root.remove();
};
return {
update(descriptors = []) {
const ids = new Set();
for (const props of descriptors) {
ids.add(props.id);
let block = blocks.get(props.id);
if (block && block.type !== props.type) {
dispose(block);
blocks.delete(props.id);
block = null;
}
if (!block) {
const root = make(
props.type === 'list'
? 'ol'
: props.type === 'actions'
? 'footer'
: 'div',
props.type === 'list' ? 'data-row-list' : `rail-card-${props.type}`,
container,
);
root.dataset.blockId = props.id;
block = { root, type: props.type, props };
const bind = (fn) => {
root.addEventListener('click', fn);
block.removeClick = () => root.removeEventListener('click', fn);
};
if (props.type === 'settings') {
block.children = createRailCardBlocks({
container: root,
cardId,
onParams,
});
} else if (props.type === 'setting') {
block.label = make(
'span',
'rail-card-setting-label panel-title',
root,
);
block.chips = make('div', 'rail-card-chips', root);
block.chips.setAttribute('role', 'group');
bind((event) => {
const button = event.target?.closest?.('[data-chip-id]');
dispatch(
block.props.chips?.find(
({ id }) => id === button?.dataset.chipId,
),
);
});
} else if (props.type === 'list') {
bind((event) => {
const button = event.target?.closest?.('.data-row-list-item');
dispatch(
block.props.list?.items?.find(
({ id }) => id === button?.dataset.listItemId,
),
);
});
} else if (props.type === 'result') {
block.header = make('div', 'rail-card-result-header', root);
block.label = make('span', 'panel-title', block.header);
block.clear = make('button', 'data-toggle-chip', block.header);
block.clear.type = 'button';
block.clear.dataset.actionId = 'clear';
block.clear.textContent = '×';
block.clear.setAttribute('aria-label', 'Clear reading');
block.clear.title = 'Clear reading';
bind((event) => {
if (event.target === block.clear) dispatch(block.props.clear);
});
block.children = createRailCardBlocks({
container: make('div', 'rail-card-result-lines', root),
cardId,
onParams,
});
} else if (props.type === 'legend') {
block.ramp = make('div', 'rail-card-ramp', root);
block.zero = make('span', 'rail-card-zero', block.ramp);
block.zero.setAttribute('aria-hidden', 'true');
block.scale = make('div', 'rail-card-scale', root);
block.entries = make('div', 'rail-card-legend-entries', root);
} else if (props.type === 'actions') {
block.actions = new Map();
bind((event) => {
for (const action of block.actions.values())
if (event.target === action.node) dispatch(action.props);
});
} else block.lines = new Map();
blocks.set(props.id, block);
}
block.props = props;
if (props.type === 'settings')
block.children.update(
(props.settings || []).map((setting) => ({
...setting,
type: 'setting',
})),
);
else if (props.type === 'setting') {
set(block.label, 'textContent', props.label);
attribute(block.chips, 'aria-label', props.label);
syncChipGroup(block.chips, props.chips);
} else if (props.type === 'list') syncRowList(block.root, props.list);
else if (props.type === 'result') {
set(block.label, 'textContent', props.label);
set(block.clear, 'hidden', !props.clear);
block.children.update([
{ id: 'result-lines', type: 'lines', lines: props.lines },
]);
} else if (props.type === 'legend') {
const legend = props.legend;
const colors = (legend?.colors || []).filter((color) =>
/^#(?:[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color),
);
const visible =
colors.length >= 2 && colors.length === legend.colors.length;
set(block.root, 'hidden', !visible);
set(block.ramp, 'hidden', !visible || Boolean(legend?.categorical));
set(block.scale, 'hidden', Boolean(legend?.categorical));
set(block.entries, 'hidden', !legend?.categorical);
if (legend?.categorical) {
while (block.entries.children.length > colors.length)
block.entries.children[
block.entries.children.length - 1
].remove();
colors.forEach((color, index) => {
let entry = block.entries.children[index];
if (!entry) {
entry = make('div', 'rail-card-legend-entry', block.entries);
make('span', 'rail-card-legend-swatch', entry);
make('span', '', entry);
}
if (entry.dataset.color !== color) {
entry.children[0].style.background = color;
entry.dataset.color = color;
}
set(entry.children[1], 'textContent', legend.labels[index] || '');
});
}
const gradient = visible
? `linear-gradient(to right, ${colors.join(',')})`
: '';
if (block.gradient !== gradient) {
block.ramp.style.background = gradient;
block.gradient = gradient;
}
const zeroVisible =
visible &&
legend.units === '°C' &&
Number.isInteger(legend.zeroIndex) &&
legend.zeroIndex >= 0 &&
legend.zeroIndex < colors.length;
set(block.zero, 'hidden', !zeroVisible);
if (zeroVisible) {
const left = `${(legend.zeroIndex / (colors.length - 1)) * 100}%`;
if (block.zeroLeft !== left) {
block.zero.style.left = left;
block.zeroLeft = left;
}
}
set(
block.scale,
'textContent',
visible
? `${legend.labels[0]} — ${legend.labels.at(-1)} ${legend.units || ''}`.trim()
: '',
);
} else if (props.type === 'actions') {
const actions = props.actions || [];
for (const [id, action] of block.actions)
if (
!actions.some(
(props) =>
props.id === id &&
(props.href ? 'a' : 'button') === action.tag,
)
) {
action.node.remove();
action.hint.remove();
block.actions.delete(id);
}
for (const actionProps of actions) {
let action = block.actions.get(actionProps.id);
if (!action) {
const tag = actionProps.href ? 'a' : 'button';
const node = make(tag, 'data-toggle-chip', block.root);
node.dataset.actionId = actionProps.id;
if (tag === 'button') node.type = 'button';
else {
node.target = '_blank';
node.rel = 'noopener';
}
action = {
node,
tag,
hint: make('span', 'rail-card-action-hint', block.root),
};
block.actions.set(actionProps.id, action);
}
action.props = actionProps;
set(action.node, 'textContent', actionProps.label);
set(action.node, 'title', actionProps.title || '');
set(action.node, 'disabled', Boolean(actionProps.disabled));
if (action.tag === 'a' && action.href !== actionProps.href) {
action.node.href = actionProps.href;
action.href = actionProps.href;
}
set(action.hint, 'textContent', actionProps.hint || '');
set(action.hint, 'hidden', !actionProps.hint);
}
order(
block.root,
actions.flatMap(({ id }) => {
const action = block.actions.get(id);
return [action.node, action.hint];
}),
);
} else {
const lines = props.lines || [{ id: props.id, text: props.text }];
for (const [id, node] of block.lines)
if (!lines.some((line) => line.id === id)) {
node.remove();
block.lines.delete(id);
}
for (const line of lines) {
let node = block.lines.get(line.id);
if (!node) {
node = make('div', 'rail-card-line', block.root);
node.dataset.lineId = line.id;
block.lines.set(line.id, node);
}
set(node, 'textContent', String(line.text ?? ''));
set(
node,
'className',
`rail-card-line${line.muted ? ' muted' : ''}`,
);
}
order(
block.root,
lines.map(({ id }) => block.lines.get(id)),
);
}
}
for (const [id, block] of blocks)
if (!ids.has(id)) {
dispose(block);
blocks.delete(id);
}
order(
container,
descriptors.map(({ id }) => blocks.get(id).root),
);
},
destroy() {
for (const block of blocks.values()) dispose(block);
blocks.clear();
},
};
}
+128
View File
@@ -0,0 +1,128 @@
import { createRailCardBlocks } from './railCardBlocks.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
let nextId = 0;
/** Keyed cards with optional ordered blocks; the feature owns open-card choice. */
export function createRailCards({
container,
document = container?.ownerDocument,
cardClassName = '',
badgeClassName = '',
onParams = () => {},
onOpen = () => {},
} = {}) {
if (!document?.createElement || !container) return null;
const rows = new Map();
let destroyed = false;
const make = (tag, className, parent) => {
const node = document.createElement(tag);
node.className = className;
parent.appendChild(node);
return node;
};
const dispose = (row) => {
row.header.removeEventListener('click', row.click);
row.blocks.destroy();
row.element.remove();
};
return {
update(cards = []) {
if (destroyed) return;
const active = new Set();
for (const card of cards) {
active.add(card.id);
let row = rows.get(card.id);
if (!row) {
const element = make(
'article',
`rail-card${cardClassName ? ` ${cardClassName}` : ''}`,
container,
);
element.dataset.cardId = card.id;
const header = make('button', 'rail-card-header', element);
header.type = 'button';
const labels = make('span', 'rail-card-labels', header);
const heading = make('span', 'rail-card-heading', labels);
const icon = make('span', 'data-icon', heading);
icon.setAttribute('aria-hidden', 'true');
const title = make(
'span',
'rail-card-title rail-card-nowrap',
heading,
);
const badge = make(
'span',
`rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
labels,
);
const disclosure = make('span', 'rail-card-disclosure', header);
disclosure.setAttribute('aria-hidden', 'true');
const compact = make('span', 'rail-card-compact', header);
const body = make('div', 'rail-card-body', element);
body.id = `rail-card-body-${++nextId}`;
header.setAttribute('aria-controls', body.id);
const blocks = createRailCardBlocks({
container: body,
cardId: card.id,
onParams,
});
const click = () => onOpen(card.id);
header.addEventListener('click', click);
row = {
element,
header,
icon,
title,
disclosure,
badge,
compact,
body,
blocks,
click,
};
rows.set(card.id, row);
}
set(row.title, 'textContent', card.title);
if (row.title.getAttribute('title') !== card.title)
row.title.setAttribute('title', card.title);
set(row.icon, 'textContent', card.icon || '');
set(row.icon, 'hidden', !card.icon);
set(row.badge, 'textContent', card.badge || '');
set(row.badge, 'hidden', !card.badge);
const open = card.open !== false;
set(row.element.dataset, 'open', String(open));
row.element.classList.toggle('is-open', open);
set(row.disclosure, 'textContent', open ? '▾' : '▸');
if (row.header.getAttribute('aria-expanded') !== String(open))
row.header.setAttribute('aria-expanded', String(open));
set(row.body, 'hidden', !open);
set(row.compact, 'hidden', open);
set(row.compact, 'textContent', card.compact || '');
set(
row.compact,
'className',
`rail-card-compact${card.compactStatus ? ' status' : ''}`,
);
row.blocks.update(card.blocks || []);
}
for (const [id, row] of rows)
if (!active.has(id)) {
dispose(row);
rows.delete(id);
}
cards.forEach(({ id }, index) => {
const node = rows.get(id).element;
if (container.children[index] !== node)
container.insertBefore(node, container.children[index] || null);
});
},
destroy() {
destroyed = true;
for (const row of rows.values()) dispose(row);
rows.clear();
},
};
}
+259
View File
@@ -0,0 +1,259 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRailCards } from './railCards.js';
import { railFixture } from './railTestFixture.mjs';
const legend = {
colors: ['#112233', '#ffffff', '#abcdef'],
labels: ['-10', '0', '10'],
units: '°C',
zeroIndex: 1,
};
const card = {
id: 'a',
title: 'Temperature',
badge: 'Global',
open: true,
blocks: [
{ id: 'details', type: 'lines', lines: [{ id: 'status', text: '' }] },
{ id: 'legend', type: 'legend', legend },
{
id: 'actions',
type: 'actions',
actions: [{ id: 'view', label: 'View coverage' }],
},
],
};
test('keyed cards and optional blocks reorder without redundant writes', () => {
let writes = 0;
const f = railFixture(() => writes++);
const view = createRailCards(f);
view.update([card]);
const article = f.container.children[0];
assert.equal(article.tagName, 'ARTICLE');
const header = article.children[0];
assert.equal(header.tagName, 'BUTTON');
assert.equal(header.getAttribute('aria-expanded'), 'true');
assert.ok(header.getAttribute('aria-controls'));
for (const cards of [
[card],
[
{
...card,
blocks: [
{ id: 'summary', type: 'summary', text: '<img onerror=alert(1)>' },
],
},
],
[card, { ...card, id: 'b', open: false }],
[{ ...card, id: 'b' }, card],
[],
]) {
view.update(cards);
writes = 0;
view.update(cards);
assert.equal(writes, 0);
}
view.update([{ ...card, blocks: [...card.blocks].reverse() }]);
assert.deepEqual(
f.container.children[0].children[1].children.map((n) => n.dataset.blockId),
['actions', 'legend', 'details'],
);
view.destroy();
assert.equal(f.container.children.length, 0);
assert.equal(createRailCards(), null);
});
test('footer buttons retain focus and current callbacks; links use safe new tabs', () => {
const f = railFixture();
const view = createRailCards(f);
const calls = [];
const model = (actions) => ({
...card,
blocks: [{ id: 'actions', type: 'actions', actions }],
});
view.update([
model([{ id: 'view', label: 'First', onClick: () => calls.push(1) }]),
]);
const button = f.find((n) => n.dataset.actionId === 'view');
button.focus();
view.update([
model([
{
id: 'view',
label: 'Read wind here',
hint: 'at map center',
onClick: () => calls.push(2),
},
{
id: 'link',
label: 'Official advisory ↗',
href: 'https://example.org/advisory',
},
]),
]);
assert.equal(f.document.activeElement, button);
assert.equal(button.parent.tagName, 'FOOTER');
assert.equal(
f.find((n) => n.className === 'rail-card-action-hint').textContent,
'at map center',
);
button.click();
assert.deepEqual(calls, [2]);
const link = f.find((n) => n.dataset.actionId === 'link');
assert.equal(link.tagName, 'A');
assert.equal(link.target, '_blank');
assert.equal(link.rel, 'noopener');
view.update([
model([
{
id: 'view',
label: 'Read wind here',
disabled: true,
onClick: () => calls.push(3),
},
]),
]);
button.click();
assert.deepEqual(calls, [2]);
view.destroy();
button.click();
assert.deepEqual(calls, [2]);
});
test('legend validates colors, preserves the freezing anchor and ignores CSSOM normalization', () => {
let writes = 0;
const f = railFixture(() => writes++);
const view = createRailCards(f);
const show = (legend) =>
view.update([
{ ...card, blocks: [{ id: 'legend', type: 'legend', legend }] },
]);
show(legend);
const zero = f.find((n) => n.className === 'rail-card-zero');
const ramp = f.find((n) => n.className === 'rail-card-ramp');
assert.equal(zero.hidden, false);
assert.equal(zero.style.left, '50%');
Object.defineProperty(ramp.style, 'background', {
get: () =>
'linear-gradient(to right, rgb(17, 34, 51), rgb(255, 255, 255), rgb(171, 205, 239))',
set() {
writes++;
},
configurable: true,
});
Object.defineProperty(zero.style, 'left', {
get: () => '50.0%',
set() {
writes++;
},
configurable: true,
});
writes = 0;
show(legend);
assert.equal(writes, 0);
delete ramp.style.background;
delete zero.style.left;
show({
...legend,
colors: Array(10).fill('#abcdef'),
labels: Array.from({ length: 10 }, (_, i) => String(i * 10 - 40)),
zeroIndex: 4,
});
assert.ok(Math.abs(parseFloat(zero.style.left) - 44.444444) < 0.001);
show({ ...legend, units: 'km/h' });
assert.equal(zero.hidden, true);
show({ ...legend, colors: ['url(https://example.org)', '#ffffff'] });
assert.equal(ramp.hidden, true);
assert.equal(ramp.style.background, '');
view.destroy();
});
test('labelled rows, lists and results retain focus and dispatch current params', () => {
let writes = 0;
const f = railFixture(() => writes++);
const calls = [];
const view = createRailCards({
...f,
onParams: (...args) => calls.push(args),
});
const model = (paused = false) => ({
...card,
blocks: [
{
id: 'storms',
type: 'list',
list: {
ariaLabel: 'Storms',
items: [
{
id: 'a',
text: 'Storm A',
active: !paused,
params: { stormId: 'a', focus: true },
},
{
id: 'b',
text: 'Storm B',
active: paused,
params: { stormId: 'b', focus: true },
},
],
},
},
{
id: 'settings',
type: 'settings',
settings: [
{
id: 'motion',
label: 'MOTION',
chips: [
{
id: 'motion',
label: paused ? 'Resume' : 'Pause',
params: { paused: !paused },
},
],
},
],
},
{
id: 'reading',
type: 'result',
label: 'WIND AT SAMPLED LOCATION · 41°N 87°W',
lines: [{ id: 'wind', text: '<b>18 km/h</b>' }],
clear: { params: { inspect: false } },
},
],
});
view.update([model()]);
const setting = f.find((n) => n.dataset.blockId === 'motion');
assert.equal(setting.children[0].textContent, 'MOTION');
assert.equal(setting.children[1].getAttribute('role'), 'group');
assert.equal(setting.children[1].getAttribute('aria-label'), 'MOTION');
const chip = f.find((n) => n.dataset.chipId === 'motion');
chip.focus();
view.update([model(true)]);
assert.equal(f.document.activeElement, chip);
chip.click();
assert.deepEqual(calls.at(-1), ['a', { paused: false }]);
const storm = f.find(
(n) => n.tagName === 'BUTTON' && n.dataset.listItemId === 'b',
);
assert.equal(storm.getAttribute('aria-pressed'), 'true');
storm.click();
assert.deepEqual(calls.at(-1), ['a', { stormId: 'b', focus: true }]);
const clear = f.find((n) => n.dataset.actionId === 'clear');
assert.equal(clear.textContent, '×');
assert.equal(clear.getAttribute('aria-label'), 'Clear reading');
clear.click();
assert.deepEqual(calls.at(-1), ['a', { inspect: false }]);
assert.equal(f.find((n) => n.dataset.lineId === 'wind').children.length, 0);
writes = 0;
view.update([model(true)]);
assert.equal(writes, 0);
view.destroy();
const before = calls.length;
chip.click();
clear.click();
storm.click();
assert.equal(calls.length, before);
});
+116
View File
@@ -0,0 +1,116 @@
/** Minimal instrumented DOM for keyed rail presentation tests. */
export function railFixture(onWrite = () => {}) {
const tracked = (target) =>
new Proxy(target, {
set(object, key, value) {
onWrite(key);
return Reflect.set(object, key, value);
},
});
const document = {
createElement(tag) {
const node = new EventTarget();
Object.assign(node, {
tagName: tag.toUpperCase(),
ownerDocument: document,
children: [],
dataset: tracked({}),
style: tracked({}),
hidden: false,
textContent: '',
attributes: new Map(),
setAttribute(key, value) {
onWrite('attribute');
this.attributes.set(key, String(value));
},
getAttribute(key) {
return this.attributes.get(key) ?? null;
},
appendChild(child) {
return this.insertBefore(child, null);
},
insertBefore(child, reference) {
if (child.parent) child.remove();
onWrite('insert');
const index = reference
? this.children.indexOf(reference)
: this.children.length;
this.children.splice(index, 0, child);
child.parent = this;
return child;
},
append(...nodes) {
for (const child of nodes) this.appendChild(child);
},
remove() {
if (!this.parent) return;
onWrite('remove');
this.parent.children.splice(this.parent.children.indexOf(this), 1);
this.parent = null;
},
focus() {
document.activeElement = this;
},
matches(selector) {
if (selector.startsWith('.'))
return this.className?.split(' ').includes(selector.slice(1));
if (selector === '[data-chip-id]')
return Boolean(this.dataset.chipId);
return false;
},
closest(selector) {
return this.matches(selector) ? this : this.parent?.closest(selector);
},
click() {
if (this.disabled) return;
const target = this;
for (let node = this; node; node = node.parent) {
const event = new Event('click');
Object.defineProperty(event, 'target', { value: target });
node.dispatchEvent(event);
}
},
querySelector(selector) {
return find(
this,
(child) =>
selector.startsWith('.')
? child.className?.split(' ').includes(selector.slice(1))
: false,
false,
);
},
});
node.classList = {
contains: (name) => node.className?.split(' ').includes(name) || false,
toggle(name, enabled) {
const classes = new Set(
(node.className || '').split(' ').filter(Boolean),
);
if (enabled) classes.add(name);
else classes.delete(name);
const next = [...classes].join(' ');
if (next !== node.className) {
node.className = next;
onWrite('className');
}
},
};
return tracked(node);
},
};
const container = document.createElement('div');
const find = (root, predicate, includeRoot = true) => {
if (includeRoot && predicate(root)) return root;
for (const child of root.children) {
const result = find(child, predicate);
if (result) return result;
}
return null;
};
return {
container,
document,
find: (predicate, root = container) => find(root, predicate),
};
}
+200
View File
@@ -0,0 +1,200 @@
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
const utc = (tick) =>
Number.isFinite(Date.parse(tick))
? `${new Date(tick).toISOString().slice(5, 16).replace('T', ' ')} UTC`
: '';
/** Stable native transport with local preview and a coalesced 150 ms drag commit. */
export function createRailTimeline({
container,
document = container?.ownerDocument,
sliderClassName = '',
heading = true,
onCommit = () => {},
onPreview = () => {},
onStep = () => {},
onLatest = () => {},
onPlay = () => {},
} = {}) {
if (!document?.createElement || !container) return null;
const root = document.createElement('section');
root.className = 'rail-timeline';
const row = document.createElement('div');
row.className = 'rail-timeline-track';
const makeButton = (label, title, parent) => {
const node = document.createElement('button');
node.type = 'button';
node.className = 'data-toggle-chip';
node.textContent = label;
node.title = title;
parent.appendChild(node);
return node;
};
const previous = makeButton('‹', 'Earlier observation', row);
const slider = document.createElement('input');
slider.type = 'range';
slider.className = `gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`;
slider.min = '0';
slider.max = '0';
slider.step = '1';
slider.value = '0';
slider.setAttribute('aria-label', 'Observed history');
row.appendChild(slider);
const next = makeButton('›', 'Later observation', row);
const controls = document.createElement('div');
controls.className = 'rail-timeline-controls';
const latest = makeButton('Latest', 'Newest frame per product', controls);
const play = makeButton('Play', 'Replay observed history', controls);
const readout = document.createElement('span');
readout.className = 'rail-timeline-readout';
controls.appendChild(readout);
const label = document.createElement('div');
label.className = 'panel-title';
label.textContent = 'Observed history';
const endpoints = document.createElement('div');
endpoints.className = 'rail-timeline-endpoints';
const oldest = document.createElement('span');
const newest = document.createElement('span');
endpoints.appendChild(oldest);
endpoints.appendChild(newest);
row.appendChild(endpoints);
if (heading) root.appendChild(label);
root.appendChild(row);
root.appendChild(controls);
container.appendChild(root);
let props = {
ticks: [],
index: 0,
mode: 'latest',
playing: false,
readout: '',
disabled: true,
};
let ticks = [];
let dragging = false;
let timer = null;
let pending = null;
let destroyed = false;
const cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
const valueText = (tick) => {
const value = utc(tick);
if (slider.getAttribute('aria-valuetext') !== value)
slider.setAttribute('aria-valuetext', value);
};
const commit = () => {
cancel();
const value = pending;
pending = null;
if (!destroyed && value && !props.disabled)
onCommit(value.tick, value.index);
};
const update = (nextProps) => {
if (destroyed) return;
props = nextProps;
if (props.disabled) {
cancel();
pending = null;
dragging = false;
}
if (!dragging) {
ticks = [...props.ticks];
set(slider, 'max', String(Math.max(0, ticks.length - 1)));
set(
slider,
'value',
String(Math.max(0, Math.min(props.index, ticks.length - 1))),
);
valueText(ticks[Number(slider.value)]);
set(oldest, 'textContent', utc(ticks[0]).slice(6));
set(newest, 'textContent', utc(ticks.at(-1)).slice(6));
set(readout, 'textContent', props.readout || '');
}
for (const node of [slider, latest, play])
set(node, 'disabled', Boolean(props.disabled));
set(previous, 'disabled', Boolean(props.disabled || props.index <= 0));
set(
next,
'disabled',
Boolean(props.disabled || props.index >= props.ticks.length - 1),
);
set(
latest,
'className',
`data-toggle-chip${props.mode === 'latest' ? ' active' : ''}`,
);
set(play, 'className', `data-toggle-chip${props.playing ? ' active' : ''}`);
set(play, 'textContent', props.playing ? 'Pause' : 'Play');
for (const [node, active] of [
[latest, props.mode === 'latest'],
[play, props.playing],
]) {
if (node.getAttribute('aria-pressed') !== String(active))
node.setAttribute('aria-pressed', String(active));
}
};
const preview = () => {
if (props.disabled) return;
dragging = true;
const index = Number(slider.value);
const tick = ticks[index];
if (!tick) return;
pending = { tick, index };
valueText(tick);
const value = onPreview(tick, index);
if (typeof value === 'string') set(readout, 'textContent', value);
if (timer === null) timer = setTimeout(commit, 150);
};
const change = () => {
if (props.disabled) return;
pending = {
tick: ticks[Number(slider.value)],
index: Number(slider.value),
};
dragging = false;
commit();
update(props);
};
const reset = () => {
cancel();
pending = null;
dragging = false;
update(props);
};
const act = (fn) => () => {
reset();
if (!props.disabled) fn();
};
const bindings = [
[slider, 'input', preview],
[slider, 'change', change],
[slider, 'pointercancel', reset],
[
slider,
'blur',
() => {
if (dragging) change();
},
],
[previous, 'click', act(() => onStep(-1))],
[next, 'click', act(() => onStep(1))],
[latest, 'click', act(onLatest)],
[play, 'click', act(onPlay)],
];
for (const [node, event, handler] of bindings)
node.addEventListener(event, handler);
return {
update,
destroy() {
destroyed = true;
cancel();
for (const [node, event, handler] of bindings)
node.removeEventListener(event, handler);
root.remove();
},
};
}
+118
View File
@@ -0,0 +1,118 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRailTimeline } from './railTimeline.js';
import { railFixture } from './railTestFixture.mjs';
const ticks = [
'2026-09-22T01:00:00Z',
'2026-09-22T01:05:00Z',
'2026-09-22T01:10:00Z',
];
const props = {
ticks,
index: 2,
mode: 'latest',
playing: false,
readout: 'Latest',
disabled: false,
};
test('preview is immediate, commits are coalesced and the drag DOM and tick mapping stay stable', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const f = railFixture();
const previews = [];
const commits = [];
const view = createRailTimeline({
...f,
onPreview: (tick) => {
previews.push(tick);
return tick;
},
onCommit: (tick) => commits.push(tick),
});
view.update(props);
const slider = f.find((n) => n.tagName === 'INPUT');
const row = slider.parent;
assert.equal(slider.getAttribute('aria-label'), 'Observed history');
slider.value = '0';
slider.dispatchEvent(new Event('input'));
assert.deepEqual(commits, []);
assert.deepEqual(previews, [ticks[0]]);
assert.equal(slider.getAttribute('aria-valuetext'), '09-22 01:00 UTC');
view.update({
...props,
ticks: ticks.slice(1),
index: 1,
readout: 'Refresh',
});
assert.equal(slider.parent, row);
assert.equal(row.children[1], slider);
assert.equal(slider.value, '0');
assert.equal(slider.max, '2');
assert.equal(
f.find((n) => n.className === 'rail-timeline-readout').textContent,
ticks[0],
);
t.mock.timers.tick(149);
assert.equal(commits.length, 0);
slider.value = '1';
slider.dispatchEvent(new Event('input'));
t.mock.timers.tick(1);
assert.deepEqual(commits, [ticks[1]]);
slider.value = '2';
slider.dispatchEvent(new Event('input'));
slider.dispatchEvent(new Event('change'));
assert.deepEqual(commits, [ticks[1], ticks[2]]);
t.mock.timers.tick(150);
assert.equal(commits.length, 2);
assert.equal(slider.max, '1');
view.destroy();
});
test('identical updates write nothing; controls call actions and destruction cancels commits', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let writes = 0;
const f = railFixture(() => writes++);
const calls = [];
const view = createRailTimeline({
...f,
onCommit: () => calls.push('commit'),
onStep: (n) => calls.push(n),
onLatest: () => calls.push('latest'),
onPlay: () => calls.push('play'),
});
view.update({ ...props, index: 1 });
writes = 0;
view.update({ ...props, index: 1 });
assert.equal(writes, 0);
for (const label of ['‹', '›', 'Latest', 'Play'])
f.find((n) => n.textContent === label).click();
assert.deepEqual(calls, [-1, 1, 'latest', 'play']);
view.update({ ...props, playing: true });
assert.ok(f.find((n) => n.textContent === 'Pause'));
const slider = f.find((n) => n.tagName === 'INPUT');
slider.value = '0';
slider.dispatchEvent(new Event('change'));
assert.equal(calls.at(-1), 'commit');
slider.dispatchEvent(new Event('input'));
view.destroy();
t.mock.timers.tick(150);
assert.equal(calls.length, 5);
assert.equal(f.container.children.length, 0);
});
test('observed history label and endpoint times track the advertised tick range', () => {
const f = railFixture();
const view = createRailTimeline(f);
view.update(props);
assert.equal(
f.find((n) => n.className === 'panel-title').textContent,
'Observed history',
);
const endpoints = f.find((n) => n.className === 'rail-timeline-endpoints');
assert.equal(endpoints.parent.className, 'rail-timeline-track');
assert.deepEqual(
endpoints.children.map((n) => n.textContent),
['01:00 UTC', '01:10 UTC'],
);
view.update({ ...props, ticks: ticks.slice(1) });
assert.equal(endpoints.children[0].textContent, '01:05 UTC');
view.destroy();
});
+63 -43
View File
@@ -7,6 +7,8 @@ import {
shouldHideCollapsedRightPanels,
} from './panelRailGeometry.js';
const pendingCollapseRetries = new WeakSet();
/**
* Measure and place the right panel rail for one synchronous layout pass.
* The caller owns scheduling, obstacle selection, disclosure preferences and
@@ -40,9 +42,11 @@ export function layoutRightPanelRail({
getComputedStyle = (element) => windowRef.getComputedStyle(element),
}) {
if (!stack) return;
// A collapse may schedule one follow-up, which only measures and allocates.
const isCollapseRetry = pendingCollapseRetries.delete(stack);
const panels = [...stack.children].filter((panel) =>
panel.matches('[data-panel-id]'),
const panels = [...stack.children].filter(
(panel) => panel.matches('[data-panel-id]') && !panel.hidden,
);
if (!hud.visible || hud.variant !== 'tactical') {
for (const panel of panels.filter((item) =>
@@ -113,33 +117,53 @@ export function layoutRightPanelRail({
});
}
// Auto-collapsed headers contribute the same need even when exclusive
// presentation hides them. Manually collapsed launchers keep their policy.
const measuredPanels = panels.filter(
(panel) =>
!exclusive ||
!panel.classList.contains('collapsed') ||
panel.classList.contains('layout-auto-collapsed'),
);
const visiblePanels = panels.filter(
(panel) => !exclusive || !panel.classList.contains('collapsed'),
);
const displayScrollTop = readDisplayScrollTop();
// Measure intrinsic content, not the allocation written by the previous
// layout pass. Display is the exception: its own scrollHeight already
// exposes every control, and removing its live allocation can reset the
// user's scroll position while HUD or preset content is settling.
for (const panel of visiblePanels) {
if (!panel.classList.contains('collapsed') && panel !== displayPanel) {
panel.style.removeProperty('--right-panel-allocated-height');
}
}
const gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
const naturalHeight =
visiblePanels.reduce(
(total, panel) =>
total +
// Measuring lifts each opted-in scroller's max-height, which clamps its
// scroll offset; remember the offsets to put back afterwards.
const scrollers = [
...(stack.querySelectorAll?.('[data-rail-scroller]') || []),
]
.map((node) => [node, node.scrollTop || 0])
.filter(([, top]) => top > 0);
const naturalHeights = new Map();
// Remove live height allocations through a synchronous CSS override. Keeping
// the stored properties intact avoids REMOVE/SET churn on unchanged panels.
// Do not toggle layout classes here: the class observer schedules new passes.
stack.setAttribute('data-rail-measuring', '');
try {
for (const panel of measuredPanels) {
naturalHeights.set(
panel,
Math.max(
panel.getBoundingClientRect().height,
panel.scrollHeight || 0,
panel.classList.contains('collapsed') ? 42 : 0,
),
);
}
} finally {
stack.removeAttribute('data-rail-measuring');
for (const [node, top] of scrollers)
if (node.scrollTop !== top) node.scrollTop = top;
}
const gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
const naturalHeight =
measuredPanels.reduce(
(total, panel) => total + naturalHeights.get(panel),
0,
) +
gap * Math.max(0, visiblePanels.length - 1);
gap * Math.max(0, measuredPanels.length - 1);
const layout = resolveHudRailLayout({
viewportHeight,
panelHeight: naturalHeight,
@@ -153,16 +177,16 @@ export function layoutRightPanelRail({
});
if (!layout) return;
const { safeTop, safeBottom, maxHeight: availableHeight } = layout;
const stabilityBand = viewportHeight * 0.01;
const stabilityBand = Math.max(48, viewportHeight * 0.06);
const wasFocused = stack.classList.contains('layout-focus');
const shouldFocus = wasFocused
? naturalHeight > availableHeight - stabilityBand * 2
: naturalHeight > availableHeight - stabilityBand;
? naturalHeight >= availableHeight - stabilityBand
: naturalHeight > availableHeight;
const layoutTop = shouldFocus ? safeTop : layout.top;
const collapsedHeight = visiblePanels.reduce(
(total, panel) =>
panel.classList.contains('collapsed')
? total + panel.getBoundingClientRect().height
? total + naturalHeights.get(panel)
: total,
0,
);
@@ -194,38 +218,34 @@ export function layoutRightPanelRail({
gap * Math.max(0, visiblePanels.length - 1),
);
const expandedHeights = allocatePanelStackHeights({
naturalHeights: expandedPanels.map((panel) =>
Math.max(panel.getBoundingClientRect().height, panel.scrollHeight || 0),
),
naturalHeights: expandedPanels.map((panel) => naturalHeights.get(panel)),
availableHeight: expandedAvailableHeight,
});
const autoCollapseIndices = hud.visible
? panelStackAutoCollapseIndices({
naturalHeights: expandedPanels.map((panel) =>
Math.max(
panel.getBoundingClientRect().height,
panel.scrollHeight || 0,
// Other HUD variants restore automatic collapse at the start of each pass.
// Do not immediately collapse those panels again and schedule a loop.
const autoCollapseIndices =
hud.visible && hud.variant === 'tactical'
? panelStackAutoCollapseIndices({
naturalHeights: expandedPanels.map((panel) =>
naturalHeights.get(panel),
),
),
allocatedHeights: expandedHeights,
collapseLaterPanels: shouldFocus && hud.variant === 'tactical',
})
: [];
if (autoCollapseIndices.length) {
allocatedHeights: expandedHeights,
collapseLaterPanels: shouldFocus && hud.variant === 'tactical',
})
: [];
if (!isCollapseRetry && autoCollapseIndices.length) {
for (const index of autoCollapseIndices) {
const panel = expandedPanels[index];
panel.classList.add('collapsed', 'layout-auto-collapsed');
onCollapse(panel);
}
pendingCollapseRetries.add(stack);
onRetry();
return;
}
// Write-if-changed. This pass runs on the 500 ms stats cadence, and an
// unconditional REMOVE-then-SET of an unchanged allocation is two style
// mutations per tick on `#pp-toggles` (the one panel the measure-strip
// above deliberately skips) — churn that reads as a genuine panel move to
// the world-overlay host's occluder observer and defeats parked-idle
// render savings. Only a real allocation change may touch the attribute.
// Write-if-changed. This pass runs on the 500 ms stats cadence. Rewriting
// an unchanged allocation wakes the world-overlay occluder observer and
// defeats parked-idle render savings. Measurement leaves these values intact.
expandedPanels.forEach((panel, index) => {
const next = `${expandedHeights[index].toFixed(1)}px`;
if (
+74
View File
@@ -0,0 +1,74 @@
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
const attribute = (node, key, value) => {
if (node.getAttribute(key) !== value) node.setAttribute(key, value);
};
/** Reconcile ordered layer/card lists without replacing focused items. */
export function syncRowList(container, list) {
const document = container?.ownerDocument ?? globalThis.document;
if (!container) return;
const items = list?.items || [];
set(container, 'hidden', items.length === 0);
attribute(container, 'aria-label', list?.ariaLabel || '');
const stale = new Map();
for (const node of [...container.children]) {
if (node.dataset?.listItemId) stale.set(node.dataset.listItemId, node);
}
let index = 0;
let activeButton = null;
for (const item of items) {
let entry = stale.get(item.id);
stale.delete(item.id);
let button;
if (!entry) {
entry = document.createElement('li');
entry.dataset.listItemId = item.id;
button = document.createElement('button');
button.type = 'button';
button.className = 'data-row-list-item';
button.dataset.listItemId = item.id;
const lead = document.createElement('span');
lead.className = 'data-row-list-lead';
const text = document.createElement('span');
text.className = 'data-row-list-text';
button.append(lead, text);
entry.appendChild(button);
} else {
button = entry.querySelector('.data-row-list-item');
}
// Keep DOM order in step with descriptor order without rebuilding.
const anchor = container.children[index++] || null;
if (entry !== anchor) container.insertBefore(entry, anchor);
if (!button) continue;
const lead = button.querySelector('.data-row-list-lead');
const text = button.querySelector('.data-row-list-text');
const leadText = String(item.lead ?? '');
const bodyText = String(item.text ?? '');
if (lead && lead.textContent !== leadText) lead.textContent = leadText;
if (text && text.textContent !== bodyText) text.textContent = bodyText;
set(button, 'disabled', Boolean(item.disabled));
set(
button,
'className',
`data-row-list-item${item.disabled ? ' note' : ''}${item.active ? ' active' : ''}${item.current ? ' current' : ''}`,
);
attribute(button, 'aria-current', item.current ? 'step' : 'false');
attribute(button, 'aria-pressed', item.active ? 'true' : 'false');
set(button, 'title', bodyText);
if (item.current) activeButton = button;
}
for (const node of stale.values()) node.remove();
// Follow the flight, but never steal a scroll the reader is making
// themselves: only when the step actually changed.
if (
activeButton &&
container.dataset.currentId !== activeButton.dataset.listItemId
) {
container.dataset.currentId = activeButton.dataset.listItemId;
activeButton.scrollIntoView?.({ block: 'nearest' });
} else if (!activeButton) {
delete container.dataset.currentId;
}
}
+1
View File
@@ -142,6 +142,7 @@ export function readShellElements(document = globalThis.document) {
_cctvCalibResetBtn: document.getElementById('cctv-calib-reset-btn'),
_cctvFrame: document.getElementById('cctv-frame'),
_cctvFrameWrap: document.getElementById('cctv-frame-wrap'),
_cctvVideo: document.getElementById('cctv-video'),
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
_cctvMeta: document.getElementById('cctv-meta'),
_cctvSummary: document.getElementById('cctv-summary'),
+99
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { UiLifetime } from './uiLifetime.js';
import { PanelChrome } from './panelChrome.js';
import { PanelLayoutController } from './panelLayoutController.js';
import { PanelPositionControls } from './panelPositionControls.js';
import { ShellFeedback } from './shellFeedback.js';
@@ -267,3 +268,101 @@ test('recording restores the original HUD after repeated entry and revokes retai
f.restore();
}
});
test('weather rail orders between CCTV and Context, resets positions, observes and syncs chrome', (t) => {
const f = fixture();
const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
observe(node) {
observed.add(node);
}
disconnect() {
observed.clear();
}
};
t.after(() => {
if (previousResizeObserver === undefined) delete globalThis.ResizeObserver;
else globalThis.ResizeObserver = previousResizeObserver;
});
try {
const stack = f.nodes.get('right-context-rail');
stack.children = [];
stack.insertBefore = (panel, before) => {
stack.children = stack.children.filter((child) => child !== panel);
const index = before
? stack.children.indexOf(before)
: stack.children.length;
stack.children.splice(index, 0, panel);
};
stack.prepend = (panel) => stack.insertBefore(panel, stack.children[0]);
for (const id of [
'pp-toggles',
'cctv-panel',
'weather-panel',
'global-context-panel',
]) {
const panel = f.nodes.get(id) || f.element();
panel.id = id;
const positions = new Map(
['top', 'right', 'bottom', 'left', 'z-index'].map((key) => [
key,
'20px',
]),
);
panel.style = {
removeProperty: (key) => positions.delete(key),
positions,
};
panel.classList.add('panel-draggable', 'panel-dragging', 'collapsed');
const button = f.element();
button.closest = () => panel;
const attributes = new Map();
button.setAttribute = (name, value) => attributes.set(name, value);
button.getAttribute = (name) => attributes.get(name);
panel.button = button;
panel.querySelectorAll = () => [button];
panel.querySelector = (selector) =>
selector.startsWith('.panel-title')
? { textContent: id === 'weather-panel' ? 'WEATHER' : id }
: null;
f.nodes.set(id, panel);
}
const weather = f.nodes.get('weather-panel');
stack.children = [weather, f.nodes.get('global-context-panel')];
const synced = [];
const chrome = { _syncContextRadioLauncherState() {} };
const owner = new PanelLayoutController({
readHud: () => ({}),
scheduleCockpitLayout() {},
readDisplayScrollTop: () => 0,
syncPanelCollapseButton(panel) {
synced.push(panel.id);
PanelChrome.prototype._syncPanelCollapseButton.call(chrome, panel);
},
});
owner._initRightPanelAdaptiveLayout();
assert.deepEqual(
stack.children.map((panel) => panel.id),
['pp-toggles', 'cctv-panel', 'weather-panel', 'global-context-panel'],
);
assert.ok(synced.includes('weather-panel'));
assert.ok(observed.has(weather));
assert.equal(weather.style.positions.size, 0);
assert.equal(weather.classList.contains('panel-draggable'), false);
assert.equal(weather.classList.contains('panel-dragging'), false);
assert.equal(weather.button.textContent, '◀');
assert.equal(weather.button.getAttribute('aria-expanded'), 'false');
assert.equal(weather.button.title, 'Expand WEATHER');
weather.classList.remove('collapsed');
PanelChrome.prototype._syncPanelCollapseButton.call(chrome, weather);
assert.equal(weather.button.textContent, '▶');
assert.equal(weather.button.getAttribute('aria-expanded'), 'true');
assert.equal(weather.button.getAttribute('aria-label'), 'Collapse WEATHER');
owner.destroy();
assert.equal(observed.size, 0);
assert.equal(f.frames.size, 0);
} finally {
f.restore();
}
});
+20
View File
@@ -133,3 +133,23 @@ test('camera-entry listeners are removed before any asynchronous layer cleanup',
owner.stop();
assert.equal(changes, 1);
});
test('weather shell supplies live imagery hosts to wind and observations and releases them', (t) => {
const { owner } = bindings(t);
let host = { collection: {}, kind: 'tileset' };
owner.services.imageryHost = () => host;
const attached = new Map();
const ids = ['wind', 'weather-radar', 'weather-satellite', 'weather-lightning'];
owner.attachDataManager({
layers: new Map(ids.map(id => [id, { module: {
attachShellServices(services) { attached.set(id, services); },
} }])),
subscribe: () => () => {},
});
for (const id of ids) assert.equal(attached.get(id).imageryHost(), host);
host = { collection: null, kind: 'none' };
for (const id of ids) assert.equal(attached.get(id).imageryHost(), host);
owner.disconnect();
for (const id of ids) assert.equal(attached.get(id), null);
});
+16
View File
@@ -298,10 +298,26 @@
transition: opacity 180ms ease;
}
#cctv-video {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
#cctv-frame.active {
opacity: 1;
}
#cctv-video:not([hidden]) {
opacity: 1;
}
#cctv-frame[hidden],
#cctv-video[hidden] {
display: none;
}
#cctv-meta {
font-family: var(--font-mono);
font-size: 9px;
+64 -2
View File
@@ -162,6 +162,7 @@
}
#right-context-rail > #pp-toggles,
#right-context-rail > #cctv-panel,
#right-context-rail > #weather-panel,
#right-context-rail > #global-context-panel {
position: relative;
inset: auto;
@@ -173,12 +174,20 @@
max-height: 100%;
pointer-events: auto;
}
#right-context-rail #cctv-panel {
#right-context-rail #cctv-panel,
#right-context-rail #weather-panel {
width: 100%;
max-height: 100%;
pointer-events: auto;
}
#right-context-rail #cctv-panel.collapsed {
#right-context-rail > #weather-panel {
display: flex;
flex-direction: column;
flex-shrink: 1;
min-height: 0;
}
#right-context-rail #cctv-panel.collapsed,
#right-context-rail #weather-panel.collapsed {
width: var(--right-display-collapsed-width);
margin-left: auto;
}
@@ -191,6 +200,7 @@
}
#right-context-rail > #pp-toggles.collapsed .pp-header-row,
#right-context-rail #cctv-panel.collapsed .cctv-panel-inner,
#right-context-rail #weather-panel.collapsed .weather-panel-inner,
#right-context-rail
#global-context-panel.collapsed
.global-context-panel-inner {
@@ -246,6 +256,7 @@
}
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
#right-context-rail.layout-focus > #cctv-panel:not(.collapsed),
#right-context-rail.layout-focus > #weather-panel:not(.collapsed),
#right-context-rail.layout-focus > #global-context-panel:not(.collapsed) {
flex: 0 1 var(--right-panel-allocated-height);
height: var(--right-panel-allocated-height);
@@ -258,6 +269,9 @@
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.4) transparent;
}
#right-context-rail.layout-focus
> #weather-panel:not(.collapsed)
.weather-panel-inner,
#right-context-rail.layout-focus
> #cctv-panel:not(.collapsed)
.cctv-panel-inner,
@@ -283,6 +297,40 @@
flex: 1 1 auto;
max-height: none;
}
/* Synchronous intrinsic measurement, independent of focus and allocation.
This attribute is not observed by the rail's layout scheduler. */
#right-context-rail[data-rail-measuring] {
height: auto !important;
max-height: none !important;
transition: none !important;
}
#right-context-rail[data-rail-measuring] > [data-panel-id]:not([hidden]) {
--right-panel-allocated-height: initial !important;
flex: 0 0 auto !important;
height: auto !important;
max-height: none !important;
transition: none !important;
}
#right-context-rail[data-rail-measuring]
> [data-panel-id].collapsed:not([hidden]) {
display: block !important;
}
#right-context-rail[data-rail-measuring]
> [data-panel-id]:not(.collapsed)
> .cctv-panel-inner,
#right-context-rail[data-rail-measuring]
> [data-panel-id]:not(.collapsed)
> .weather-panel-inner,
#right-context-rail[data-rail-measuring]
> [data-panel-id]:not(.collapsed)
> .global-context-panel-inner {
height: auto !important;
max-height: none !important;
}
#right-context-rail[data-rail-measuring] [data-rail-scroller],
#right-context-rail[data-rail-measuring] .space-mission-roster-list {
max-height: none !important;
}
#space-mission-panel {
width: 100%;
margin-top: 8px;
@@ -1306,3 +1354,17 @@
.data-count {
display: none;
}
body.ui-clean-view #right-context-rail,
body.recording-mode #right-context-rail,
body.ui-clean-view #right-context-rail > [data-panel-id],
body.recording-mode #right-context-rail > [data-panel-id] {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
@media (max-width: 720px) {
#right-context-rail #weather-panel.collapsed {
width: 100%;
}
}
+9
View File
@@ -890,3 +890,12 @@
opacity: 0.75;
font-style: italic;
}
.data-toggle-controls-info {
flex-basis: 100%;
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-secondary);
padding-top: 3px;
overflow-wrap: anywhere;
}
+385
View File
@@ -0,0 +1,385 @@
/* The rail owns placement and height; only the product body scrolls. */
.weather-panel-inner {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
max-height: 100%;
box-sizing: border-box;
padding: 14px;
border: 1px solid var(--glass-border);
border-radius: var(--panel-radius);
background: var(--glass-bg);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 1px 0 rgba(255, 255, 255, 0.06) inset;
}
#weather-panel .panel-glow {
position: absolute;
inset: -18px;
background: radial-gradient(
ellipse at center,
rgba(0, 212, 255, 0.16) 0%,
transparent 65%
);
filter: blur(16px);
opacity: 0.25;
pointer-events: none;
}
#weather-panel .panel-header {
flex: 0 0 auto;
}
#weather-panel-count {
color: var(--text-secondary);
font: 9px var(--font-mono);
}
#weather-panel-body {
min-height: 0;
max-height: calc(var(--right-stack-max-height, 50svh) - 64px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.46) transparent;
}
#weather-panel.collapsed #weather-panel-body,
#weather-panel[hidden],
.weather-readout[hidden] {
display: none !important;
}
#weather-panel.collapsed .panel-title {
font-size: 8px;
letter-spacing: 1.8px;
}
#weather-panel.collapsed .panel-header {
margin-bottom: 2px;
}
#weather-panel.collapsed .weather-panel-inner {
padding-bottom: 10px;
}
/* Card typography follows the layer rows and app tokens. */
.weather-readout,
.rail-card,
.rail-timeline {
min-width: 0;
color: var(--text-dim);
font: 9px/1.45 var(--font-mono);
letter-spacing: 0.5px;
}
.weather-readout {
--weather-card-border: rgba(98, 181, 255, 0.28);
}
.rail-card {
padding: 0;
overflow-wrap: anywhere;
border: 1px solid transparent;
border-radius: 6px;
}
.rail-card + .rail-card {
border-top-color: var(--weather-card-border);
}
.rail-card:not(.is-open):hover,
.rail-card:not(.is-open):focus-within {
border-color: #79c2ff;
}
.rail-card.is-open {
border-color: rgba(0, 212, 255, 0.55);
background: rgba(42, 113, 171, 0.09);
}
.rail-card-labels {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 8px;
min-width: 0;
}
.rail-card-heading {
display: flex;
flex: 1 1 auto;
align-items: center;
gap: 8px;
min-width: 0;
}
.rail-card-heading .data-icon {
color: var(--text-primary);
}
.rail-card-disclosure {
color: var(--text-dim);
font: 9px/1.45 var(--font-mono);
}
.rail-card-title {
flex: 1 1 auto;
min-width: 0;
font-family: var(--font-sans);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.3px;
}
.rail-card.is-open .rail-card-title {
color: #e8feff;
}
.rail-card-nowrap {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-card-badge {
flex: 0 1 auto;
min-width: 0;
margin-left: auto;
text-align: right;
}
.rail-card-badge,
.rail-card-line,
.rail-timeline-readout {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.5px;
color: var(--text-dim);
}
.rail-card-lines {
margin-top: 4px;
}
.rail-card-line {
white-space: pre-line;
}
.weather-card [data-line-id='status'] {
height: 1.45em;
overflow-y: auto;
color: var(--accent);
}
.rail-card-ramp {
position: relative;
height: 4px;
border-radius: 2px;
margin-top: 5px;
}
.rail-card-scale {
text-align: right;
font: 9px/1.45 var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--text-dim);
}
.rail-card-actions,
.rail-card-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
min-width: 0;
}
.rail-card .data-toggle-chip {
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
}
.rail-card .data-row-list {
padding-left: 0;
margin-left: 0;
}
.rail-card .data-row-list-item {
min-width: 0;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.5px;
}
.rail-card [hidden],
.weather-timeline-block[hidden],
.weather-observed-group[hidden] {
display: none !important;
}
.rail-timeline {
padding: 5px;
border-bottom: 1px solid var(--weather-card-border);
}
.rail-timeline-track {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 3px 8px;
margin-top: 6px;
}
.rail-timeline-track input {
min-width: 0;
width: 100%;
margin: 0;
}
.rail-timeline-endpoints {
display: flex;
grid-column: 2;
justify-content: space-between;
flex-wrap: wrap;
gap: 3px 8px;
font: 8px/1.45 var(--font-mono);
font-variant-numeric: tabular-nums;
}
.rail-timeline-endpoints span {
white-space: nowrap;
}
.rail-timeline-endpoints span:last-child {
margin-left: auto;
}
.rail-timeline-controls .data-toggle-chip {
flex: 0 0 auto;
}
.rail-timeline-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.rail-timeline-readout {
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
body.ui-clean-view .gev-wind-inspection-marker,
body.recording-mode .gev-wind-inspection-marker {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
.rail-card-zero {
position: absolute;
top: -2px;
height: 8px;
border-left: 1px solid var(--text-primary);
}
.rail-card-zero::after {
content: '0°C';
position: absolute;
top: 8px;
left: 0;
transform: translateX(-50%);
font: 9px/1.45 var(--font-mono);
color: var(--text-dim);
}
.weather-observed-group {
border: 1px solid var(--weather-card-border);
border-radius: 6px;
padding: 6px;
margin-top: 6px;
min-width: 0;
}
.weather-observed-group > .panel-title {
margin: 0;
}
.weather-observed-scope {
margin-top: 4px;
color: var(--text-dim);
}
.rail-card-header {
width: 100%;
padding: 8px 5px;
align-items: baseline;
background: none;
border: 0;
text-align: left;
cursor: pointer;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 8px;
row-gap: 2px;
color: inherit;
}
.rail-card-header:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 3px;
}
.rail-card-badge {
text-transform: uppercase;
}
.rail-card-compact {
grid-column: 1 / -1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* Dim and secondary fall below 4.5:1 over bright imagery through glass. */
color: var(--text-primary);
font: 9px/1.45 var(--font-mono);
letter-spacing: 0.5px;
}
.rail-card-compact.status {
color: var(--accent);
}
.rail-card-setting {
display: grid;
grid-template-columns: 56px minmax(0, 1fr);
align-items: baseline;
gap: 6px;
margin-top: 6px;
}
.rail-card-setting-label {
color: var(--text-dim);
font: 9px var(--font-mono);
letter-spacing: 3px;
text-transform: uppercase;
}
.rail-card-setting .rail-card-chips {
margin-top: 0;
}
.rail-card-result {
display: grid;
gap: 4px;
margin-top: 8px;
border: 1px solid rgba(65, 220, 231, 0.18);
border-radius: 7px;
background: rgba(2, 15, 20, 0.48);
padding: 10px;
}
.rail-card-result-header {
display: flex;
align-items: baseline;
gap: 6px;
}
.rail-card-result-header .panel-title {
white-space: normal;
flex: 1;
min-width: 0;
color: var(--text-dim);
font: 9px/1.45 var(--font-mono);
letter-spacing: 0.5px;
}
.rail-card-result .rail-card-lines {
display: grid;
gap: 4px;
margin-top: 0;
}
.rail-card-result [data-line-id='wind'] {
color: #e8feff;
font: 13px/1.25 var(--font-mono);
}
.rail-card-result [data-line-id='explanation'] {
font: 8px/1.35 var(--font-mono);
}
.rail-card-actions {
align-items: center;
}
.rail-card-legend-entry {
display: flex;
align-items: center;
gap: 6px;
margin-top: 3px;
}
.rail-card-legend-swatch {
width: 12px;
height: 4px;
flex: 0 0 12px;
}
.rail-card-body {
padding: 0 5px 8px;
}
.rail-card[data-open='true'] > .rail-card-header {
padding-bottom: 0;
}
+12
View File
@@ -1,5 +1,17 @@
<!-- Global Context is deliberately independent from the left layer accordion. -->
<aside id="right-context-rail">
<div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden>
<div class="panel-glow"></div>
<div class="weather-panel-inner">
<div class="panel-header">
<span class="panel-title">WEATHER</span>
<span id="weather-panel-count" aria-label="Active weather products">0</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="weather-panel" type="button" title="Collapse panel">▼</button>
</div>
<div id="weather-panel-body" data-rail-scroller></div>
</div>
</div>
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
<div class="panel-glow"></div>
<div class="global-context-panel-inner">
+1
View File
@@ -23,6 +23,7 @@
</div>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
</div>
<div id="cctv-source-badge" role="status" aria-live="polite">SOURCE · UNKNOWN</div>
<div id="cctv-meta">Enable CCTV to load camera intersections</div>
+296
View File
@@ -0,0 +1,296 @@
import { createRailCards } from './railCards.js';
import { createRailTimeline } from './railTimeline.js';
// Remounting controls must not reopen a panel the user already collapsed.
const appearedDocuments = new WeakSet();
const openDocuments = new WeakMap();
const ORDER = [
'weather-cyclones',
'wind',
'weather-radar',
'weather-satellite',
'weather-lightning',
];
const OBSERVED = new Set(ORDER.slice(2));
const utc = (time) =>
Number.isFinite(Date.parse(time))
? `${new Date(time).toISOString().slice(5, 16).replace('T', ' ')} UTC`
: 'Unavailable';
const age = (time) => {
const minutes = Math.max(
0,
Math.floor((Date.now() - Date.parse(time)) / 60_000),
);
return minutes >= 60
? `${Math.floor(minutes / 60)}h ${minutes % 60}m ago`
: `${minutes}m ago`;
};
const historyTime = (time) =>
`${utc(time).slice(6)} · ${Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000))} min ago`;
const dated = (time) => `${utc(time)} · ${age(time)}`;
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
/**
* Bring a newly opened card into the scrolled body: nearest alignment, or its
* header at the top when the card is taller than the body. One write to the
* body's own scroll position, without animation; ancestors never move.
*/
function revealCard(scroller, card) {
const height = scroller?.clientHeight;
if (!(height > 0) || !scroller.getBoundingClientRect) return;
if (typeof card?.getBoundingClientRect !== 'function') return;
const view = scroller.getBoundingClientRect();
const box = card.getBoundingClientRect();
const top = box.top - view.top - (scroller.clientTop || 0);
const delta =
top < 0 || box.height > height
? top
: Math.max(0, top + box.height - height);
if (delta) scroller.scrollTop = (scroller.scrollTop || 0) + delta;
}
/** Map weather descriptors and the observed clock into reusable rail readouts. */
export function createWeatherPanel({
container,
clock,
setLayerParams = () => {},
onAction = () => {},
} = {}) {
const document = container?.ownerDocument;
if (!document?.createElement) return null;
const root = document.createElement('section');
root.className = 'weather-readout';
root.hidden = true;
root.setAttribute('aria-label', 'Active weather');
const timelineHost = document.createElement('div');
timelineHost.className = 'weather-timeline-block';
timelineHost.hidden = true;
const cardsHost = document.createElement('div');
cardsHost.className = 'weather-cards';
const observedGroup = document.createElement('section');
observedGroup.className = 'weather-observed-group';
observedGroup.setAttribute('aria-label', 'Observed history');
const heading = document.createElement('h3');
heading.className = 'panel-title';
heading.textContent = 'Observed history';
const scope = document.createElement('div');
scope.className = 'weather-observed-scope';
const observedCardsHost = document.createElement('div');
observedCardsHost.className = 'weather-cards';
observedGroup.append(heading, scope, timelineHost, observedCardsHost);
root.append(cardsHost, observedGroup);
container.appendChild(root);
const panel = container.closest?.('#weather-panel');
const count = panel?.querySelector('#weather-panel-count');
let entries = [];
let destroyed = false;
let previousIds = null;
let hasAppeared = false;
let openId = openDocuments.get(document) || null;
// Open card as last shown; null until cards are on screen.
let shownOpenId = null;
const timeline = createRailTimeline({
container: timelineHost,
document,
sliderClassName: 'weather-timeline',
heading: false,
onCommit: (tick) => clock?.setTarget(tick),
onPreview: historyTime,
onStep: (direction) => clock?.step(direction),
onLatest: () => clock?.latest(),
onPlay: () => clock?.togglePlay(),
});
const cardOptions = {
document,
cardClassName: 'weather-card',
badgeClassName: 'weather-coverage',
onParams: (id, params) => setLayerParams(id, params, { origin: 'user' }),
onOpen: (id) => {
openId = id;
openDocuments.set(document, id);
render();
},
};
const cards = createRailCards({ ...cardOptions, container: cardsHost });
const observedCards = createRailCards({
...cardOptions,
container: observedCardsHost,
});
const render = () => {
if (destroyed) return;
const state = clock?.getState() || {
mode: 'latest',
timeline: [],
products: [],
};
const active = entries
.filter(({ summary }) => summary)
.sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id));
const observed = active.filter(({ id }) => OBSERVED.has(id));
const showTimeline = observed.length > 0;
set(observedGroup, 'hidden', !showTimeline);
const names = {
'weather-radar': 'Rain radar',
'weather-satellite': 'Satellite clouds',
'weather-lightning': 'Lightning density',
};
set(scope, 'textContent', observed.map(({ id }) => names[id]).join(' · '));
set(timelineHost, 'hidden', !showTimeline);
const index =
state.mode === 'latest'
? state.timeline.length - 1
: Math.max(
0,
state.timeline.findLastIndex(
(tick) => Date.parse(tick) <= Date.parse(state.target),
),
);
timeline.update({
ticks: state.timeline,
index,
mode: state.mode,
playing: Boolean(state.playing),
disabled: !showTimeline || state.timeline.length < 2,
readout:
state.mode === 'latest'
? 'LATEST · newest per product'
: historyTime(state.target),
});
const models = active.map(({ id, icon, summary, legend = [], list }) => {
let detail = summary.detail;
const product = state.products.find((item) => item.id === id);
if (OBSERVED.has(id)) {
const shown = product?.shown ?? summary.shownTime;
if (state.mode === 'history' && product?.selected === null) {
const gap = summary.maxGapMinutes || 30;
detail = `No frame within ${gap < 60 ? `${gap} min` : `${gap / 60} h`} of ${utc(state.target).slice(6)}`;
} else if (shown) {
detail = dated(shown);
if (state.mode === 'history')
detail +=
Date.parse(shown) === Date.parse(state.target)
? ' · synced'
: ' · nearest';
}
} else if (id === 'wind') {
detail = `Forecast · valid ${utc(summary.validTime)} · issued ${utc(summary.issuedTime)}`;
if (state.mode === 'history') detail += ' · Does not follow history';
}
const lines = [
{ id: 'time', text: detail, muted: true },
{ id: 'status', text: summary.status },
];
for (const line of summary.lines || []) lines.push(line);
const actions = (summary.actions || []).map((action) => ({
...action,
onClick: action.onClick || (() => onAction(id, action.id)),
}));
const legendBlock = {
id: 'legend',
type: 'legend',
legend: {
categorical: id === 'weather-cyclones',
colors: legend.map(({ color }) => color),
labels: legend.map(({ label }) => label),
units: id === 'weather-cyclones' ? '' : summary.units,
zeroIndex: legend.findIndex(({ label }) => label === '0'),
},
};
const blocks = [
...(id === 'weather-cyclones' && list?.items?.length
? [{ id: 'storms', type: 'list', list }]
: []),
{ id: 'details', type: 'lines', lines },
...(legend.length ? [legendBlock] : []),
...(summary.settings?.length
? [{ id: 'settings', type: 'settings', settings: summary.settings }]
: []),
...(actions.length
? [{ id: 'actions', type: 'actions', actions }]
: []),
...(summary.result ? [{ ...summary.result, type: 'result' }] : []),
];
return {
id,
icon,
title: summary.label,
badge: summary.coverage,
open: id === openId,
compact:
summary.status ||
summary.compact ||
(id === 'wind'
? `Forecast · valid ${utc(summary.validTime)}`
: detail),
compactStatus: Boolean(summary.status),
blocks,
};
});
cards.update(models.filter(({ id }) => !OBSERVED.has(id)));
observedCards.update(models.filter(({ id }) => OBSERVED.has(id)));
// Only a change of open card moves the scroll position; refreshes leave it.
if (shownOpenId !== null && openId !== shownOpenId) {
const open = [...cardsHost.children, ...observedCardsHost.children].find(
(node) => node.dataset?.cardId === openId,
);
revealCard(container, open);
}
shownOpenId = active.length ? openId : null;
const hidden = active.length === 0;
set(root, 'hidden', hidden);
if (count) set(count, 'textContent', String(active.length));
if (panel) set(panel, 'hidden', hidden);
if (!hidden && panel && !appearedDocuments.has(document)) {
appearedDocuments.add(document);
// First appearance opens the panel unless a stored or shared collapse
// choice exists (marked by the panel chrome when it restored the state).
const preference = panel.dataset?.collapsedPreference;
if (
panel.classList.contains('collapsed') &&
(preference === undefined || preference === 'default')
)
panel.querySelector('[data-collapse-target="weather-panel"]')?.click();
}
};
const unsubscribe = clock?.subscribe(render);
return {
update(nextEntries) {
entries = nextEntries.filter(({ summary }) => summary);
const ids = new Set(entries.map(({ id }) => id));
const ordered = [...entries].sort(
(a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id),
);
const added =
previousIds && entries.filter(({ id }) => !previousIds.has(id));
if (hasAppeared && added?.length) {
openId = added.at(-1).id;
openDocuments.set(document, openId);
} else if (ids.size && !ids.has(openId)) {
openId =
(!hasAppeared &&
!openId &&
ordered.find(
({ id, list }) =>
id === 'weather-cyclones' && list?.items?.length,
)?.id) ||
ordered[0]?.id ||
null;
}
if (ids.size) hasAppeared = true;
previousIds = ids;
render();
},
destroy() {
destroyed = true;
unsubscribe?.();
timeline.destroy();
cards.destroy();
observedCards.destroy();
root.remove();
if (panel) set(panel, 'hidden', true);
if (count) set(count, 'textContent', '0');
},
};
}
+656
View File
@@ -0,0 +1,656 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWeatherPanel } from './weatherPanel.js';
import { railFixture } from './railTestFixture.mjs';
const ticks = ['2026-09-22T01:00:00Z', '2026-09-22T01:05:00Z'];
const wind = {
id: 'wind',
icon: '🌬',
summary: {
label: 'Wind motion',
coverage: 'Global · 1° grid',
validTime: ticks[0],
issuedTime: ticks[0],
},
};
const radar = {
id: 'weather-radar',
icon: '◉',
summary: { label: 'Rain radar', coverage: 'CONUS' },
};
function fixture(onWrite) {
const f = railFixture(onWrite);
const panel = f.document.createElement('div');
const count = f.document.createElement('span');
let collapsed = true;
let clicks = 0;
panel.hidden = true;
panel.classList = { contains: () => collapsed };
const collapse = {
click() {
collapsed = !collapsed;
clicks++;
},
};
panel.querySelector = (selector) =>
selector === '#weather-panel-count' ? count : collapse;
f.container.closest = () => panel;
const listeners = new Set();
const calls = [];
let state = {
mode: 'latest',
timeline: ticks,
products: [{ id: radar.id, shown: ticks[1], selected: ticks[1] }],
};
const clock = {
getState: () => state,
subscribe: (fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
},
setTarget: (tick) => calls.push(['target', tick]),
latest: () => calls.push(['latest']),
togglePlay: () => calls.push(['play']),
step: (n) => calls.push(['step', n]),
};
return {
...f,
panel,
count,
collapse,
clicks: () => clicks,
calls,
listeners,
clock,
state: (next) => {
state = { ...state, ...next };
for (const fn of listeners) fn(state);
},
};
}
const timelineHost = (f) =>
f.find((n) => n.className === 'weather-timeline-block');
const card = (f, id) => f.find((n) => n.dataset.cardId === id);
const line = (f, id, name) =>
f.find((n) => n.dataset.lineId === name, card(f, id));
test('timeline stays visible with any observed product; native preview and transport use the clock', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const f = fixture();
const view = createWeatherPanel(f);
view.update([wind]);
assert.equal(timelineHost(f).hidden, true);
view.update([wind, radar]);
assert.equal(timelineHost(f).hidden, false);
f.state({ timeline: [ticks[0]] });
assert.equal(timelineHost(f).hidden, false);
assert.equal(f.find((n) => n.tagName === 'INPUT').disabled, true);
f.state({ timeline: [] });
assert.equal(timelineHost(f).hidden, false);
f.state({ timeline: ticks });
assert.equal(timelineHost(f).hidden, false);
const slider = f.find((n) => n.tagName === 'INPUT');
slider.value = '0';
slider.dispatchEvent(new Event('input'));
assert.deepEqual(f.calls, []);
slider.dispatchEvent(new Event('change'));
assert.deepEqual(f.calls, [['target', ticks[0]]]);
f.find((n) => n.textContent === 'Latest' && n.tagName === 'BUTTON').click();
f.find((n) => n.textContent === 'Play').click();
assert.deepEqual(f.calls.slice(1), [['latest'], ['play']]);
slider.dispatchEvent(new Event('input'));
view.update([wind]);
t.mock.timers.tick(150);
assert.equal(f.calls.length, 3, 'hiding cancels pending drag');
view.destroy();
assert.equal(f.listeners.size, 0);
});
test('ordered articles omit Controls and retain history labels, forecast dates and missing-frame readouts', () => {
const f = fixture();
const view = createWeatherPanel(f);
view.update([radar, wind]);
const windCard = card(f, 'wind');
assert.equal(windCard.tagName, 'ARTICLE');
assert.equal(windCard.parent.children[0], windCard);
assert.equal(
f.find((n) => n.dataset.weatherOpen),
null,
);
assert.match(
line(f, 'wind', 'time').textContent,
/Forecast · valid .*UTC · issued .*UTC/,
);
f.state({ mode: 'history', target: ticks[1] });
assert.match(line(f, radar.id, 'time').textContent, /01:05 UTC.*synced/);
assert.match(line(f, 'wind', 'time').textContent, /Does not follow history/);
f.state({
products: [{ id: radar.id, shown: ticks[0], selected: ticks[0] }],
});
assert.match(line(f, radar.id, 'time').textContent, /01:00 UTC.*nearest/);
f.state({ products: [{ id: radar.id, shown: null, selected: null }] });
assert.equal(
line(f, radar.id, 'time').textContent,
'No frame within 30 min of 01:05 UTC',
);
const global = {
id: 'weather-satellite',
summary: { label: 'Satellite clouds', maxGapMinutes: 180 },
};
view.update([global]);
f.state({ products: [{ id: global.id, shown: null, selected: null }] });
assert.equal(
line(f, global.id, 'time').textContent,
'No frame within 3 h of 01:05 UTC',
);
view.destroy();
});
test('cyclone readout retains advisory, position, intensity, geometry, coverage, legend and source link', () => {
const f = fixture();
const view = createWeatherPanel(f);
const cyclone = {
id: 'weather-cyclones',
summary: {
label: 'Cyclones',
coverage: 'Atlantic + E/C Pacific',
detail: 'JULIO · Hurricane · Advisory 10 · 09-22 01:00 UTC',
status: 'Loading advisories…',
lines: [
{ id: 'position', text: 'Position as of 09-22 00:00 UTC' },
{ id: 'intensity', text: '80 kt · 970 hPa' },
{ id: 'geometry', text: 'Track/cone awaiting advisory 10' },
],
actions: [
{
id: 'advisory',
label: 'Official advisory ↗',
href: 'https://www.nhc.noaa.gov/advisory',
},
],
},
legend: [
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
],
};
view.update([cyclone]);
assert.equal(timelineHost(f).hidden, true);
assert.match(
line(f, cyclone.id, 'time').textContent,
/JULIO.*Hurricane.*Advisory 10.*UTC/,
);
assert.match(line(f, cyclone.id, 'position').textContent, /00:00 UTC/);
assert.match(line(f, cyclone.id, 'intensity').textContent, /80 kt · 970 hPa/);
assert.match(
line(f, cyclone.id, 'geometry').textContent,
/awaiting advisory 10/,
);
const link = f.find((n) => n.textContent === 'Official advisory ↗');
assert.equal(link.href, cyclone.summary.actions[0].href);
assert.equal(link.tagName, 'A');
assert.equal(link.rel, 'noopener');
const entries = f.find((n) => n.className === 'rail-card-legend-entries');
assert.equal(entries.children.length, 2);
assert.match(entries.children[0].children[1].textContent, /forecast track/);
assert.match(entries.children[1].children[1].textContent, /uncertainty cone/);
assert.equal(f.find((n) => n.className === 'rail-card-ramp').hidden, true);
view.destroy();
});
test('identical updates write nothing and status keeps its line through loading and empty text', () => {
let writes = 0;
const f = fixture(() => writes++);
const view = createWeatherPanel(f);
for (const entries of [
[wind, radar],
[{ ...wind, summary: { ...wind.summary, status: 'Loading' } }],
[wind],
[],
]) {
view.update(entries);
const status = entries.length ? line(f, 'wind', 'status') : null;
writes = 0;
view.update(entries);
assert.equal(writes, 0);
if (status) {
assert.equal(status.hidden, false);
assert.equal(line(f, 'wind', 'status'), status);
}
}
view.destroy();
assert.equal(f.container.children.length, 0);
assert.equal(createWeatherPanel(), null);
});
test('count, hidden-empty and first-appearance expansion survive remount and owner collapse', () => {
const f = fixture();
let view = createWeatherPanel(f);
view.update([]);
assert.equal(f.panel.hidden, true);
assert.equal(f.clicks(), 0);
view.update([wind, radar, { id: 'missing' }]);
assert.equal(f.count.textContent, '2');
assert.equal(f.panel.hidden, false);
assert.equal(f.clicks(), 1);
f.collapse.click();
view.update([]);
view.update([wind]);
assert.equal(f.panel.classList.contains('collapsed'), true);
assert.equal(f.clicks(), 2);
assert.equal(f.count.textContent, '1');
view.destroy();
assert.equal(f.count.textContent, '0');
assert.equal(f.panel.hidden, true);
view = createWeatherPanel(f);
view.update([radar]);
assert.equal(f.panel.hidden, false);
assert.equal(f.clicks(), 2);
view.destroy();
const expanded = fixture();
expanded.collapse.click();
const other = createWeatherPanel(expanded);
other.update([wind]);
assert.equal(expanded.clicks(), 1);
other.destroy();
});
test('a stored or shared collapse choice is not overridden on first appearance', () => {
for (const preference of ['stored', 'share']) {
const f = fixture();
f.panel.dataset = { collapsedPreference: preference };
const view = createWeatherPanel(f);
view.update([radar]);
assert.equal(f.panel.hidden, false);
assert.equal(
f.clicks(),
0,
`${preference} preference keeps the panel collapsed`,
);
view.destroy();
}
const f = fixture();
f.panel.dataset = { collapsedPreference: 'default' };
const view = createWeatherPanel(f);
view.update([radar]);
assert.equal(f.clicks(), 1, 'a default state still expands once');
view.destroy();
});
test('settings, reading and footer actions pass the layer id, params and user origin', async () => {
const { windReadingResult } = await import('../layers/wind/presentation.js');
const { createWindLayer } = await import('../layers/wind/index.js');
const layer = createWindLayer({ feed: { getSnapshot: async () => null } });
const action = layer.getRowControls().summary.actions[0];
assert.equal(action.label, 'Read wind at map center');
assert.equal(action.hint, undefined);
const f = fixture();
const calls = [];
const view = createWeatherPanel({
...f,
setLayerParams: (...args) => calls.push(args),
});
const reading = {
coordinates: '26.59°N · 123.34°W',
wind: '6.0 m/s from N',
model: 'ECMWF',
validTime: '2026-09-22 06:00 UTC',
scalarLabel: 'Wind speed',
scalarValue: '6.0 m/s',
explanation: 'Interpolated model forecast.',
};
const summary = {
...wind.summary,
reading,
result: windReadingResult(reading),
settings: [
{
id: 'units',
label: 'UNITS',
chips: [{ id: 'units-mph', label: 'mph', params: { units: 'mph' } }],
},
],
actions: [{ ...action, disabled: false }],
};
view.update([{ ...wind, summary }]);
f.find((n) => n.dataset.actionId === 'read-wind').click();
f.find((n) => n.dataset.chipId === 'units-mph').click();
f.find((n) => n.dataset.actionId === 'clear').click();
assert.deepEqual(calls, [
['wind', { inspect: true }, { origin: 'user' }],
['wind', { units: 'mph' }, { origin: 'user' }],
['wind', { inspect: false }, { origin: 'user' }],
]);
const result = f.find((n) => n.className === 'rail-card-result');
const header = result.children[0];
assert.equal(header.className, 'rail-card-result-header');
assert.equal(header.children[0].textContent, 'WIND AT 26.59°N 123.34°W');
assert.equal(header.children[1].textContent, '×');
assert.equal(header.children[1].getAttribute('aria-label'), 'Clear reading');
assert.deepEqual(
result.children[1].children[0].children.map((n) => [
n.dataset.lineId,
n.textContent,
]),
[
['wind', '6.0 m/s from N'],
['meta', 'ECMWF · valid 09-22 06:00 UTC'],
['scalar', 'Wind speed · 6.0 m/s'],
['explanation', 'Interpolated model forecast.'],
],
);
assert.equal(
f.find((n) => n.dataset.actionId === 'read-wind').textContent,
'Read wind at map center',
);
assert.equal(
f.find((n) => n.className === 'rail-card-action-hint').hidden,
true,
);
layer.destroy();
const body = card(f, 'wind').children[1];
assert.deepEqual(
body.children.map((n) => n.dataset.blockId),
['details', 'settings', 'actions', 'reading'],
);
view.destroy();
});
const cyclone = {
id: 'weather-cyclones',
icon: '◉',
summary: {
label: 'Cyclones',
compact: '3 active storms · Fay selected',
detail: 'Fay · Advisory 3',
},
list: {
items: [
{
id: 'fay',
text: 'Fay · Tropical storm · 40 kt',
active: true,
params: { stormId: 'fay', focus: true },
},
],
},
};
const satellite = {
id: 'weather-satellite',
icon: '☁',
summary: { label: 'Satellite clouds' },
};
const lightning = {
id: 'weather-lightning',
icon: 'ϟ',
summary: { label: 'Lightning density' },
};
const opened = (f) =>
[cyclone, wind, radar, satellite, lightning]
.filter(({ id }) => card(f, id)?.dataset.open === 'true')
.map(({ id }) => id);
const clickHeader = (f, id) => card(f, id).children[0].click();
test('cyclones lead, and observed history owns one bordered group with only active products in scope', () => {
const f = fixture();
const view = createWeatherPanel(f);
view.update([lightning, radar, wind, cyclone, satellite]);
const root = f.container.children[0];
assert.deepEqual(
root.children[0].children.map((n) => n.dataset.cardId),
['weather-cyclones', 'wind'],
);
const group = f.find((n) => n.className === 'weather-observed-group');
assert.equal(root.children[1], group);
assert.equal(group.children[0].textContent, 'Observed history');
assert.equal(
group.children[1].textContent,
'Rain radar · Satellite clouds · Lightning density',
);
assert.equal(group.children[2], timelineHost(f));
assert.deepEqual(
group.children[3].children.map((n) => n.dataset.cardId),
['weather-radar', 'weather-satellite', 'weather-lightning'],
);
assert.deepEqual(opened(f), ['weather-cyclones']);
assert.equal(
card(f, cyclone.id).children[1].children[0].dataset.blockId,
'storms',
);
view.update([wind, satellite]);
assert.equal(group.children[1].textContent, 'Satellite clouds');
view.update([wind]);
assert.equal(group.hidden, true);
view.destroy();
});
test('accordion changes only on headers, newly enabled layers and loss of the open layer', () => {
const f = fixture();
const calls = [];
let view = createWeatherPanel({
...f,
setLayerParams: (...args) => calls.push(args),
});
view.update([wind, cyclone, radar]);
assert.deepEqual(opened(f), [cyclone.id]);
clickHeader(f, wind.id);
clickHeader(f, wind.id);
assert.deepEqual(opened(f), [wind.id]);
assert.equal(
card(f, wind.id).children[0].getAttribute('aria-expanded'),
'true',
);
assert.equal(
card(f, cyclone.id).children[0].getAttribute('aria-expanded'),
'false',
);
assert.equal(card(f, cyclone.id).children[1].hidden, true);
assert.deepEqual(calls, [], 'opening/collapsing never selects a storm');
f.state({ mode: 'history', target: ticks[0] });
view.update([
wind,
{ ...cyclone, summary: { ...cyclone.summary, status: 'Updated' } },
radar,
]);
assert.deepEqual(opened(f), [wind.id]);
view.update([wind, cyclone, radar, lightning, satellite]);
assert.deepEqual(opened(f), [satellite.id], 'last newly enabled entry wins');
view.update([wind, cyclone, radar, lightning]);
assert.deepEqual(
opened(f),
[cyclone.id],
'disabled open layer falls back to first in card order',
);
clickHeader(f, radar.id);
view.destroy();
view = createWeatherPanel(f);
view.update([]);
view.update([wind, cyclone, radar]);
assert.deepEqual(opened(f), [radar.id], 'explicit choice survives remount');
view.update([]);
view.update([wind]);
assert.deepEqual(opened(f), [wind.id]);
view.destroy();
});
test('first appearance without storms chooses the first card and late storm arrival never steals it', () => {
const f = fixture();
const view = createWeatherPanel(f);
view.update([satellite, wind]);
assert.deepEqual(opened(f), [wind.id]);
view.update([satellite, wind, { ...cyclone, list: { items: [] } }]);
assert.deepEqual(opened(f), [cyclone.id]);
clickHeader(f, wind.id);
view.update([satellite, wind, cyclone]);
assert.deepEqual(opened(f), [wind.id]);
view.destroy();
});
test('compact lines show observation age, forecast validity, storm selection or accented status', (t) => {
t.mock.timers.enable({
apis: ['Date'],
now: Date.parse('2026-09-22T01:13:00Z'),
});
const f = fixture();
const view = createWeatherPanel(f);
view.update([cyclone, wind, radar]);
const compact = (id) =>
f.find((n) => n.className.startsWith('rail-card-compact'), card(f, id));
assert.equal(compact(radar.id).textContent, '09-22 01:05 UTC · 8m ago');
assert.equal(
compact(wind.id).textContent,
'Forecast · valid 09-22 01:00 UTC',
);
f.state({ mode: 'history', target: ticks[1] });
assert.equal(
compact(radar.id).textContent,
'09-22 01:05 UTC · 8m ago · synced',
);
assert.equal(
compact(wind.id).textContent,
'Forecast · valid 09-22 01:00 UTC',
);
assert.match(
line(f, wind.id, 'time').textContent,
/issued .*Does not follow history/,
);
f.state({
products: [{ id: radar.id, shown: ticks[0], selected: ticks[0] }],
});
assert.equal(
compact(radar.id).textContent,
'09-22 01:00 UTC · 13m ago · nearest',
);
clickHeader(f, wind.id);
assert.equal(
compact(cyclone.id).textContent,
'3 active storms · Fay selected',
);
view.update([
cyclone,
wind,
{
...radar,
summary: { ...radar.summary, status: 'Map center outside coverage' },
},
]);
assert.equal(compact(radar.id).textContent, 'Map center outside coverage');
assert.match(compact(radar.id).className, /status/);
view.destroy();
});
test('all five headers retain descriptor icons and disclosure state when opened or compact', () => {
const f = fixture();
const view = createWeatherPanel(f);
const entries = [cyclone, wind, radar, satellite, lightning];
view.update(entries);
for (const active of entries) {
clickHeader(f, active.id);
for (const entry of entries) {
const article = card(f, entry.id);
const open = entry === active;
assert.equal(article.dataset.open, String(open));
assert.equal(article.classList.contains('is-open'), open);
const header = article.children[0];
const labels = header.children[0];
const heading = labels.children[0];
assert.equal(
labels.children[1].textContent,
entry.summary.coverage || '',
);
assert.equal(
labels.children[1].classList.contains('rail-card-badge'),
true,
);
assert.equal(header.children[1].className, 'rail-card-disclosure');
assert.equal(heading.children[0].className, 'data-icon');
assert.equal(heading.children[0].textContent, entry.icon);
assert.equal(heading.children[0].hidden, false);
assert.equal(heading.children[1].textContent, entry.summary.label);
assert.equal(
heading.children[1].getAttribute('title'),
entry.summary.label,
);
assert.equal(
heading.children[1].classList.contains('rail-card-nowrap'),
true,
);
assert.equal(header.children[1].textContent, open ? '▾' : '▸');
}
}
view.destroy();
});
test('a change of open card scrolls its header into view once; refreshes never move the body', () => {
const f = fixture();
const body = f.container;
const writes = [];
const intoView = [];
const rects = new Map();
let scrollTop = 240;
Object.defineProperty(body, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
writes.push(value);
scrollTop = value;
},
});
body.clientHeight = 200;
body.getBoundingClientRect = () => ({ top: 100, height: 200 });
const create = f.document.createElement;
f.document.createElement = (tag) => {
const node = create(tag);
node.getBoundingClientRect = function () {
return rects.get(this.dataset.cardId) || { top: 0, height: 0 };
};
node.scrollIntoView = function () {
intoView.push(this.dataset.cardId);
};
return node;
};
// Rectangles are viewport coordinates; the body's visible top is at 100.
const place = (id, top, height) => rects.set(id, { top: 100 + top, height });
const view = createWeatherPanel(f);
view.update([cyclone, wind, radar]);
assert.deepEqual(opened(f), [cyclone.id]);
// Plain refreshes: clock, identical and changed descriptors, the open header.
f.state({ mode: 'history', target: ticks[0] });
view.update([cyclone, wind, radar]);
view.update([
{ ...cyclone, summary: { ...cyclone.summary, status: 'Updated' } },
wind,
radar,
]);
clickHeader(f, cyclone.id);
assert.deepEqual(writes, [], 'no scroll writes without a change of card');
// Opened below the fold: the smallest move that shows the whole card.
place(radar.id, 150, 120);
clickHeader(f, radar.id);
assert.deepEqual(opened(f), [radar.id]);
assert.deepEqual(writes, [240 + 70], 'one write, placed by the opened card');
// Taller than the body: the header goes to the top.
place(wind.id, 40, 480);
clickHeader(f, wind.id);
assert.deepEqual(writes.slice(1), [310 + 40]);
// Header left above the view by the collapse: back to the top edge.
place(cyclone.id, -60, 120);
clickHeader(f, cyclone.id);
assert.deepEqual(writes.slice(2), [350 - 60]);
// Already fully visible: nothing to do.
place(radar.id, 20, 100);
clickHeader(f, radar.id);
assert.equal(writes.length, 3);
// A newly enabled layer opens and is revealed the same way.
place(lightning.id, 190, 60);
view.update([cyclone, wind, radar, lightning]);
assert.deepEqual(opened(f), [lightning.id]);
assert.deepEqual(writes.slice(3), [290 + 50]);
f.state({ mode: 'latest' });
view.update([cyclone, wind, radar, lightning]);
assert.equal(writes.length, 4, 'refresh after a reveal leaves it alone');
assert.deepEqual(intoView, [], 'ancestor scrollers are never moved');
view.destroy();
});