mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
25491f7e33
commit
082074a006
50 files changed
+13169
-440
No files matched your search
@@ -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');
|
||||
});
|
||||
+28
-1
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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">
|
||||
|
||||
Reference in new issue
Block a user