Merge upstream/main into pr/panel-resize

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-23 19:00:18 +01:00
commit 11603729dc
234 files changed
+45360 -944

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';
@@ -231,6 +232,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,
@@ -892,6 +898,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]);
});
+293
View File
@@ -0,0 +1,293 @@
/**
* Box selection for the Recent Imagery layer: press on the world, drag, and
* release to hand the layer a degrees box. Follows the draw tool's ownership
* rules — the pointer is claimed for the session, the viewer's own click
* actions are borrowed and given back, and `destroy()` returns everything —
* and freezes the camera only while a drag is in progress.
*/
import * as Cesium from 'cesium';
import { pickWorldFromScreen } from '../annotations/annotationResolver.js';
import {
claimPointer,
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import { validateBox } from '../layers/recentImagery/model.js';
/** The id this tool claims the pointer under. */
export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box';
/**
* Wire the box tool.
* @param {{ viewer: object, onBox: (box: object) => void, onCancel?: (reason: string, message?: string, box?: object) => void, onActive?: (active: boolean) => void, onEscape?: () => boolean, cesium?: object, pickWorld?: Function, documentRef?: object }} deps
* `onEscape` runs before the tool cancels on Escape; returning `true` claims
* the key (it is still consumed) and leaves the tool armed.
* @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => Promise<void> } | null}
*/
export function initImageryBoxTool({
viewer,
onBox,
onCancel,
onActive,
onEscape,
cesium = Cesium,
pickWorld = pickWorldFromScreen,
documentRef = globalThis.document,
} = {}) {
if (!viewer?.scene || typeof onBox !== 'function') return null;
let active = false;
let destroyed = false;
/** Drag corners in degrees; `anchor` is null while no drag is live. */
let anchor = null;
let corner = null;
let handler = null;
let lease = null;
let savedSingleClick = null;
let savedDoubleClick = null;
let savedCameraInputs = null;
const dragBox = () =>
anchor && {
west: Math.min(anchor.lon, corner.lon),
south: Math.min(anchor.lat, corner.lat),
east: Math.max(anchor.lon, corner.lon),
north: Math.max(anchor.lat, corner.lat),
};
const dataSource = new cesium.CustomDataSource(
'gev-recent-imagery-box-preview',
);
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
() => null,
);
const stroke = cesium.Color.fromCssColorString('#8be9ff');
const previewRectangle = () => {
const box = dragBox();
return box
? cesium.Rectangle.fromDegrees(box.west, box.south, box.east, box.north)
: undefined;
};
const previewOutline = () => {
const box = dragBox();
if (!box) return [];
return cesium.Cartesian3.fromDegreesArray([
box.west,
box.south,
box.east,
box.south,
box.east,
box.north,
box.west,
box.north,
box.west,
box.south,
]);
};
const fill = dataSource.entities.add({
show: false,
rectangle: {
coordinates: new cesium.CallbackProperty(previewRectangle, false),
material: stroke.withAlpha(0.12),
classificationType: cesium.ClassificationType.BOTH,
},
});
// A rectangle graphic cannot draw a dashed edge on the ground, so the
// outline is its own draped polyline traced round the same corners.
const outline = dataSource.entities.add({
show: false,
polyline: {
positions: new cesium.CallbackProperty(previewOutline, false),
width: 2,
clampToGround: true,
classificationType: cesium.ClassificationType.BOTH,
material: new cesium.PolylineDashMaterialProperty({
color: stroke.withAlpha(0.95),
dashLength: 12,
}),
},
});
const requestRender = () => viewer.scene.requestRender?.();
const setPreview = (visible) => {
fill.show = visible;
outline.show = visible;
requestRender();
};
const worldAt = (position) => {
if (!position) return null;
const canvas = viewer.scene.canvas;
const w = canvas?.clientWidth || canvas?.width || 1;
const h = canvas?.clientHeight || canvas?.height || 1;
const point = pickWorld(viewer, position.x / w, position.y / h);
const lon = Number(point?.lon);
const lat = Number(point?.lat);
return Number.isFinite(lon) && Number.isFinite(lat) ? { lon, lat } : null;
};
const cameraController = () => viewer.scene.screenSpaceCameraController;
function freezeCamera() {
const controller = cameraController();
if (!controller || savedCameraInputs !== null) return;
savedCameraInputs = controller.enableInputs;
controller.enableInputs = false;
}
function thawCamera() {
const controller = cameraController();
if (controller && savedCameraInputs !== null)
controller.enableInputs = savedCameraInputs;
savedCameraInputs = null;
}
function endDrag() {
anchor = null;
corner = null;
thawCamera();
setPreview(false);
}
const onDown = (event) => {
if (!active || destroyed || anchor) return;
const point = worldAt(event.position);
if (!point) {
onCancel?.('sky', 'Press on the ground, not the sky');
return;
}
anchor = point;
corner = point;
freezeCamera();
setPreview(true);
};
const onMove = (event) => {
const point = anchor && !destroyed && worldAt(event.endPosition);
if (!point) return;
corner = point;
requestRender();
};
const onUp = (event) => {
if (!anchor || destroyed) return;
corner = worldAt(event.position) || corner;
const box = dragBox();
const result = validateBox(box);
if (result.reason === 'degenerate') result.message = 'Drag to size the box';
endDrag();
setActive(false);
if (result.ok) onBox(result.box);
else onCancel?.(result.reason, result.message, box);
};
// Escape is taken from a document capture listener, ahead of every other
// handler, so the interceptor runs first: when it claims the key (the
// panel exits a live comparison), the tool stays armed and the key still
// goes no further; otherwise Escape cancels the session.
const onKey = (event) => {
if (!active || destroyed || event.key !== 'Escape') return;
event.preventDefault?.();
event.stopImmediatePropagation?.();
if (onEscape?.() === true) return;
cancel('escape');
};
function bindScene() {
if (handler) return;
handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(onDown, cesium.ScreenSpaceEventType.LEFT_DOWN);
handler.setInputAction(onMove, cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction(onUp, cesium.ScreenSpaceEventType.LEFT_UP);
const stock = viewer.screenSpaceEventHandler;
if (stock) {
savedSingleClick =
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
savedDoubleClick =
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null;
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK);
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
}
documentRef?.addEventListener?.('keydown', onKey, true);
documentRef?.body?.classList?.add?.('gev-imagery-box');
}
function releaseScene() {
if (handler) {
handler.destroy();
handler = null;
}
const stock = viewer.screenSpaceEventHandler;
if (stock && savedSingleClick) {
stock.setInputAction(
savedSingleClick,
cesium.ScreenSpaceEventType.LEFT_CLICK,
);
}
if (stock && savedDoubleClick) {
stock.setInputAction(
savedDoubleClick,
cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
}
savedSingleClick = null;
savedDoubleClick = null;
documentRef?.removeEventListener?.('keydown', onKey, true);
documentRef?.body?.classList?.remove?.('gev-imagery-box');
}
function setActive(next) {
if (next === active) return active;
if (next) {
lease = claimPointer(IMAGERY_BOX_POINTER_OWNER);
if (!lease) {
onCancel?.(
'pointer-busy',
`${pointerOwner()} is using the pointer — close it first`,
);
return active;
}
active = true;
bindScene();
} else {
if (anchor) endDrag();
releaseScene();
releasePointer(lease);
lease = null;
active = false;
}
onActive?.(active);
return active;
}
function cancel(reason = 'cancel') {
if (!active || destroyed) return;
setActive(false);
onCancel?.(reason);
}
return {
/** Begin a box session. False when the pointer is held elsewhere. */
start() {
return !destroyed && setActive(true);
},
cancel,
isActive() {
return active;
},
/** Returns everything; resolves once the preview is detached. */
destroy() {
if (destroyed) return attaching;
if (active) setActive(false);
destroyed = true;
releaseScene();
releasePointer(lease);
lease = null;
thawCamera();
dataSource.entities.removeAll();
attaching = attaching.then(() => {
try {
viewer.dataSources.remove(dataSource, true);
} catch {
/* viewer already disposed */
}
});
return attaching;
},
};
}
+154
View File
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
claimPointer,
pointerOwner,
releasePointer,
resetPointerOwnership,
} from '../data/inputOwnership.js';
import {
IMAGERY_BOX_POINTER_OWNER,
initImageryBoxTool,
} from './imageryBoxTool.js';
import { boxToolFakes } from '../layers/recentImagery/testDoubles.mjs';
function fixture(options = {}) {
resetPointerOwnership();
const fakes = boxToolFakes();
const boxes = [];
const cancels = [];
const actives = [];
const tool = initImageryBoxTool({
viewer: fakes.viewer,
cesium: fakes.cesium,
pickWorld: fakes.pickWorld,
documentRef: fakes.documentRef,
onBox: (box) => boxes.push(box),
onCancel: (reason, message) => cancels.push([reason, message]),
onActive: (active) => actives.push(active),
...options,
});
const camera = fakes.viewer.scene.screenSpaceCameraController;
const stockAction = (type) =>
fakes.viewer.screenSpaceEventHandler.getInputAction(fakes.types[type]);
const preview = () =>
fakes.viewer.dataSources.sources[0]?.entities.values.every((e) => e.show);
return {
...fakes,
tool,
boxes,
cancels,
actives,
camera,
stockAction,
preview,
};
}
test('a session claims the pointer, borrows the stock clicks, freezes the camera only mid-drag and returns everything with the box', async () => {
const f = fixture();
assert.equal(f.tool.start(), true);
assert.equal(pointerOwner(), IMAGERY_BOX_POINTER_OWNER);
assert.equal(f.stockAction('LEFT_CLICK'), undefined);
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), undefined);
assert.ok(f.documentRef.classes.has('gev-imagery-box'));
assert.equal(f.camera.enableInputs, true, 'not frozen before the press');
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
assert.equal(f.camera.enableInputs, false);
assert.equal(f.preview(), true);
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 250 } });
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 10 } }); // sky: ignored
f.fire('LEFT_UP', { position: { x: 500, y: 250 } });
assert.equal(f.boxes.length, 1);
const { west, south, east, north } = f.boxes[0];
assert.deepEqual(
[west, south, east, north].map((v) => Math.round(v * 1e6) / 1e6),
[-97.85, 30.3, -97.8, 30.35],
);
assert.deepEqual(f.cancels, []);
assert.equal(f.tool.isActive(), false);
assert.equal(pointerOwner(), null);
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), f.originalDouble);
assert.equal(f.camera.enableInputs, true);
assert.equal(f.preview(), false);
assert.equal(f.documentRef.classes.has('gev-imagery-box'), false);
assert.equal(f.documentRef.listeners.length, 0);
assert.equal(f.handler(), null);
assert.deepEqual(f.actives, [true, false]);
await f.tool.destroy();
});
test('a sky press, a click without a drag and Escape each say why', async () => {
const f = fixture();
f.tool.start();
f.fire('LEFT_DOWN', { position: { x: 250, y: 10 } });
assert.deepEqual(f.cancels.at(-1), [
'sky',
'Press on the ground, not the sky',
]);
assert.equal(f.tool.isActive(), true, 'a sky press keeps the session');
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
f.fire('LEFT_UP', { position: { x: 250, y: 125 } });
assert.deepEqual(f.cancels.at(-1), ['degenerate', 'Drag to size the box']);
f.tool.start();
assert.equal(f.key('Escape').prevented, true);
assert.deepEqual(f.cancels.at(-1), ['escape', undefined]);
assert.equal(pointerOwner(), null);
await f.tool.destroy();
});
test('an Escape interceptor that claims the key leaves the tool armed; the next Escape cancels', async () => {
let claim = true;
let asked = 0;
const f = fixture({
onEscape: () => {
asked += 1;
return claim;
},
});
f.tool.start();
const first = f.key('Escape');
assert.equal(asked, 1);
assert.equal(
first.prevented && first.stopped,
true,
'the key goes no further',
);
assert.equal(f.tool.isActive(), true);
claim = false;
f.key('Escape');
assert.deepEqual(f.cancels, [['escape', undefined]]);
f.tool.start();
f.key('Enter');
assert.equal(asked, 2, 'other keys never reach the interceptor');
await f.tool.destroy();
});
test('a held pointer refuses the session and nothing is borrowed', async () => {
const f = fixture();
const lease = claimPointer('draw');
assert.equal(f.tool.start(), false);
assert.deepEqual(f.cancels, [
['pointer-busy', 'draw is using the pointer — close it first'],
]);
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
assert.equal(f.handler(), null);
releasePointer(lease);
assert.equal(f.tool.start(), true);
await f.tool.destroy();
});
test('destroy mid-drag restores the camera, releases the claim and detaches the preview', async () => {
const f = fixture();
f.tool.start();
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
assert.equal(f.viewer.dataSources.sources.length, 1);
await f.tool.destroy();
assert.equal(f.camera.enableInputs, true);
assert.equal(pointerOwner(), null);
assert.equal(f.viewer.dataSources.sources.length, 0);
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
assert.equal(f.tool.start(), false);
assert.equal(initImageryBoxTool({ viewer: null, onBox() {} }), null);
});
+176
View File
@@ -0,0 +1,176 @@
const HANDLE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']);
function clampUnit(value) {
return Math.max(0, Math.min(1, Number(value) || 0));
}
function defaultViewportWidth() {
return (
Number(document.documentElement?.clientWidth) ||
Number(globalThis.window?.innerWidth) ||
0
);
}
function defaultValueText(beforePercent, afterPercent) {
return `A ${beforePercent} percent, B ${afterPercent} percent`;
}
/**
* Split divider for before/after imagery comparison.
*
* Owns the DOM line + handle, pointer capture, keyboard, ARIA, a CSS custom
* property and scene.splitPosition. `setValue` is the owner's own write and
* never echoes through `onChange`; `onChange` reports pointer and keyboard
* changes so the owner can mirror them, and `requestRender` fires whenever
* the clamped value actually changes. `destroy` is idempotent: it removes the
* listeners and line, clears the CSS property and restores the scene split
* captured at creation once.
*/
export function createImagerySplit({
scene,
parent = document.body,
initialValue = 0.5,
id,
handleClass,
cssTarget = document.documentElement,
cssProperty,
beforeLabel = 'A',
afterLabel = 'B',
beforeTitle,
afterTitle,
ariaLabel,
formatValueText = defaultValueText,
getViewportWidth = defaultViewportWidth,
onChange,
requestRender,
} = {}) {
const restoreValue = scene ? (scene.splitPosition ?? null) : null;
const line = document.createElement('div');
if (id) line.id = id;
const before = document.createElement('span');
const after = document.createElement('span');
const handle = document.createElement('button');
before.className = 'before';
after.className = 'after';
before.textContent = beforeLabel;
if (beforeTitle != null) before.title = beforeTitle;
after.textContent = afterLabel;
if (afterTitle != null) after.title = afterTitle;
handle.type = 'button';
if (handleClass) handle.className = handleClass;
handle.setAttribute('role', 'slider');
if (ariaLabel != null) handle.setAttribute('aria-label', ariaLabel);
handle.setAttribute('aria-valuemin', '0');
handle.setAttribute('aria-valuemax', '100');
handle.setAttribute('aria-orientation', 'horizontal');
handle.textContent = '↔';
let value = null;
let cssValue = null;
let dragging = false;
let pointerId = null;
let destroyed = false;
function apply(next, { notify = false, render = true } = {}) {
const clamped = clampUnit(next);
const changed = clamped !== value;
value = clamped;
if (scene && scene.splitPosition !== value) scene.splitPosition = value;
const css = `${value * 100}%`;
if (cssProperty && cssValue !== css) {
cssTarget.style.setProperty(cssProperty, css);
cssValue = css;
}
const beforePercent = Math.round(value * 100);
handle.setAttribute('aria-valuenow', String(beforePercent));
handle.setAttribute(
'aria-valuetext',
formatValueText(beforePercent, 100 - beforePercent),
);
if (changed && render) requestRender?.();
if (changed && notify) onChange?.(value);
return changed;
}
function setFromClientX(clientX) {
const viewportWidth = Number(getViewportWidth()) || 0;
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
apply(Number(clientX) / viewportWidth, { notify: true });
}
function handlePointerDown(event) {
if (event.button != null && event.button !== 0) return;
event.preventDefault?.();
dragging = true;
pointerId = event.pointerId ?? null;
handle.setPointerCapture?.(event.pointerId);
setFromClientX(event.clientX);
}
function handlePointerMove(event) {
if (!dragging) return;
if (pointerId != null && event.pointerId !== pointerId) return;
event.preventDefault?.();
setFromClientX(event.clientX);
}
function finishPointerDrag(event) {
if (!dragging) return;
if (pointerId != null && event.pointerId !== pointerId) return;
handle.releasePointerCapture?.(event.pointerId);
dragging = false;
pointerId = null;
}
function handleKeyDown(event) {
if (!HANDLE_KEYS.has(event.key)) return;
event.preventDefault?.();
const step = event.shiftKey ? 0.05 : 0.01;
if (event.key === 'Home') apply(0, { notify: true });
else if (event.key === 'End') apply(1, { notify: true });
else
apply(value + (event.key === 'ArrowRight' ? step : -step), {
notify: true,
});
}
const listeners = [
['pointerdown', handlePointerDown],
['pointermove', handlePointerMove],
['pointerup', finishPointerDrag],
['pointercancel', finishPointerDrag],
['keydown', handleKeyDown],
];
for (const [type, listener] of listeners)
handle.addEventListener(type, listener);
line.append(before, handle, after);
parent.appendChild(line);
apply(initialValue, { render: false });
return {
getValue() {
return value;
},
setValue(next) {
if (destroyed) return false;
return apply(next);
},
setVisible(visible) {
line.hidden = !visible;
},
destroy() {
if (destroyed) return;
destroyed = true;
if (dragging && pointerId != null)
handle.releasePointerCapture?.(pointerId);
dragging = false;
pointerId = null;
for (const [type, listener] of listeners)
handle.removeEventListener?.(type, listener);
line.remove();
if (cssProperty) cssTarget.style.removeProperty(cssProperty);
if (scene && restoreValue != null) scene.splitPosition = restoreValue;
},
};
}
+307
View File
@@ -0,0 +1,307 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createImagerySplit } from './imagerySplit.js';
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
this.removed = false;
this.captured = [];
this.released = [];
this._attributes = new Map();
this._listeners = new Map();
}
setAttribute(name, value) {
this._attributes.set(name, value);
}
getAttribute(name) {
return this._attributes.get(name) ?? null;
}
addEventListener(type, handler) {
this._listeners.set(type, handler);
}
removeEventListener(type, handler) {
if (this._listeners.get(type) === handler) this._listeners.delete(type);
}
dispatch(type, event = {}) {
return this._listeners.get(type)?.(event);
}
setPointerCapture(id) {
this.captured.push(id);
}
releasePointerCapture(id) {
this.released.push(id);
}
append(...children) {
this.children.push(...children);
}
appendChild(child) {
this.children.push(child);
return child;
}
remove() {
this.removed = true;
}
}
function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) {
const body = new FakeElement('body');
const cssCalls = [];
const changes = [];
let renders = 0;
const scene = { splitPosition };
const cssTarget = {
style: {
setProperty(name, value) {
cssCalls.push(['set', name, value]);
},
removeProperty(name) {
cssCalls.push(['remove', name]);
},
},
};
const previous = globalThis.document;
globalThis.document = {
body,
createElement: (tagName) => new FakeElement(tagName),
documentElement: cssTarget,
};
const split = createImagerySplit({
scene,
parent: body,
initialValue,
id: 'split-line',
handleClass: 'split-handle',
cssTarget,
cssProperty: '--split',
beforeTitle: 'Before',
afterTitle: 'After',
ariaLabel: 'Divider',
getViewportWidth: () => 1000,
onChange: (value) => changes.push(value),
requestRender: () => {
renders += 1;
},
});
globalThis.document = previous;
const line = body.children[0];
const handle = line.children.find((child) => child.tagName === 'button');
return {
split,
scene,
body,
line,
handle,
cssCalls,
changes,
renders: () => renders,
};
}
test('the split builds its line and applies the initial value without notifying', () => {
const env = fixture({ initialValue: 0.62 });
assert.equal(env.line.id, 'split-line');
assert.equal(env.line.children[0].textContent, 'A');
assert.equal(env.line.children[0].title, 'Before');
assert.equal(env.line.children[2].textContent, 'B');
assert.equal(env.line.children[2].title, 'After');
assert.equal(env.handle.className, 'split-handle');
assert.equal(env.handle.getAttribute('role'), 'slider');
assert.equal(env.handle.getAttribute('aria-label'), 'Divider');
assert.equal(env.handle.getAttribute('aria-valuenow'), '62');
assert.equal(
env.handle.getAttribute('aria-valuetext'),
'A 62 percent, B 38 percent',
);
assert.equal(env.scene.splitPosition, 0.62);
assert.deepEqual(env.cssCalls, [['set', '--split', '62%']]);
assert.deepEqual(env.changes, []);
assert.equal(env.renders(), 0);
assert.equal(env.split.getValue(), 0.62);
});
test('keyboard steps 1 percent, 5 percent with Shift, and jumps to Home and End', () => {
const env = fixture();
const key = (key, shiftKey = false) => {
let prevented = false;
env.handle.dispatch('keydown', {
key,
shiftKey,
preventDefault() {
prevented = true;
},
});
return prevented;
};
assert.equal(key('ArrowRight'), true);
assert.equal(env.scene.splitPosition, 0.51);
assert.equal(key('ArrowLeft', true), true);
assert.equal(env.scene.splitPosition, 0.46);
assert.equal(key('Home'), true);
assert.equal(env.scene.splitPosition, 0);
assert.equal(env.handle.getAttribute('aria-valuenow'), '0');
assert.equal(key('End'), true);
assert.equal(env.scene.splitPosition, 1);
assert.equal(env.handle.getAttribute('aria-valuenow'), '100');
assert.equal(key('Enter'), false, 'other keys are ignored and not prevented');
assert.equal(env.scene.splitPosition, 1);
assert.deepEqual(env.changes, [0.51, 0.46, 0, 1]);
});
test('values clamp to the unit range and only real changes notify or render', () => {
const env = fixture();
assert.equal(env.split.setValue(1.4), true);
assert.equal(env.split.getValue(), 1);
assert.equal(env.scene.splitPosition, 1);
assert.equal(env.split.setValue(1), false);
assert.equal(env.split.setValue(-3), true);
assert.equal(env.split.getValue(), 0);
assert.equal(
env.split.setValue('nonsense'),
false,
'non-numeric input clamps to 0',
);
assert.equal(env.renders(), 2, 'render only when the clamped value moved');
assert.deepEqual(
env.changes,
[],
'setValue is the owner writing; it never echoes',
);
env.handle.dispatch('keydown', { key: 'Home', preventDefault() {} });
env.handle.dispatch('keydown', { key: 'ArrowLeft', preventDefault() {} });
assert.deepEqual(
env.changes,
[],
'interaction that cannot move the value stays silent',
);
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
assert.deepEqual(env.changes, [1]);
assert.equal(env.renders(), 3);
assert.deepEqual(env.cssCalls, [
['set', '--split', '50%'],
['set', '--split', '100%'],
['set', '--split', '0%'],
['set', '--split', '100%'],
]);
});
test('pointer drags follow the primary pointer only', () => {
const env = fixture();
env.handle.dispatch('pointerdown', {
button: 2,
clientX: 900,
pointerId: 1,
preventDefault() {},
});
assert.equal(
env.scene.splitPosition,
0.5,
'non-primary buttons never start a drag',
);
env.handle.dispatch('pointermove', {
clientX: 900,
pointerId: 1,
preventDefault() {},
});
assert.equal(
env.scene.splitPosition,
0.5,
'moves without a drag are ignored',
);
env.handle.dispatch('pointerdown', {
button: 0,
clientX: 250,
pointerId: 4,
preventDefault() {},
});
assert.equal(env.scene.splitPosition, 0.25);
assert.deepEqual(env.handle.captured, [4]);
env.handle.dispatch('pointermove', {
clientX: 900,
pointerId: 9,
preventDefault() {},
});
assert.equal(
env.scene.splitPosition,
0.25,
'a different pointer id is ignored',
);
env.handle.dispatch('pointerup', { pointerId: 9 });
env.handle.dispatch('pointermove', {
clientX: 750,
pointerId: 4,
preventDefault() {},
});
assert.equal(
env.scene.splitPosition,
0.75,
'the drag survives a foreign pointerup',
);
env.handle.dispatch('pointermove', {
clientX: Number.NaN,
pointerId: 4,
preventDefault() {},
});
assert.equal(
env.scene.splitPosition,
0.75,
'unusable coordinates are ignored',
);
env.handle.dispatch('pointerup', { pointerId: 4 });
assert.deepEqual(env.handle.released, [4]);
env.handle.dispatch('pointermove', {
clientX: 100,
pointerId: 4,
preventDefault() {},
});
assert.equal(env.scene.splitPosition, 0.75, 'the drag ended');
assert.deepEqual(env.changes, [0.25, 0.75]);
});
test('visibility toggles the line without touching the value', () => {
const env = fixture();
env.split.setVisible(false);
assert.equal(env.line.hidden, true);
env.split.setVisible(true);
assert.equal(env.line.hidden, false);
assert.equal(env.split.getValue(), 0.5);
});
test('destroy removes the line, clears the CSS property, restores the scene split once, and is idempotent', () => {
const env = fixture({ initialValue: 0.8, splitPosition: 0.37 });
env.handle.dispatch('pointerdown', {
button: 0,
clientX: 100,
pointerId: 2,
preventDefault() {},
});
assert.equal(env.scene.splitPosition, 0.1);
env.split.destroy();
assert.equal(env.line.removed, true);
assert.deepEqual(
env.handle.released,
[2],
'an in-flight drag releases its capture',
);
assert.equal(env.cssCalls.at(-1)[0], 'remove');
assert.equal(env.scene.splitPosition, 0.37);
assert.equal(env.handle._listeners.size, 0);
env.scene.splitPosition = 0.9;
env.split.destroy();
assert.equal(
env.scene.splitPosition,
0.9,
'a second destroy does not restore again',
);
assert.equal(env.cssCalls.filter(([kind]) => kind === 'remove').length, 1);
assert.equal(
env.split.setValue(0.2),
false,
'a destroyed split ignores writes',
);
assert.equal(env.scene.splitPosition, 0.9);
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
assert.deepEqual(env.changes, [0.1], 'listeners are gone');
});
+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();
}
}
+194 -140
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',
@@ -28,11 +31,12 @@ const PANEL_GROUPS = [
},
{
label: 'Cameras',
ids: ['cctv', 'alpr-cameras'],
ids: ['cctv', 'recent-imagery'],
},
{
label: 'Infrastructure',
ids: [
'alpr-cameras',
'military-installations',
'local-datacenters',
'telegeography-submarine-cables',
@@ -43,6 +47,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 +112,9 @@ export class LayerPanel {
hasRowControls,
subscribeRowControls,
onHiddenRefresh = () => {},
weatherClock,
}) {
this.weatherClock = weatherClock;
this.getAll = getLayers;
this.isEnabled = isEnabled;
this.setEnabled = setEnabled;
@@ -110,25 +126,63 @@ export class LayerPanel {
this._generation = 0;
this._removers = [];
this._destroyed = false;
this._cancelRowControlsRefresh = null;
this._recentImageryFactory = null;
this._recentImageryPanel = 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._mountRecentImagery();
this._renderToggles();
}
/**
* Host the Recent Imagery readout in its rail body, like the weather
* readout. The application supplies the factory once the layer, viewer and
* box tool exist; a remount rebuilds the readout in place.
* @param {((container: HTMLElement) => { destroy: () => void } | null) | null} factory
*/
attachRecentImagery(factory) {
if (this._destroyed) return;
this._recentImageryFactory = typeof factory === 'function' ? factory : null;
this._mountRecentImagery();
}
_mountRecentImagery() {
this._recentImageryPanel?.destroy();
this._recentImageryPanel = null;
const container = this._toggleContainer?.ownerDocument?.getElementById?.(
'recent-imagery-panel-body',
);
if (container && this._recentImageryFactory)
this._recentImageryPanel = this._recentImageryFactory(container) || null;
}
_bind(element, type, listener) {
element.addEventListener(type, listener);
this._removers.push(() => element.removeEventListener(type, listener));
}
_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._recentImageryPanel?.destroy();
this._recentImageryPanel = null;
this._recentImageryFactory = null;
this._toggleContainer = null;
}
_renderToggles() {
@@ -210,7 +264,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 +285,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 +390,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 +399,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 +474,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 +512,7 @@ export class LayerPanel {
row.querySelector('.data-row-list'),
);
}
this._refreshWeatherPanel();
}
_buildMetaText(layer) {
+138
View File
@@ -89,3 +89,141 @@ 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;
}
});
test('the Recent Imagery readout mounts in its rail body like the weather readout and is rebuilt or released with the panel', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const { railFixture } = await import('./railTestFixture.mjs');
const f = railFixture();
const body = f.document.createElement('div');
f.document.getElementById = (id) =>
id === 'recent-imagery-panel-body' ? body : null;
const previousDocument = globalThis.document;
globalThis.document = f.document;
const mounted = [];
const factory = (container) => {
const readout = {
container,
destroyed: false,
destroy() {
this.destroyed = true;
},
};
mounted.push(readout);
return readout;
};
const panel = new LayerPanel({
getLayers: () => [],
isEnabled: () => false,
setEnabled() {},
setLayerParams() {},
hasRowControls: () => false,
subscribeRowControls() {},
getRowControls: () => null,
});
try {
panel.attachRecentImagery(factory);
assert.equal(mounted.length, 0, 'no body before the panel mounts');
panel.mount(f.container);
assert.equal(mounted.length, 1);
assert.equal(mounted[0].container, body);
panel.mount(f.container);
assert.equal(mounted[0].destroyed, true, 'a remount rebuilds it');
assert.equal(mounted.length, 2);
panel.attachRecentImagery(null);
assert.equal(mounted[1].destroyed, true);
panel.attachRecentImagery(factory);
assert.equal(mounted.length, 3);
panel.destroy();
assert.equal(mounted[2].destroyed, true);
panel.attachRecentImagery(factory);
assert.equal(mounted.length, 3, 'a destroyed panel mounts nothing');
} finally {
panel.destroy();
globalThis.document = previousDocument;
}
});
+6
View File
@@ -11,6 +11,8 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
{ id: 'location-bar', pinnable: true },
{ id: 'data-panel' },
{ id: 'cctv-panel' },
{ id: 'weather-panel' },
{ id: 'recent-imagery-panel' },
{ id: 'radio-panel' },
{ id: 'scene-panel' },
{ id: 'global-context-panel' },
@@ -21,6 +23,8 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
'data-panel',
'cctv-panel',
'weather-panel',
'recent-imagery-panel',
'scene-panel',
'pp-toggles',
'global-context-panel',
@@ -283,6 +287,8 @@ export class PanelChrome {
const isRightRail = [
'pp-toggles',
'cctv-panel',
'weather-panel',
'recent-imagery-panel',
'global-context-panel',
].includes(panelEl?.id);
const collapsed = panelEl.classList.contains('collapsed');
+15 -9
View File
@@ -94,6 +94,8 @@ 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._recentImageryPanel = document.getElementById('recent-imagery-panel');
this._sliderPanel = document.getElementById('param-slider-panel');
this._detectionBtn = document.getElementById('detection-toggle');
}
@@ -194,15 +196,17 @@ 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,
this._recentImageryPanel,
]) {
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 +226,8 @@ export class PanelLayoutController {
for (const panel of [
this._ppToggles,
this._cctvPanel,
this._weatherPanel,
this._recentImageryPanel,
globalContextPanel,
]) {
if (panel) this._rightStackResizeObserver.observe(panel);
+8
View File
@@ -202,6 +202,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);
}
+352
View File
@@ -1,3 +1,4 @@
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict';
import test from 'node:test';
import {
@@ -214,6 +215,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';
@@ -294,3 +323,326 @@ test('natural height includes visible content, margins and wrapper chrome, exclu
100,
);
});
// 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
// .panel-glow is absolutely positioned with a negative inset, so inside a
// scroll container that overhang is no longer harmless paint: it becomes
// 18–20px of scrollable overflow on both axes, drawing a horizontal scrollbar
// band under the expanded CCTV/Context/Data panel plus a vertical scrollbar
// that scrolls nothing but glow. The narrow block therefore pins every hosted
// glow to its panel box.
/** Strip comments and return the bodies of every ≤720px media block. */
function narrowScreenBlocks(css) {
const source = css.replace(/\/\*[\s\S]*?\*\//g, '');
const blocks = [];
let from = 0;
for (;;) {
const start = source.indexOf('@media (max-width: 720px) {', from);
if (start === -1) break;
const open = source.indexOf('{', start);
let depth = 0;
let close = -1;
for (let index = open; index < source.length; index += 1) {
if (source[index] === '{') depth += 1;
if (source[index] === '}' && (depth -= 1) === 0) {
close = index;
break;
}
}
assert.notEqual(close, -1, 'unterminated narrow-screen media block');
blocks.push(source.slice(open + 1, close));
from = close + 1;
}
assert.ok(blocks.length, 'narrow-screen media block is missing');
return blocks;
}
/** Split a flat (non-nested) rule list into [selectors, declarations] pairs. */
function flatRules(block) {
assert.doesNotMatch(block, /@media/, 'nested media queries are not modelled');
return [...block.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(
([, selectors, declarations]) => [
selectors.split(',').map((part) => part.trim().replace(/\s+/g, ' ')),
declarations,
],
);
}
test('narrow-screen rails pin every hosted panel glow inside its panel box', () => {
const css = readStylesheet(new URL('../../style.css', import.meta.url));
for (const panel of [
'data-panel',
'scene-panel',
'cctv-panel',
'global-context-panel',
]) {
assert.match(
css,
new RegExp(`#${panel} \\.panel-glow \\{[^}]*\\binset: -\\d+px;`),
`${panel} glow no longer overhangs its panel; revisit this pin`,
);
}
const narrow = narrowScreenBlocks(css).flatMap(flatRules);
const scrollingRails = [
...new Set(
narrow
.filter(([, declarations]) =>
/\boverflow(?:-y)?:\s*(?:auto|scroll)\b/.test(declarations),
)
.flatMap(([selectors]) => selectors)
.filter((selector) =>
/^#(?:left-panel-stack|right-context-rail)$/.test(selector),
),
),
].sort();
assert.deepEqual(scrollingRails, [
'#left-panel-stack',
'#right-context-rail',
]);
for (const rail of scrollingRails) {
assert.ok(
narrow.some(
([selectors, declarations]) =>
selectors.includes(`${rail} .panel-glow`) &&
/(?:^|;)\s*inset:\s*0\s*;/.test(declarations),
),
`${rail} scrolls at ≤720px but does not pin its panel glows (inset: 0)`,
);
}
});
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
const f = fixture('right');
const imagery = element('recent-imagery-panel', { height: 0 });
imagery.hidden = true;
imagery.scrollHeight = 0;
imagery.rect.height = 0;
f.stack.children.push(imagery);
imagery.parentElement = f.stack;
// A tall panel expands into focus mode: the hidden sibling is not among the
// later panels that focus mode collapses, and it counts for nothing.
f.expand(f.first, 900);
f.run();
assert.equal(f.stack.dataset.layoutMode, 'focus');
assert.equal(imagery.classList.contains('collapsed'), false);
assert.equal(imagery.classList.contains('layout-auto-collapsed'), false);
assert.equal(f.stack.dataset.expandedCount, '1');
assert.equal(
imagery.style.getPropertyValue('--right-panel-allocated-height'),
'',
);
assert.equal(imagery.getAttribute('aria-hidden'), undefined);
const alone = parseFloat(
f.first.style.getPropertyValue('--right-panel-allocated-height'),
);
// Shown (and expanded) it joins the allocation like any other panel.
imagery.hidden = false;
imagery.scrollHeight = 300;
imagery.rect.height = 300;
const retriesBefore = f.retries();
f.run();
assert.ok(
f.collapsed.includes('recent-imagery-panel'),
'focus mode collapses the later panel and asks for another pass',
);
assert.equal(f.retries(), retriesBefore + 1);
f.run();
assert.equal(f.stack.dataset.expandedCount, '1');
assert.ok(
parseFloat(
f.first.style.getPropertyValue('--right-panel-allocated-height'),
) <= alone,
);
});
+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();
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+64 -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,11 +42,14 @@ 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);
// A panel lifted out of the rail (PanelPositionControls' portable mode)
// is a fixed window the rail neither measures nor allocates.
const panels = [...stack.children].filter((panel) =>
panel.matches('[data-panel-id]:not(.panel-floating)'),
const panels = [...stack.children].filter(
(panel) =>
panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden,
);
if (!hud.visible || hud.variant !== 'tactical') {
for (const panel of panels.filter((item) =>
@@ -115,33 +120,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,
@@ -155,16 +180,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,
);
@@ -196,38 +221,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;
+22
View File
@@ -14,6 +14,8 @@
--text-primary: #e8eaed;
--text-secondary: rgba(232, 234, 237, 0.5);
--text-dim: rgba(232, 234, 237, 0.3);
/* Opaque surface for browser-painted menus, which cannot use --glass-bg. */
--menu-bg: #12121c;
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--panel-radius: 16px;
@@ -69,6 +71,26 @@
outline-offset: 2px;
}
/* Native `<select>` popups are painted by the browser, not by this sheet: the
closed control inherits its panel skin, but the option list falls back to the
platform's light palette unless the control declares a scheme. Declare it on
the controls rather than on :root so the app keeps its own hand-tuned
scrollbar and input skins, and set the option colors explicitly because the
popup surface is not themed on every platform. */
select {
color-scheme: dark;
}
select option,
select optgroup {
background-color: var(--menu-bg);
color: var(--text-primary);
}
select option:disabled {
color: var(--text-dim);
}
html,
body {
width: 100%;
+78 -2
View File
@@ -162,6 +162,8 @@
}
#right-context-rail > #pp-toggles,
#right-context-rail > #cctv-panel:not(.panel-floating),
#right-context-rail > #weather-panel,
#right-context-rail > #recent-imagery-panel,
#right-context-rail > #global-context-panel {
position: relative;
inset: auto;
@@ -173,12 +175,24 @@
max-height: 100%;
pointer-events: auto;
}
#right-context-rail #cctv-panel:not(.panel-floating) {
#right-context-rail #cctv-panel:not(.panel-floating),
#right-context-rail #weather-panel,
#right-context-rail #recent-imagery-panel {
width: 100%;
max-height: 100%;
pointer-events: auto;
}
#right-context-rail #cctv-panel.collapsed:not(.panel-floating) {
#right-context-rail > #weather-panel,
/* Collapsed, it must stay hideable by the rail's exclusive layout. */
#right-context-rail > #recent-imagery-panel:not(.collapsed) {
display: flex;
flex-direction: column;
flex-shrink: 1;
min-height: 0;
}
#right-context-rail #cctv-panel.collapsed:not(.panel-floating),
#right-context-rail #weather-panel.collapsed,
#right-context-rail #recent-imagery-panel.collapsed {
width: var(--right-display-collapsed-width);
margin-left: auto;
}
@@ -191,6 +205,8 @@
}
#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 #recent-imagery-panel.collapsed .recent-imagery-panel-inner,
#right-context-rail
#global-context-panel.collapsed
.global-context-panel-inner {
@@ -250,6 +266,8 @@
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
#right-context-rail.layout-focus
> #cctv-panel:not(.collapsed):not(.panel-floating),
#right-context-rail.layout-focus > #weather-panel:not(.collapsed),
#right-context-rail.layout-focus > #recent-imagery-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);
@@ -262,9 +280,15 @@
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):not(.panel-floating)
.cctv-panel-inner,
#right-context-rail.layout-focus
> #recent-imagery-panel:not(.collapsed)
.recent-imagery-panel-inner,
#right-context-rail.layout-focus
> #global-context-panel:not(.collapsed)
.global-context-panel-inner {
@@ -287,6 +311,43 @@
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)
> .recent-imagery-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;
@@ -1310,3 +1371,18 @@
.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,
#right-context-rail #recent-imagery-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;
}
+483
View File
@@ -0,0 +1,483 @@
/* Recent Imagery: a right-rail readout laid out by the rail like WEATHER; only
the body scrolls. Every block has a fixed height, so no click moves a
control. Card typography comes from the rail-card rules in weather.css. */
.recent-imagery-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;
}
#recent-imagery-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;
}
#recent-imagery-panel .panel-header {
flex: 0 0 auto;
}
#recent-imagery-panel-count {
color: var(--text-secondary);
font: 9px var(--font-mono);
letter-spacing: 1px;
white-space: nowrap;
}
#recent-imagery-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;
}
#recent-imagery-panel.collapsed #recent-imagery-panel-body,
#recent-imagery-panel.collapsed #recent-imagery-panel-count,
#recent-imagery-panel[hidden],
#recent-imagery-panel [hidden],
#recent-imagery-split-line[hidden] {
display: none !important;
}
#recent-imagery-panel.collapsed .panel-title {
font-size: 8px;
letter-spacing: 1.8px;
}
#recent-imagery-panel.collapsed .panel-header {
margin-bottom: 2px;
}
#recent-imagery-panel.collapsed .recent-imagery-panel-inner {
padding-bottom: 10px;
}
/* ---- the fixed stack ---------------------------------------------------- */
.recent-imagery-readout {
--weather-card-border: rgba(98, 181, 255, 0.28);
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
color: var(--text-dim);
font: 9px/1.45 var(--font-mono);
letter-spacing: 0.5px;
}
.recent-imagery-readout .ri-block {
flex: 0 0 auto;
min-width: 0;
box-sizing: border-box;
}
/* Reserved controls keep their box; they are only invisible. */
.recent-imagery-readout .is-reserved {
visibility: hidden;
pointer-events: none;
}
.recent-imagery-readout .data-toggle-chip {
box-sizing: border-box;
min-width: 0;
height: 22px;
padding: 0 4px;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-imagery-readout .data-toggle-chip:disabled {
cursor: default;
opacity: 0.4;
}
/* 1. SELECT BOX · USE VIEW · CLEAR, three equal cells. */
.recent-imagery-readout .ri-actions-row {
height: 22px;
}
.recent-imagery-readout .ri-actions-row .rail-card-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin: 0;
}
.recent-imagery-readout .ri-actions-row .data-toggle-chip {
height: 22px;
}
/* 2 and 3b. One-line notice and hint: always there, empty or not. */
.recent-imagery-readout .ri-line {
height: 14px;
line-height: 14px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* The notice text yields room to ZOOM IN only while it shows; the chip sits
in a fixed slot at the line's right end either way. The ellipsis is a
safety net: every notice is written to fit the panel. */
.recent-imagery-readout .ri-notice {
position: relative;
}
.recent-imagery-readout .ri-notice-text {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.recent-imagery-readout .ri-notice.has-action .ri-notice-text {
padding-right: 52px;
}
.recent-imagery-readout .ri-zoom-in {
position: absolute;
top: 0;
right: 0;
width: 46px;
height: 14px;
padding: 0 3px;
font-size: 8px;
line-height: 12px;
letter-spacing: 0.4px;
color: var(--accent);
border-color: var(--accent);
}
.recent-imagery-readout .ri-notice.warn {
color: rgba(255, 196, 120, 0.95);
}
.recent-imagery-readout .ri-notice.info {
color: var(--accent);
}
.recent-imagery-readout .ri-hint {
color: var(--text-secondary);
}
/* 3. The strip: fixed height, newest day first. */
.recent-imagery-readout .ri-strip {
display: flex;
gap: 6px;
height: 120px;
padding: 2px 2px 6px;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
scrollbar-width: thin;
outline: none;
border-radius: 6px;
}
.recent-imagery-readout .ri-strip:focus-visible {
box-shadow: 0 0 0 1px var(--accent);
}
.recent-imagery-readout .ri-strip.searching {
opacity: 0.7;
}
.recent-imagery-readout .ri-strip-empty {
flex: 1 0 100%;
align-self: center;
text-align: center;
letter-spacing: 1px;
text-transform: uppercase;
}
.recent-imagery-readout .ri-card {
position: relative;
flex: 0 0 94px;
height: 112px;
box-sizing: border-box;
display: grid;
grid-template-rows: 42px 13px 11px 11px 18px;
row-gap: 2px;
padding: 3px 2px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
color: var(--text-secondary);
cursor: pointer;
scroll-snap-align: start;
transition:
border-color var(--transition-fast),
background-color var(--transition-fast);
}
.recent-imagery-readout .ri-card:hover {
border-color: var(--glass-border-hover);
}
.recent-imagery-readout .ri-card.focused {
border-color: var(--accent);
background: var(--accent-dim);
}
.recent-imagery-readout .ri-card.preview {
box-shadow: inset 0 0 0 1px rgba(139, 233, 255, 0.35);
}
.recent-imagery-readout .ri-card.pinned {
box-shadow: inset 0 0 0 1px rgba(139, 233, 255, 0.75);
}
.recent-imagery-readout .ri-thumb {
position: relative;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 4px;
background: rgba(120, 130, 140, 0.18);
}
.recent-imagery-readout .ri-thumb[data-status='empty'] {
background: rgba(90, 96, 104, 0.22);
}
.recent-imagery-readout .ri-thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.recent-imagery-readout .ri-thumb-text {
padding: 0 4px;
text-align: center;
font-size: 8px;
}
.recent-imagery-readout .ri-card-flag,
.recent-imagery-readout .ri-card-start {
position: absolute;
padding: 0 4px;
border-radius: 3px;
background: rgba(4, 17, 27, 0.9);
font-size: 7px;
font-weight: 700;
line-height: 12px;
letter-spacing: 0.8px;
white-space: nowrap;
}
.recent-imagery-readout .ri-card-flag {
top: 3px;
left: 3px;
color: var(--accent);
}
.recent-imagery-readout .ri-card-start {
right: 3px;
bottom: 3px;
color: rgba(255, 196, 120, 0.95);
}
.recent-imagery-readout .ri-card-date,
.recent-imagery-readout .ri-card-sensor,
.recent-imagery-readout .ri-card-cloud {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.recent-imagery-readout .ri-card-date {
font-size: 10px;
line-height: 13px;
color: var(--text-primary);
}
.recent-imagery-readout .ri-card-sensor,
.recent-imagery-readout .ri-card-cloud {
font-size: 8px;
line-height: 11px;
letter-spacing: 0;
color: var(--text-dim);
}
.recent-imagery-readout .ri-card-chips {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
}
.recent-imagery-readout .ri-card-chips .data-toggle-chip {
height: 18px;
font-weight: 700;
}
/* 4. The selection bar: mode, then two rows that are always there. */
.recent-imagery-readout .ri-selection {
display: grid;
grid-template-rows: 20px 20px 20px;
row-gap: 4px;
padding: 5px;
border: 1px solid var(--weather-card-border);
border-radius: 6px;
}
.recent-imagery-readout .ri-mode {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
}
.recent-imagery-readout .ri-selection .data-toggle-chip {
height: 20px;
}
.recent-imagery-readout .ri-slot {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 22px;
align-items: center;
column-gap: 6px;
height: 20px;
}
.recent-imagery-readout .ri-slot-tag {
height: 16px;
line-height: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 3px;
text-align: center;
font-size: 8px;
font-weight: 700;
letter-spacing: 1px;
white-space: nowrap;
}
.recent-imagery-readout .ri-slot.lit .ri-slot-tag {
border-color: var(--accent);
color: var(--accent);
}
.recent-imagery-readout .ri-slot-value {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
letter-spacing: 0;
color: var(--text-dim);
}
.recent-imagery-readout .ri-slot.lit .ri-slot-value {
color: var(--text-primary);
}
.recent-imagery-readout .ri-slot[data-state='preview'] .ri-slot-value {
color: var(--text-secondary);
font-style: italic;
}
.recent-imagery-readout .ri-unpin {
width: 22px;
padding: 0;
font-size: 12px;
letter-spacing: 0;
}
/* 5. Opacity (both images), SWAP and export, one row. SWAP keeps its slot
while no swipe is live. */
.recent-imagery-readout .ri-controls {
display: grid;
grid-template-columns: 76px minmax(0, 1fr) 40px 128px;
align-items: center;
column-gap: 6px;
height: 22px;
}
.recent-imagery-readout .ri-opacity-label {
display: flex;
justify-content: space-between;
gap: 4px;
font-size: 8px;
letter-spacing: 1px;
white-space: nowrap;
}
.recent-imagery-readout .ri-opacity-value {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.recent-imagery-readout .ri-opacity {
width: 100%;
min-width: 0;
margin: 0;
accent-color: var(--accent);
}
.recent-imagery-readout .ri-opacity:disabled {
opacity: 0.35;
}
.recent-imagery-readout .ri-swap {
width: 40px;
padding: 0 2px;
}
.recent-imagery-readout .ri-exports .rail-card-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
margin: 0;
}
/* 6. DETAILS: a collapsed rail card at the bottom, for every note. */
.recent-imagery-readout .ri-details .rail-card {
border-color: var(--weather-card-border);
}
.recent-imagery-readout .ri-details .rail-card-title {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1.2px;
text-transform: uppercase;
}
.recent-imagery-readout .ri-details .rail-card-line.muted {
color: var(--text-dim);
}
.recent-imagery-readout .ri-details .rail-card-actions .data-toggle-chip {
height: 20px;
padding: 0 7px;
}
/* ---- box tool cursor ---------------------------------------------------- */
body.gev-imagery-box #cesium-container canvas,
body.gev-imagery-box .cesium-widget canvas {
cursor: crosshair;
}
/* ---- split line --------------------------------------------------------- */
/* Above the right rail (z-index 110) so the handle can always be grabbed. */
#recent-imagery-split-line {
position: fixed;
top: 0;
bottom: 0;
left: var(--recent-imagery-split, 50%);
z-index: 130;
width: 1px;
pointer-events: none;
background: rgba(218, 241, 247, 0.76);
box-shadow: 0 0 12px rgba(94, 216, 239, 0.72);
}
#recent-imagery-split-line span {
position: absolute;
top: 14px;
padding: 2px 6px;
border-radius: 3px;
background: rgba(4, 17, 27, 0.82);
color: rgba(239, 247, 249, 0.92);
font: 700 9px/1.4 var(--font-mono);
letter-spacing: 1.2px;
white-space: nowrap;
pointer-events: none;
}
#recent-imagery-split-line span.before {
right: 8px;
}
#recent-imagery-split-line span.after {
left: 8px;
}
#recent-imagery-split-line .recent-imagery-split-handle {
position: absolute;
top: 50%;
left: 50%;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid rgba(180, 215, 222, 0.72);
border-radius: 50%;
outline: 0;
color: rgba(239, 247, 249, 0.96);
background: rgba(4, 17, 27, 0.96);
box-shadow:
0 0 0 5px rgba(62, 112, 125, 0.3),
0 0 13px rgba(94, 216, 239, 0.3);
font: 700 14px/1 var(--font-mono);
transform: translate(-50%, -50%);
pointer-events: auto;
touch-action: none;
cursor: ew-resize;
}
#recent-imagery-split-line .recent-imagery-split-handle:focus-visible {
outline: 2px solid #fff;
outline-offset: 4px;
}
@media (max-width: 720px) {
#recent-imagery-panel-body {
max-height: 48vh;
}
}
+16
View File
@@ -197,6 +197,20 @@
overflow-y: auto;
}
/* Both stacks are scroll containers at this width (overflow-y above), which
also makes their overflow-x compute to auto. Each panel's decorative
.panel-glow is absolutely positioned with a negative inset (-18px/-20px),
so inside a scroll container it stops being harmless paint and becomes
18–20px of scrollable overflow on BOTH axes: a horizontal scrollbar band
under the expanded panel plus a vertical scrollbar that scrolls nothing
but glow. Pin the glow to the panel box here — its blur is ink overflow,
which never scrolls — instead of clipping the rail, which would also clip
legitimate content such as the Context radio popover. */
#left-panel-stack .panel-glow,
#right-context-rail .panel-glow {
inset: 0;
}
#right-context-rail > #pp-toggles {
width: 100%;
}
@@ -204,6 +218,8 @@
#right-context-rail #pp-toggles.collapsed .pp-header-row,
#right-context-rail #cctv-panel:not(.panel-floating),
#right-context-rail #cctv-panel.collapsed:not(.panel-floating),
#right-context-rail #recent-imagery-panel,
#right-context-rail #recent-imagery-panel.collapsed,
#right-context-rail #radio-panel,
#right-context-rail #radio-panel.collapsed,
#right-context-rail #global-context-panel,
+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;
}
+24
View File
@@ -1,5 +1,29 @@
<!-- 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="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
<div class="panel-glow"></div>
<div class="recent-imagery-panel-inner">
<div class="panel-header">
<span class="panel-title">RECENT IMAGERY</span>
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box"></span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="recent-imagery-panel" type="button" title="Collapse panel">▼</button>
</div>
<div id="recent-imagery-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();
});