feat(layers): Recent Imagery — open satellite imagery for a selected box (#716)

Select a box on the world and browse the last 30 days of open satellite imagery over it, keyless: NASA GIBS Harmonized Landsat Sentinel-2 (30 m) and the VIIRS daily overview (250 m), with a day strip, IMAGE / VS BASEMAP / A / B modes with a swipe divider, PNG export and share links. The before/after swipe from the Nepal scene moves into shared modules so both use it.

Co-authored-by: manjunath22466 <[email protected]>
This commit is contained in:
Bilawal Sidhuandmanjunath22466 authored and GitHub committed 2026-09-22 20:34:11 -05:00
1 parent 25491f7e33
commit 082074a006
50 files changed
+13169 -440

No files matched your search

+293
View File
@@ -0,0 +1,293 @@
/**
* Box selection for the Recent Imagery layer: press on the world, drag, and
* release to hand the layer a degrees box. Follows the draw tool's ownership
* rules — the pointer is claimed for the session, the viewer's own click
* actions are borrowed and given back, and `destroy()` returns everything —
* and freezes the camera only while a drag is in progress.
*/
import * as Cesium from 'cesium';
import { pickWorldFromScreen } from '../annotations/annotationResolver.js';
import {
claimPointer,
pointerOwner,
releasePointer,
} from '../data/inputOwnership.js';
import { validateBox } from '../layers/recentImagery/model.js';
/** The id this tool claims the pointer under. */
export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box';
/**
* Wire the box tool.
* @param {{ viewer: object, onBox: (box: object) => void, onCancel?: (reason: string, message?: string, box?: object) => void, onActive?: (active: boolean) => void, onEscape?: () => boolean, cesium?: object, pickWorld?: Function, documentRef?: object }} deps
* `onEscape` runs before the tool cancels on Escape; returning `true` claims
* the key (it is still consumed) and leaves the tool armed.
* @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => Promise<void> } | null}
*/
export function initImageryBoxTool({
viewer,
onBox,
onCancel,
onActive,
onEscape,
cesium = Cesium,
pickWorld = pickWorldFromScreen,
documentRef = globalThis.document,
} = {}) {
if (!viewer?.scene || typeof onBox !== 'function') return null;
let active = false;
let destroyed = false;
/** Drag corners in degrees; `anchor` is null while no drag is live. */
let anchor = null;
let corner = null;
let handler = null;
let lease = null;
let savedSingleClick = null;
let savedDoubleClick = null;
let savedCameraInputs = null;
const dragBox = () =>
anchor && {
west: Math.min(anchor.lon, corner.lon),
south: Math.min(anchor.lat, corner.lat),
east: Math.max(anchor.lon, corner.lon),
north: Math.max(anchor.lat, corner.lat),
};
const dataSource = new cesium.CustomDataSource(
'gev-recent-imagery-box-preview',
);
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
() => null,
);
const stroke = cesium.Color.fromCssColorString('#8be9ff');
const previewRectangle = () => {
const box = dragBox();
return box
? cesium.Rectangle.fromDegrees(box.west, box.south, box.east, box.north)
: undefined;
};
const previewOutline = () => {
const box = dragBox();
if (!box) return [];
return cesium.Cartesian3.fromDegreesArray([
box.west,
box.south,
box.east,
box.south,
box.east,
box.north,
box.west,
box.north,
box.west,
box.south,
]);
};
const fill = dataSource.entities.add({
show: false,
rectangle: {
coordinates: new cesium.CallbackProperty(previewRectangle, false),
material: stroke.withAlpha(0.12),
classificationType: cesium.ClassificationType.BOTH,
},
});
// A rectangle graphic cannot draw a dashed edge on the ground, so the
// outline is its own draped polyline traced round the same corners.
const outline = dataSource.entities.add({
show: false,
polyline: {
positions: new cesium.CallbackProperty(previewOutline, false),
width: 2,
clampToGround: true,
classificationType: cesium.ClassificationType.BOTH,
material: new cesium.PolylineDashMaterialProperty({
color: stroke.withAlpha(0.95),
dashLength: 12,
}),
},
});
const requestRender = () => viewer.scene.requestRender?.();
const setPreview = (visible) => {
fill.show = visible;
outline.show = visible;
requestRender();
};
const worldAt = (position) => {
if (!position) return null;
const canvas = viewer.scene.canvas;
const w = canvas?.clientWidth || canvas?.width || 1;
const h = canvas?.clientHeight || canvas?.height || 1;
const point = pickWorld(viewer, position.x / w, position.y / h);
const lon = Number(point?.lon);
const lat = Number(point?.lat);
return Number.isFinite(lon) && Number.isFinite(lat) ? { lon, lat } : null;
};
const cameraController = () => viewer.scene.screenSpaceCameraController;
function freezeCamera() {
const controller = cameraController();
if (!controller || savedCameraInputs !== null) return;
savedCameraInputs = controller.enableInputs;
controller.enableInputs = false;
}
function thawCamera() {
const controller = cameraController();
if (controller && savedCameraInputs !== null)
controller.enableInputs = savedCameraInputs;
savedCameraInputs = null;
}
function endDrag() {
anchor = null;
corner = null;
thawCamera();
setPreview(false);
}
const onDown = (event) => {
if (!active || destroyed || anchor) return;
const point = worldAt(event.position);
if (!point) {
onCancel?.('sky', 'Press on the ground, not the sky');
return;
}
anchor = point;
corner = point;
freezeCamera();
setPreview(true);
};
const onMove = (event) => {
const point = anchor && !destroyed && worldAt(event.endPosition);
if (!point) return;
corner = point;
requestRender();
};
const onUp = (event) => {
if (!anchor || destroyed) return;
corner = worldAt(event.position) || corner;
const box = dragBox();
const result = validateBox(box);
if (result.reason === 'degenerate') result.message = 'Drag to size the box';
endDrag();
setActive(false);
if (result.ok) onBox(result.box);
else onCancel?.(result.reason, result.message, box);
};
// Escape is taken from a document capture listener, ahead of every other
// handler, so the interceptor runs first: when it claims the key (the
// panel exits a live comparison), the tool stays armed and the key still
// goes no further; otherwise Escape cancels the session.
const onKey = (event) => {
if (!active || destroyed || event.key !== 'Escape') return;
event.preventDefault?.();
event.stopImmediatePropagation?.();
if (onEscape?.() === true) return;
cancel('escape');
};
function bindScene() {
if (handler) return;
handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(onDown, cesium.ScreenSpaceEventType.LEFT_DOWN);
handler.setInputAction(onMove, cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction(onUp, cesium.ScreenSpaceEventType.LEFT_UP);
const stock = viewer.screenSpaceEventHandler;
if (stock) {
savedSingleClick =
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
savedDoubleClick =
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null;
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK);
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
}
documentRef?.addEventListener?.('keydown', onKey, true);
documentRef?.body?.classList?.add?.('gev-imagery-box');
}
function releaseScene() {
if (handler) {
handler.destroy();
handler = null;
}
const stock = viewer.screenSpaceEventHandler;
if (stock && savedSingleClick) {
stock.setInputAction(
savedSingleClick,
cesium.ScreenSpaceEventType.LEFT_CLICK,
);
}
if (stock && savedDoubleClick) {
stock.setInputAction(
savedDoubleClick,
cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
);
}
savedSingleClick = null;
savedDoubleClick = null;
documentRef?.removeEventListener?.('keydown', onKey, true);
documentRef?.body?.classList?.remove?.('gev-imagery-box');
}
function setActive(next) {
if (next === active) return active;
if (next) {
lease = claimPointer(IMAGERY_BOX_POINTER_OWNER);
if (!lease) {
onCancel?.(
'pointer-busy',
`${pointerOwner()} is using the pointer — close it first`,
);
return active;
}
active = true;
bindScene();
} else {
if (anchor) endDrag();
releaseScene();
releasePointer(lease);
lease = null;
active = false;
}
onActive?.(active);
return active;
}
function cancel(reason = 'cancel') {
if (!active || destroyed) return;
setActive(false);
onCancel?.(reason);
}
return {
/** Begin a box session. False when the pointer is held elsewhere. */
start() {
return !destroyed && setActive(true);
},
cancel,
isActive() {
return active;
},
/** Returns everything; resolves once the preview is detached. */
destroy() {
if (destroyed) return attaching;
if (active) setActive(false);
destroyed = true;
releaseScene();
releasePointer(lease);
lease = null;
thawCamera();
dataSource.entities.removeAll();
attaching = attaching.then(() => {
try {
viewer.dataSources.remove(dataSource, true);
} catch {
/* viewer already disposed */
}
});
return attaching;
},
};
}
+154
View File
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
claimPointer,
pointerOwner,
releasePointer,
resetPointerOwnership,
} from '../data/inputOwnership.js';
import {
IMAGERY_BOX_POINTER_OWNER,
initImageryBoxTool,
} from './imageryBoxTool.js';
import { boxToolFakes } from '../layers/recentImagery/testDoubles.mjs';
function fixture(options = {}) {
resetPointerOwnership();
const fakes = boxToolFakes();
const boxes = [];
const cancels = [];
const actives = [];
const tool = initImageryBoxTool({
viewer: fakes.viewer,
cesium: fakes.cesium,
pickWorld: fakes.pickWorld,
documentRef: fakes.documentRef,
onBox: (box) => boxes.push(box),
onCancel: (reason, message) => cancels.push([reason, message]),
onActive: (active) => actives.push(active),
...options,
});
const camera = fakes.viewer.scene.screenSpaceCameraController;
const stockAction = (type) =>
fakes.viewer.screenSpaceEventHandler.getInputAction(fakes.types[type]);
const preview = () =>
fakes.viewer.dataSources.sources[0]?.entities.values.every((e) => e.show);
return {
...fakes,
tool,
boxes,
cancels,
actives,
camera,
stockAction,
preview,
};
}
test('a session claims the pointer, borrows the stock clicks, freezes the camera only mid-drag and returns everything with the box', async () => {
const f = fixture();
assert.equal(f.tool.start(), true);
assert.equal(pointerOwner(), IMAGERY_BOX_POINTER_OWNER);
assert.equal(f.stockAction('LEFT_CLICK'), undefined);
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), undefined);
assert.ok(f.documentRef.classes.has('gev-imagery-box'));
assert.equal(f.camera.enableInputs, true, 'not frozen before the press');
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
assert.equal(f.camera.enableInputs, false);
assert.equal(f.preview(), true);
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 250 } });
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 10 } }); // sky: ignored
f.fire('LEFT_UP', { position: { x: 500, y: 250 } });
assert.equal(f.boxes.length, 1);
const { west, south, east, north } = f.boxes[0];
assert.deepEqual(
[west, south, east, north].map((v) => Math.round(v * 1e6) / 1e6),
[-97.85, 30.3, -97.8, 30.35],
);
assert.deepEqual(f.cancels, []);
assert.equal(f.tool.isActive(), false);
assert.equal(pointerOwner(), null);
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), f.originalDouble);
assert.equal(f.camera.enableInputs, true);
assert.equal(f.preview(), false);
assert.equal(f.documentRef.classes.has('gev-imagery-box'), false);
assert.equal(f.documentRef.listeners.length, 0);
assert.equal(f.handler(), null);
assert.deepEqual(f.actives, [true, false]);
await f.tool.destroy();
});
test('a sky press, a click without a drag and Escape each say why', async () => {
const f = fixture();
f.tool.start();
f.fire('LEFT_DOWN', { position: { x: 250, y: 10 } });
assert.deepEqual(f.cancels.at(-1), [
'sky',
'Press on the ground, not the sky',
]);
assert.equal(f.tool.isActive(), true, 'a sky press keeps the session');
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
f.fire('LEFT_UP', { position: { x: 250, y: 125 } });
assert.deepEqual(f.cancels.at(-1), ['degenerate', 'Drag to size the box']);
f.tool.start();
assert.equal(f.key('Escape').prevented, true);
assert.deepEqual(f.cancels.at(-1), ['escape', undefined]);
assert.equal(pointerOwner(), null);
await f.tool.destroy();
});
test('an Escape interceptor that claims the key leaves the tool armed; the next Escape cancels', async () => {
let claim = true;
let asked = 0;
const f = fixture({
onEscape: () => {
asked += 1;
return claim;
},
});
f.tool.start();
const first = f.key('Escape');
assert.equal(asked, 1);
assert.equal(
first.prevented && first.stopped,
true,
'the key goes no further',
);
assert.equal(f.tool.isActive(), true);
claim = false;
f.key('Escape');
assert.deepEqual(f.cancels, [['escape', undefined]]);
f.tool.start();
f.key('Enter');
assert.equal(asked, 2, 'other keys never reach the interceptor');
await f.tool.destroy();
});
test('a held pointer refuses the session and nothing is borrowed', async () => {
const f = fixture();
const lease = claimPointer('draw');
assert.equal(f.tool.start(), false);
assert.deepEqual(f.cancels, [
['pointer-busy', 'draw is using the pointer — close it first'],
]);
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
assert.equal(f.handler(), null);
releasePointer(lease);
assert.equal(f.tool.start(), true);
await f.tool.destroy();
});
test('destroy mid-drag restores the camera, releases the claim and detaches the preview', async () => {
const f = fixture();
f.tool.start();
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
assert.equal(f.viewer.dataSources.sources.length, 1);
await f.tool.destroy();
assert.equal(f.camera.enableInputs, true);
assert.equal(pointerOwner(), null);
assert.equal(f.viewer.dataSources.sources.length, 0);
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
assert.equal(f.tool.start(), false);
assert.equal(initImageryBoxTool({ viewer: null, onBox() {} }), null);
});
+176
View File
@@ -0,0 +1,176 @@
const HANDLE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']);
function clampUnit(value) {
return Math.max(0, Math.min(1, Number(value) || 0));
}
function defaultViewportWidth() {
return (
Number(document.documentElement?.clientWidth) ||
Number(globalThis.window?.innerWidth) ||
0
);
}
function defaultValueText(beforePercent, afterPercent) {
return `A ${beforePercent} percent, B ${afterPercent} percent`;
}
/**
* Split divider for before/after imagery comparison.
*
* Owns the DOM line + handle, pointer capture, keyboard, ARIA, a CSS custom
* property and scene.splitPosition. `setValue` is the owner's own write and
* never echoes through `onChange`; `onChange` reports pointer and keyboard
* changes so the owner can mirror them, and `requestRender` fires whenever
* the clamped value actually changes. `destroy` is idempotent: it removes the
* listeners and line, clears the CSS property and restores the scene split
* captured at creation once.
*/
export function createImagerySplit({
scene,
parent = document.body,
initialValue = 0.5,
id,
handleClass,
cssTarget = document.documentElement,
cssProperty,
beforeLabel = 'A',
afterLabel = 'B',
beforeTitle,
afterTitle,
ariaLabel,
formatValueText = defaultValueText,
getViewportWidth = defaultViewportWidth,
onChange,
requestRender,
} = {}) {
const restoreValue = scene ? (scene.splitPosition ?? null) : null;
const line = document.createElement('div');
if (id) line.id = id;
const before = document.createElement('span');
const after = document.createElement('span');
const handle = document.createElement('button');
before.className = 'before';
after.className = 'after';
before.textContent = beforeLabel;
if (beforeTitle != null) before.title = beforeTitle;
after.textContent = afterLabel;
if (afterTitle != null) after.title = afterTitle;
handle.type = 'button';
if (handleClass) handle.className = handleClass;
handle.setAttribute('role', 'slider');
if (ariaLabel != null) handle.setAttribute('aria-label', ariaLabel);
handle.setAttribute('aria-valuemin', '0');
handle.setAttribute('aria-valuemax', '100');
handle.setAttribute('aria-orientation', 'horizontal');
handle.textContent = '↔';
let value = null;
let cssValue = null;
let dragging = false;
let pointerId = null;
let destroyed = false;
function apply(next, { notify = false, render = true } = {}) {
const clamped = clampUnit(next);
const changed = clamped !== value;
value = clamped;
if (scene && scene.splitPosition !== value) scene.splitPosition = value;
const css = `${value * 100}%`;
if (cssProperty && cssValue !== css) {
cssTarget.style.setProperty(cssProperty, css);
cssValue = css;
}
const beforePercent = Math.round(value * 100);
handle.setAttribute('aria-valuenow', String(beforePercent));
handle.setAttribute(
'aria-valuetext',
formatValueText(beforePercent, 100 - beforePercent),
);
if (changed && render) requestRender?.();
if (changed && notify) onChange?.(value);
return changed;
}
function setFromClientX(clientX) {
const viewportWidth = Number(getViewportWidth()) || 0;
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
apply(Number(clientX) / viewportWidth, { notify: true });
}
function handlePointerDown(event) {
if (event.button != null && event.button !== 0) return;
event.preventDefault?.();
dragging = true;
pointerId = event.pointerId ?? null;
handle.setPointerCapture?.(event.pointerId);
setFromClientX(event.clientX);
}
function handlePointerMove(event) {
if (!dragging) return;
if (pointerId != null && event.pointerId !== pointerId) return;
event.preventDefault?.();
setFromClientX(event.clientX);
}
function finishPointerDrag(event) {
if (!dragging) return;
if (pointerId != null && event.pointerId !== pointerId) return;
handle.releasePointerCapture?.(event.pointerId);
dragging = false;
pointerId = null;
}
function handleKeyDown(event) {
if (!HANDLE_KEYS.has(event.key)) return;
event.preventDefault?.();
const step = event.shiftKey ? 0.05 : 0.01;
if (event.key === 'Home') apply(0, { notify: true });
else if (event.key === 'End') apply(1, { notify: true });
else
apply(value + (event.key === 'ArrowRight' ? step : -step), {
notify: true,
});
}
const listeners = [
['pointerdown', handlePointerDown],
['pointermove', handlePointerMove],
['pointerup', finishPointerDrag],
['pointercancel', finishPointerDrag],
['keydown', handleKeyDown],
];
for (const [type, listener] of listeners)
handle.addEventListener(type, listener);
line.append(before, handle, after);
parent.appendChild(line);
apply(initialValue, { render: false });
return {
getValue() {
return value;
},
setValue(next) {
if (destroyed) return false;
return apply(next);
},
setVisible(visible) {
line.hidden = !visible;
},
destroy() {
if (destroyed) return;
destroyed = true;
if (dragging && pointerId != null)
handle.releasePointerCapture?.(pointerId);
dragging = false;
pointerId = null;
for (const [type, listener] of listeners)
handle.removeEventListener?.(type, listener);
line.remove();
if (cssProperty) cssTarget.style.removeProperty(cssProperty);
if (scene && restoreValue != null) scene.splitPosition = restoreValue;
},
};
}
+307
View File
@@ -0,0 +1,307 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createImagerySplit } from './imagerySplit.js';
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
this.removed = false;
this.captured = [];
this.released = [];
this._attributes = new Map();
this._listeners = new Map();
}
setAttribute(name, value) {
this._attributes.set(name, value);
}
getAttribute(name) {
return this._attributes.get(name) ?? null;
}
addEventListener(type, handler) {
this._listeners.set(type, handler);
}
removeEventListener(type, handler) {
if (this._listeners.get(type) === handler) this._listeners.delete(type);
}
dispatch(type, event = {}) {
return this._listeners.get(type)?.(event);
}
setPointerCapture(id) {
this.captured.push(id);
}
releasePointerCapture(id) {
this.released.push(id);
}
append(...children) {
this.children.push(...children);
}
appendChild(child) {
this.children.push(child);
return child;
}
remove() {
this.removed = true;
}
}
function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) {
const body = new FakeElement('body');
const cssCalls = [];
const changes = [];
let renders = 0;
const scene = { splitPosition };
const cssTarget = {
style: {
setProperty(name, value) {
cssCalls.push(['set', name, value]);
},
removeProperty(name) {
cssCalls.push(['remove', name]);
},
},
};
const previous = globalThis.document;
globalThis.document = {
body,
createElement: (tagName) => new FakeElement(tagName),
documentElement: cssTarget,
};
const split = createImagerySplit({
scene,
parent: body,
initialValue,
id: 'split-line',
handleClass: 'split-handle',
cssTarget,
cssProperty: '--split',
beforeTitle: 'Before',
afterTitle: 'After',
ariaLabel: 'Divider',
getViewportWidth: () => 1000,
onChange: (value) => changes.push(value),
requestRender: () => {
renders += 1;
},
});
globalThis.document = previous;
const line = body.children[0];
const handle = line.children.find((child) => child.tagName === 'button');
return {
split,
scene,
body,
line,
handle,
cssCalls,
changes,
renders: () => renders,
};
}
test('the split builds its line and applies the initial value without notifying', () => {
const env = fixture({ initialValue: 0.62 });
assert.equal(env.line.id, 'split-line');
assert.equal(env.line.children[0].textContent, 'A');
assert.equal(env.line.children[0].title, 'Before');
assert.equal(env.line.children[2].textContent, 'B');
assert.equal(env.line.children[2].title, 'After');
assert.equal(env.handle.className, 'split-handle');
assert.equal(env.handle.getAttribute('role'), 'slider');
assert.equal(env.handle.getAttribute('aria-label'), 'Divider');
assert.equal(env.handle.getAttribute('aria-valuenow'), '62');
assert.equal(
env.handle.getAttribute('aria-valuetext'),
'A 62 percent, B 38 percent',
);
assert.equal(env.scene.splitPosition, 0.62);
assert.deepEqual(env.cssCalls, [['set', '--split', '62%']]);
assert.deepEqual(env.changes, []);
assert.equal(env.renders(), 0);
assert.equal(env.split.getValue(), 0.62);
});
test('keyboard steps 1 percent, 5 percent with Shift, and jumps to Home and End', () => {
const env = fixture();
const key = (key, shiftKey = false) => {
let prevented = false;
env.handle.dispatch('keydown', {
key,
shiftKey,
preventDefault() {
prevented = true;
},
});
return prevented;
};
assert.equal(key('ArrowRight'), true);
assert.equal(env.scene.splitPosition, 0.51);
assert.equal(key('ArrowLeft', true), true);
assert.equal(env.scene.splitPosition, 0.46);
assert.equal(key('Home'), true);
assert.equal(env.scene.splitPosition, 0);
assert.equal(env.handle.getAttribute('aria-valuenow'), '0');
assert.equal(key('End'), true);
assert.equal(env.scene.splitPosition, 1);
assert.equal(env.handle.getAttribute('aria-valuenow'), '100');
assert.equal(key('Enter'), false, 'other keys are ignored and not prevented');
assert.equal(env.scene.splitPosition, 1);
assert.deepEqual(env.changes, [0.51, 0.46, 0, 1]);
});
test('values clamp to the unit range and only real changes notify or render', () => {
const env = fixture();
assert.equal(env.split.setValue(1.4), true);
assert.equal(env.split.getValue(), 1);
assert.equal(env.scene.splitPosition, 1);
assert.equal(env.split.setValue(1), false);
assert.equal(env.split.setValue(-3), true);
assert.equal(env.split.getValue(), 0);
assert.equal(
env.split.setValue('nonsense'),
false,
'non-numeric input clamps to 0',
);
assert.equal(env.renders(), 2, 'render only when the clamped value moved');
assert.deepEqual(
env.changes,
[],
'setValue is the owner writing; it never echoes',
);
env.handle.dispatch('keydown', { key: 'Home', preventDefault() {} });
env.handle.dispatch('keydown', { key: 'ArrowLeft', preventDefault() {} });
assert.deepEqual(
env.changes,
[],
'interaction that cannot move the value stays silent',
);
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
assert.deepEqual(env.changes, [1]);
assert.equal(env.renders(), 3);
assert.deepEqual(env.cssCalls, [
['set', '--split', '50%'],
['set', '--split', '100%'],
['set', '--split', '0%'],
['set', '--split', '100%'],
]);
});
test('pointer drags follow the primary pointer only', () => {
const env = fixture();
env.handle.dispatch('pointerdown', {
button: 2,
clientX: 900,
pointerId: 1,
preventDefault() {},
});
assert.equal(
env.scene.splitPosition,
0.5,
'non-primary buttons never start a drag',
);
env.handle.dispatch('pointermove', {
clientX: 900,
pointerId: 1,
preventDefault() {},
});
assert.equal(
env.scene.splitPosition,
0.5,
'moves without a drag are ignored',
);
env.handle.dispatch('pointerdown', {
button: 0,
clientX: 250,
pointerId: 4,
preventDefault() {},
});
assert.equal(env.scene.splitPosition, 0.25);
assert.deepEqual(env.handle.captured, [4]);
env.handle.dispatch('pointermove', {
clientX: 900,
pointerId: 9,
preventDefault() {},
});
assert.equal(
env.scene.splitPosition,
0.25,
'a different pointer id is ignored',
);
env.handle.dispatch('pointerup', { pointerId: 9 });
env.handle.dispatch('pointermove', {
clientX: 750,
pointerId: 4,
preventDefault() {},
});
assert.equal(
env.scene.splitPosition,
0.75,
'the drag survives a foreign pointerup',
);
env.handle.dispatch('pointermove', {
clientX: Number.NaN,
pointerId: 4,
preventDefault() {},
});
assert.equal(
env.scene.splitPosition,
0.75,
'unusable coordinates are ignored',
);
env.handle.dispatch('pointerup', { pointerId: 4 });
assert.deepEqual(env.handle.released, [4]);
env.handle.dispatch('pointermove', {
clientX: 100,
pointerId: 4,
preventDefault() {},
});
assert.equal(env.scene.splitPosition, 0.75, 'the drag ended');
assert.deepEqual(env.changes, [0.25, 0.75]);
});
test('visibility toggles the line without touching the value', () => {
const env = fixture();
env.split.setVisible(false);
assert.equal(env.line.hidden, true);
env.split.setVisible(true);
assert.equal(env.line.hidden, false);
assert.equal(env.split.getValue(), 0.5);
});
test('destroy removes the line, clears the CSS property, restores the scene split once, and is idempotent', () => {
const env = fixture({ initialValue: 0.8, splitPosition: 0.37 });
env.handle.dispatch('pointerdown', {
button: 0,
clientX: 100,
pointerId: 2,
preventDefault() {},
});
assert.equal(env.scene.splitPosition, 0.1);
env.split.destroy();
assert.equal(env.line.removed, true);
assert.deepEqual(
env.handle.released,
[2],
'an in-flight drag releases its capture',
);
assert.equal(env.cssCalls.at(-1)[0], 'remove');
assert.equal(env.scene.splitPosition, 0.37);
assert.equal(env.handle._listeners.size, 0);
env.scene.splitPosition = 0.9;
env.split.destroy();
assert.equal(
env.scene.splitPosition,
0.9,
'a second destroy does not restore again',
);
assert.equal(env.cssCalls.filter(([kind]) => kind === 'remove').length, 1);
assert.equal(
env.split.setValue(0.2),
false,
'a destroyed split ignores writes',
);
assert.equal(env.scene.splitPosition, 0.9);
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
assert.deepEqual(env.changes, [0.1], 'listeners are gone');
});
+28 -1
View File
@@ -31,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',
@@ -126,6 +127,8 @@ export class LayerPanel {
this._removers = [];
this._destroyed = false;
this._cancelRowControlsRefresh = null;
this._recentImageryFactory = null;
this._recentImageryPanel = null;
}
mount(container) {
if (this._destroyed) return;
@@ -138,8 +141,29 @@ export class LayerPanel {
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));
@@ -156,6 +180,9 @@ export class LayerPanel {
this._releaseBindings();
this._weatherPanel?.destroy();
this._weatherPanel = null;
this._recentImageryPanel?.destroy();
this._recentImageryPanel = null;
this._recentImageryFactory = null;
this._toggleContainer = null;
}
_renderToggles() {
+53
View File
@@ -174,3 +174,56 @@ test('readout rows contain only toggles and metadata; ordinary rows retain contr
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;
}
});
+3
View File
@@ -12,6 +12,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([
{ id: 'data-panel' },
{ id: 'cctv-panel' },
{ id: 'weather-panel' },
{ id: 'recent-imagery-panel' },
{ id: 'radio-panel' },
{ id: 'scene-panel' },
{ id: 'global-context-panel' },
@@ -23,6 +24,7 @@ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([
'data-panel',
'cctv-panel',
'weather-panel',
'recent-imagery-panel',
'scene-panel',
'pp-toggles',
'global-context-panel',
@@ -284,6 +286,7 @@ export class PanelChrome {
'pp-toggles',
'cctv-panel',
'weather-panel',
'recent-imagery-panel',
'global-context-panel',
].includes(panelEl?.id);
const collapsed = panelEl.classList.contains('collapsed');
+7 -1
View File
@@ -95,6 +95,7 @@ export class PanelLayoutController {
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');
}
@@ -195,7 +196,11 @@ export class PanelLayoutController {
this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title');
stack.prepend(this._ppToggles);
const globalContextPanel = document.getElementById('global-context-panel');
for (const panel of [this._cctvPanel, this._weatherPanel]) {
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);
@@ -222,6 +227,7 @@ export class PanelLayoutController {
this._ppToggles,
this._cctvPanel,
this._weatherPanel,
this._recentImageryPanel,
globalContextPanel,
]) {
if (panel) this._rightStackResizeObserver.observe(panel);
+44
View File
@@ -577,3 +577,47 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', ()
);
}
});
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,
);
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18 -4
View File
@@ -163,6 +163,7 @@
#right-context-rail > #pp-toggles,
#right-context-rail > #cctv-panel,
#right-context-rail > #weather-panel,
#right-context-rail > #recent-imagery-panel,
#right-context-rail > #global-context-panel {
position: relative;
inset: auto;
@@ -175,19 +176,23 @@
pointer-events: auto;
}
#right-context-rail #cctv-panel,
#right-context-rail #weather-panel {
#right-context-rail #weather-panel,
#right-context-rail #recent-imagery-panel {
width: 100%;
max-height: 100%;
pointer-events: auto;
}
#right-context-rail > #weather-panel {
#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,
#right-context-rail #weather-panel.collapsed {
#right-context-rail #weather-panel.collapsed,
#right-context-rail #recent-imagery-panel.collapsed {
width: var(--right-display-collapsed-width);
margin-left: auto;
}
@@ -201,6 +206,7 @@
#right-context-rail > #pp-toggles.collapsed .pp-header-row,
#right-context-rail #cctv-panel.collapsed .cctv-panel-inner,
#right-context-rail #weather-panel.collapsed .weather-panel-inner,
#right-context-rail #recent-imagery-panel.collapsed .recent-imagery-panel-inner,
#right-context-rail
#global-context-panel.collapsed
.global-context-panel-inner {
@@ -257,6 +263,7 @@
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
#right-context-rail.layout-focus > #cctv-panel:not(.collapsed),
#right-context-rail.layout-focus > #weather-panel:not(.collapsed),
#right-context-rail.layout-focus > #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);
@@ -275,6 +282,9 @@
#right-context-rail.layout-focus
> #cctv-panel:not(.collapsed)
.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 {
@@ -321,6 +331,9 @@
#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 {
@@ -1364,7 +1377,8 @@ body.recording-mode #right-context-rail > [data-panel-id] {
pointer-events: none !important;
}
@media (max-width: 720px) {
#right-context-rail #weather-panel.collapsed {
#right-context-rail #weather-panel.collapsed,
#right-context-rail #recent-imagery-panel.collapsed {
width: 100%;
}
}
+483
View File
@@ -0,0 +1,483 @@
/* Recent Imagery: a right-rail readout laid out by the rail like WEATHER; only
the body scrolls. Every block has a fixed height, so no click moves a
control. Card typography comes from the rail-card rules in weather.css. */
.recent-imagery-panel-inner {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
max-height: 100%;
box-sizing: border-box;
padding: 14px;
border: 1px solid var(--glass-border);
border-radius: var(--panel-radius);
background: var(--glass-bg);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 1px 0 rgba(255, 255, 255, 0.06) inset;
}
#recent-imagery-panel .panel-glow {
position: absolute;
inset: -18px;
background: radial-gradient(
ellipse at center,
rgba(0, 212, 255, 0.16) 0%,
transparent 65%
);
filter: blur(16px);
opacity: 0.25;
pointer-events: none;
}
#recent-imagery-panel .panel-header {
flex: 0 0 auto;
}
#recent-imagery-panel-count {
color: var(--text-secondary);
font: 9px var(--font-mono);
letter-spacing: 1px;
white-space: nowrap;
}
#recent-imagery-panel-body {
min-height: 0;
max-height: calc(var(--right-stack-max-height, 50svh) - 64px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.46) transparent;
}
#recent-imagery-panel.collapsed #recent-imagery-panel-body,
#recent-imagery-panel.collapsed #recent-imagery-panel-count,
#recent-imagery-panel[hidden],
#recent-imagery-panel [hidden],
#recent-imagery-split-line[hidden] {
display: none !important;
}
#recent-imagery-panel.collapsed .panel-title {
font-size: 8px;
letter-spacing: 1.8px;
}
#recent-imagery-panel.collapsed .panel-header {
margin-bottom: 2px;
}
#recent-imagery-panel.collapsed .recent-imagery-panel-inner {
padding-bottom: 10px;
}
/* ---- the fixed stack ---------------------------------------------------- */
.recent-imagery-readout {
--weather-card-border: rgba(98, 181, 255, 0.28);
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
color: var(--text-dim);
font: 9px/1.45 var(--font-mono);
letter-spacing: 0.5px;
}
.recent-imagery-readout .ri-block {
flex: 0 0 auto;
min-width: 0;
box-sizing: border-box;
}
/* Reserved controls keep their box; they are only invisible. */
.recent-imagery-readout .is-reserved {
visibility: hidden;
pointer-events: none;
}
.recent-imagery-readout .data-toggle-chip {
box-sizing: border-box;
min-width: 0;
height: 22px;
padding: 0 4px;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-imagery-readout .data-toggle-chip:disabled {
cursor: default;
opacity: 0.4;
}
/* 1. SELECT BOX · USE VIEW · CLEAR, three equal cells. */
.recent-imagery-readout .ri-actions-row {
height: 22px;
}
.recent-imagery-readout .ri-actions-row .rail-card-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
margin: 0;
}
.recent-imagery-readout .ri-actions-row .data-toggle-chip {
height: 22px;
}
/* 2 and 3b. One-line notice and hint: always there, empty or not. */
.recent-imagery-readout .ri-line {
height: 14px;
line-height: 14px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* The notice text yields room to ZOOM IN only while it shows; the chip sits
in a fixed slot at the line's right end either way. The ellipsis is a
safety net: every notice is written to fit the panel. */
.recent-imagery-readout .ri-notice {
position: relative;
}
.recent-imagery-readout .ri-notice-text {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.recent-imagery-readout .ri-notice.has-action .ri-notice-text {
padding-right: 52px;
}
.recent-imagery-readout .ri-zoom-in {
position: absolute;
top: 0;
right: 0;
width: 46px;
height: 14px;
padding: 0 3px;
font-size: 8px;
line-height: 12px;
letter-spacing: 0.4px;
color: var(--accent);
border-color: var(--accent);
}
.recent-imagery-readout .ri-notice.warn {
color: rgba(255, 196, 120, 0.95);
}
.recent-imagery-readout .ri-notice.info {
color: var(--accent);
}
.recent-imagery-readout .ri-hint {
color: var(--text-secondary);
}
/* 3. The strip: fixed height, newest day first. */
.recent-imagery-readout .ri-strip {
display: flex;
gap: 6px;
height: 120px;
padding: 2px 2px 6px;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
scrollbar-width: thin;
outline: none;
border-radius: 6px;
}
.recent-imagery-readout .ri-strip:focus-visible {
box-shadow: 0 0 0 1px var(--accent);
}
.recent-imagery-readout .ri-strip.searching {
opacity: 0.7;
}
.recent-imagery-readout .ri-strip-empty {
flex: 1 0 100%;
align-self: center;
text-align: center;
letter-spacing: 1px;
text-transform: uppercase;
}
.recent-imagery-readout .ri-card {
position: relative;
flex: 0 0 94px;
height: 112px;
box-sizing: border-box;
display: grid;
grid-template-rows: 42px 13px 11px 11px 18px;
row-gap: 2px;
padding: 3px 2px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
color: var(--text-secondary);
cursor: pointer;
scroll-snap-align: start;
transition:
border-color var(--transition-fast),
background-color var(--transition-fast);
}
.recent-imagery-readout .ri-card:hover {
border-color: var(--glass-border-hover);
}
.recent-imagery-readout .ri-card.focused {
border-color: var(--accent);
background: var(--accent-dim);
}
.recent-imagery-readout .ri-card.preview {
box-shadow: inset 0 0 0 1px rgba(139, 233, 255, 0.35);
}
.recent-imagery-readout .ri-card.pinned {
box-shadow: inset 0 0 0 1px rgba(139, 233, 255, 0.75);
}
.recent-imagery-readout .ri-thumb {
position: relative;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 4px;
background: rgba(120, 130, 140, 0.18);
}
.recent-imagery-readout .ri-thumb[data-status='empty'] {
background: rgba(90, 96, 104, 0.22);
}
.recent-imagery-readout .ri-thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.recent-imagery-readout .ri-thumb-text {
padding: 0 4px;
text-align: center;
font-size: 8px;
}
.recent-imagery-readout .ri-card-flag,
.recent-imagery-readout .ri-card-start {
position: absolute;
padding: 0 4px;
border-radius: 3px;
background: rgba(4, 17, 27, 0.9);
font-size: 7px;
font-weight: 700;
line-height: 12px;
letter-spacing: 0.8px;
white-space: nowrap;
}
.recent-imagery-readout .ri-card-flag {
top: 3px;
left: 3px;
color: var(--accent);
}
.recent-imagery-readout .ri-card-start {
right: 3px;
bottom: 3px;
color: rgba(255, 196, 120, 0.95);
}
.recent-imagery-readout .ri-card-date,
.recent-imagery-readout .ri-card-sensor,
.recent-imagery-readout .ri-card-cloud {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.recent-imagery-readout .ri-card-date {
font-size: 10px;
line-height: 13px;
color: var(--text-primary);
}
.recent-imagery-readout .ri-card-sensor,
.recent-imagery-readout .ri-card-cloud {
font-size: 8px;
line-height: 11px;
letter-spacing: 0;
color: var(--text-dim);
}
.recent-imagery-readout .ri-card-chips {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
}
.recent-imagery-readout .ri-card-chips .data-toggle-chip {
height: 18px;
font-weight: 700;
}
/* 4. The selection bar: mode, then two rows that are always there. */
.recent-imagery-readout .ri-selection {
display: grid;
grid-template-rows: 20px 20px 20px;
row-gap: 4px;
padding: 5px;
border: 1px solid var(--weather-card-border);
border-radius: 6px;
}
.recent-imagery-readout .ri-mode {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
}
.recent-imagery-readout .ri-selection .data-toggle-chip {
height: 20px;
}
.recent-imagery-readout .ri-slot {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 22px;
align-items: center;
column-gap: 6px;
height: 20px;
}
.recent-imagery-readout .ri-slot-tag {
height: 16px;
line-height: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 3px;
text-align: center;
font-size: 8px;
font-weight: 700;
letter-spacing: 1px;
white-space: nowrap;
}
.recent-imagery-readout .ri-slot.lit .ri-slot-tag {
border-color: var(--accent);
color: var(--accent);
}
.recent-imagery-readout .ri-slot-value {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
letter-spacing: 0;
color: var(--text-dim);
}
.recent-imagery-readout .ri-slot.lit .ri-slot-value {
color: var(--text-primary);
}
.recent-imagery-readout .ri-slot[data-state='preview'] .ri-slot-value {
color: var(--text-secondary);
font-style: italic;
}
.recent-imagery-readout .ri-unpin {
width: 22px;
padding: 0;
font-size: 12px;
letter-spacing: 0;
}
/* 5. Opacity (both images), SWAP and export, one row. SWAP keeps its slot
while no swipe is live. */
.recent-imagery-readout .ri-controls {
display: grid;
grid-template-columns: 76px minmax(0, 1fr) 40px 128px;
align-items: center;
column-gap: 6px;
height: 22px;
}
.recent-imagery-readout .ri-opacity-label {
display: flex;
justify-content: space-between;
gap: 4px;
font-size: 8px;
letter-spacing: 1px;
white-space: nowrap;
}
.recent-imagery-readout .ri-opacity-value {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.recent-imagery-readout .ri-opacity {
width: 100%;
min-width: 0;
margin: 0;
accent-color: var(--accent);
}
.recent-imagery-readout .ri-opacity:disabled {
opacity: 0.35;
}
.recent-imagery-readout .ri-swap {
width: 40px;
padding: 0 2px;
}
.recent-imagery-readout .ri-exports .rail-card-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
margin: 0;
}
/* 6. DETAILS: a collapsed rail card at the bottom, for every note. */
.recent-imagery-readout .ri-details .rail-card {
border-color: var(--weather-card-border);
}
.recent-imagery-readout .ri-details .rail-card-title {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 1.2px;
text-transform: uppercase;
}
.recent-imagery-readout .ri-details .rail-card-line.muted {
color: var(--text-dim);
}
.recent-imagery-readout .ri-details .rail-card-actions .data-toggle-chip {
height: 20px;
padding: 0 7px;
}
/* ---- box tool cursor ---------------------------------------------------- */
body.gev-imagery-box #cesium-container canvas,
body.gev-imagery-box .cesium-widget canvas {
cursor: crosshair;
}
/* ---- split line --------------------------------------------------------- */
/* Above the right rail (z-index 110) so the handle can always be grabbed. */
#recent-imagery-split-line {
position: fixed;
top: 0;
bottom: 0;
left: var(--recent-imagery-split, 50%);
z-index: 130;
width: 1px;
pointer-events: none;
background: rgba(218, 241, 247, 0.76);
box-shadow: 0 0 12px rgba(94, 216, 239, 0.72);
}
#recent-imagery-split-line span {
position: absolute;
top: 14px;
padding: 2px 6px;
border-radius: 3px;
background: rgba(4, 17, 27, 0.82);
color: rgba(239, 247, 249, 0.92);
font: 700 9px/1.4 var(--font-mono);
letter-spacing: 1.2px;
white-space: nowrap;
pointer-events: none;
}
#recent-imagery-split-line span.before {
right: 8px;
}
#recent-imagery-split-line span.after {
left: 8px;
}
#recent-imagery-split-line .recent-imagery-split-handle {
position: absolute;
top: 50%;
left: 50%;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid rgba(180, 215, 222, 0.72);
border-radius: 50%;
outline: 0;
color: rgba(239, 247, 249, 0.96);
background: rgba(4, 17, 27, 0.96);
box-shadow:
0 0 0 5px rgba(62, 112, 125, 0.3),
0 0 13px rgba(94, 216, 239, 0.3);
font: 700 14px/1 var(--font-mono);
transform: translate(-50%, -50%);
pointer-events: auto;
touch-action: none;
cursor: ew-resize;
}
#recent-imagery-split-line .recent-imagery-split-handle:focus-visible {
outline: 2px solid #fff;
outline-offset: 4px;
}
@media (max-width: 720px) {
#recent-imagery-panel-body {
max-height: 48vh;
}
}
+2
View File
@@ -218,6 +218,8 @@
#right-context-rail #pp-toggles.collapsed .pp-header-row,
#right-context-rail #cctv-panel,
#right-context-rail #cctv-panel.collapsed,
#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,
+12
View File
@@ -12,6 +12,18 @@
<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">