mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge upstream/main into pr/panel-resize
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
11603729dc
234 files changed
+45360
-944
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Vendored
+22
@@ -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%;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user