mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(ui): render the imagery split dividers from a store
createImagerySplit keeps the divider's value, drag, keyboard, CSS position and scene split, and publishes its line to the imagery split store. ImagerySplitView, in the scene chrome, teleports each line to the body, so recent imagery and the Bhote Koshi event no longer build divider DOM.
This commit is contained in:
1 parent
0b8cb4f33f
commit
492b719f19
12 files changed
+402
-201
No files matched your search
+3
-1
@@ -206,7 +206,9 @@ screen (`#loading-screen` with a `.loader-status` line) as static markup so it
|
||||
paints before any script runs. `createApplication()` renders the views before
|
||||
anything reads the page; `omitViews` names views the application leaves out,
|
||||
such as `provider-settings`, and their hosts are removed. Leaving out
|
||||
`layer-panels` also leaves out the scene sharing dialogs it draws. An unknown
|
||||
`layer-panels` also leaves out the scene sharing dialogs it draws, and leaving
|
||||
out `scene-chrome` the before/after imagery dividers: the imagery still splits,
|
||||
with no line or handle to move it. An unknown
|
||||
view name is an error. A host that does not initialize Core's locale passes
|
||||
`localePicker: false` to `createApplicationControls` instead of editing the
|
||||
markup. Node tests that import application modules register the component
|
||||
|
||||
@@ -139,7 +139,11 @@ a feature supplying only its accent.
|
||||
phone shell (`PhoneTabBar` renders the tabs and open sheet the shell
|
||||
publishes to the `gev-display` store, teleporting the tab bar and sheet
|
||||
backdrop to the end of the page from the first publish until the shell
|
||||
is destroyed; the shell takes their clicks from the document), then
|
||||
is destroyed; the shell takes their clicks from the document), the
|
||||
imagery dividers (`ImagerySplitView`, in the scene chrome, teleports each
|
||||
before/after divider that `createImagerySplit` publishes to the
|
||||
`gev-imagery-split` store to the body; the split keeps the
|
||||
drag, keyboard, CSS position and scene split), then
|
||||
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
|
||||
pages, cycle toggle, regional news and local conditions, and
|
||||
`CockpitContext` the Contact card's scope, navigation, counts, nearest
|
||||
|
||||
@@ -180,6 +180,8 @@
|
||||
"src/ui/panelDisclosure.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
"src/ui/imagerySplit.test.mjs",
|
||||
"src/ui/imagerySplitView.test.mjs",
|
||||
"src/ui/stores/imagerySplit.js",
|
||||
"src/ui/surfaceKeyboard.js",
|
||||
"src/ui/surfaceKeyboard.test.mjs",
|
||||
"src/ui/panelRails.js",
|
||||
|
||||
@@ -92,7 +92,9 @@
|
||||
"src/ui/rail/focusSafeChildren.js",
|
||||
"src/ui/rail/fieldValue.js",
|
||||
"src/ui/rail/statusText.js",
|
||||
"src/voice/control.js"
|
||||
"src/voice/control.js",
|
||||
"src/ui/views/panels/ImagerySplitView.vue",
|
||||
"src/ui/stores/imagerySplit.js"
|
||||
],
|
||||
"external": ["vue", "pinia"]
|
||||
},
|
||||
@@ -2992,7 +2994,8 @@
|
||||
"src/ui/stores/sceneSharing.js",
|
||||
"src/ui/stores/voice.js",
|
||||
"src/ui/stores/location.js",
|
||||
"src/ui/stores/display.js"
|
||||
"src/ui/stores/display.js",
|
||||
"src/ui/stores/imagerySplit.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3578,7 +3581,9 @@
|
||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||
"src/data/geoImportImages.js",
|
||||
"src/data/geoImportPins.js",
|
||||
"src/data/geoFileImport.js"
|
||||
"src/data/geoFileImport.js",
|
||||
"src/ui/stores/imagerySplit.js",
|
||||
"src/ui/vueRuntime.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3589,7 +3594,9 @@
|
||||
"pbf",
|
||||
"mgrs",
|
||||
"mapillary-js",
|
||||
"@jtarrio/webrtlsdr"
|
||||
"@jtarrio/webrtlsdr",
|
||||
"vue",
|
||||
"pinia"
|
||||
]
|
||||
},
|
||||
"application-operations": {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { claimPointer, releasePointer } from './inputOwnership.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { useImagerySplitStore } from '../ui/stores/imagerySplit.js';
|
||||
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||
import { SOURCE_PATH_BEAT_IDS } from './bhoteKoshiShotPaths.js';
|
||||
import {
|
||||
BHOTE_KOSHI_LAYER_ID,
|
||||
@@ -36,6 +38,12 @@ import { SceneDirector } from '../scenes/director.js';
|
||||
const eventUrl = new URL('../../public/events/bhote-koshi-2026/event.json', import.meta.url);
|
||||
const eventModuleUrl = new URL('./bhoteKoshiEvent.js', import.meta.url);
|
||||
|
||||
/** The event's divider as the imagery split store holds it, or undefined. */
|
||||
const eventSplit = () =>
|
||||
useImagerySplitStore(applicationPinia()).splits.findLast(
|
||||
(split) => split.view.id === 'bhote-koshi-split-line',
|
||||
);
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = 'div') {
|
||||
this.tagName = tagName;
|
||||
@@ -840,6 +848,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
const { body, removedProperties } = installEventDom();
|
||||
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
|
||||
const clearedTimeouts = [];
|
||||
globalThis.window = {
|
||||
setTimeout: () => 71,
|
||||
@@ -879,7 +888,8 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
assert.equal(await layer.enable(viewer, { origin: 'scene' }), true);
|
||||
assert.equal(viewer._test.imagery.length, 2);
|
||||
assert.equal(viewer._test.dataSources.length, 1);
|
||||
assert.equal(body.children.length, 2);
|
||||
assert.equal(body.children.length, 1);
|
||||
assert.ok(eventSplit());
|
||||
|
||||
activeMap = 'operator-choice';
|
||||
generation += 1;
|
||||
@@ -889,6 +899,7 @@ test('Bhote Koshi disable preserves an operator-selected map and tears down ever
|
||||
assert.equal(viewer._test.removedImagery.length, 2);
|
||||
assert.equal(viewer._test.removedDataSources.length, 1);
|
||||
assert.ok(body.children.every((element) => element.removed));
|
||||
assert.equal(eventSplit(), undefined);
|
||||
assert.equal(viewer.scene.splitPosition, 0.37);
|
||||
assert.deepEqual(clearedTimeouts, [71]);
|
||||
assert.ok(overlayCalls.some(([kind]) => kind === 'clear'));
|
||||
@@ -1075,6 +1086,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
const { body } = installEventDom();
|
||||
assert.equal(eventSplit(), undefined, 'no divider is left from an earlier test');
|
||||
const timers = [];
|
||||
const openedUrls = [];
|
||||
const cancelledFrames = [];
|
||||
@@ -1183,9 +1195,8 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
|
||||
const panel = body.children.find((element) => element.tagName === 'aside');
|
||||
assert.ok(panel);
|
||||
const splitLine = body.children.find((element) => element.id === 'bhote-koshi-split-line');
|
||||
assert.ok(splitLine);
|
||||
assert.equal(splitLine.hidden, true);
|
||||
assert.ok(eventSplit());
|
||||
assert.equal(eventSplit().view.hidden, true);
|
||||
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, true);
|
||||
assert.match(panel.querySelector('[data-role="caveat"]').textContent, /SCHEMATIC CORRIDOR/i);
|
||||
assert.doesNotMatch(panel.querySelector('[data-role="caveat"]').textContent, /historical/i);
|
||||
@@ -1240,30 +1251,27 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
beatId: 'timure-cluster',
|
||||
sceneControls: { imageryComparison: true },
|
||||
}, { origin: 'scene' });
|
||||
assert.equal(splitLine.hidden, false);
|
||||
assert.equal(eventSplit().view.hidden, false);
|
||||
assert.equal(panel.querySelector('[data-role="imagery-comparison"]').hidden, false);
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === true));
|
||||
const splitHandle = splitLine.children.find(
|
||||
(element) => element.className === 'bhote-koshi-split-handle',
|
||||
);
|
||||
assert.ok(splitHandle);
|
||||
assert.equal(splitHandle.textContent, '↔');
|
||||
assert.equal(splitHandle.getAttribute('aria-valuenow'), '62');
|
||||
splitHandle.dispatch('pointerdown', {
|
||||
assert.equal(eventSplit().view.handleClass, 'bhote-koshi-split-handle');
|
||||
assert.equal(eventSplit().view.valueNow, 62);
|
||||
const splitHandle = eventSplit().controls;
|
||||
splitHandle.pointerdown({
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
splitHandle.dispatch('pointermove', {
|
||||
splitHandle.pointermove({
|
||||
clientX: 750,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
splitHandle.dispatch('pointerup', { pointerId: 4 });
|
||||
splitHandle.pointerup({ pointerId: 4 });
|
||||
assert.equal(viewer.scene.splitPosition, 0.75);
|
||||
assert.equal(splitHandle.getAttribute('aria-valuenow'), '75');
|
||||
splitHandle.dispatch('keydown', {
|
||||
assert.equal(eventSplit().view.valueNow, 75);
|
||||
splitHandle.keydown({
|
||||
key: 'ArrowLeft',
|
||||
shiftKey: true,
|
||||
preventDefault() {},
|
||||
@@ -1276,7 +1284,7 @@ test('Bhote Koshi scene beats preserve photoreal and never take camera ownership
|
||||
split: 0.62,
|
||||
sceneControls: {},
|
||||
}, { origin: 'scene' });
|
||||
assert.equal(splitLine.hidden, true);
|
||||
assert.equal(eventSplit().view.hidden, true);
|
||||
assert.ok(viewer._test.imagery.every((imageryLayer) => imageryLayer.show === false));
|
||||
assert.equal(viewer.scene.splitPosition, 0.62);
|
||||
assert.equal(overlayVisibility.at(-1).visible, false);
|
||||
|
||||
+84
-86
@@ -1,3 +1,7 @@
|
||||
import { useImagerySplitStore } from './stores/imagerySplit.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
|
||||
let splitCount = 0;
|
||||
const HANDLE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']);
|
||||
|
||||
function clampUnit(value) {
|
||||
@@ -19,17 +23,17 @@ function defaultValueText(beforePercent, afterPercent) {
|
||||
/**
|
||||
* 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.
|
||||
* Owns the divider's value, pointer capture, keyboard, ARIA, a CSS custom
|
||||
* property and scene.splitPosition, and publishes the line to the imagery
|
||||
* split store, where the ImagerySplitView renders it as a child of the body.
|
||||
* `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 withdraws the 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,
|
||||
@@ -44,34 +48,40 @@ export function createImagerySplit({
|
||||
getViewportWidth = defaultViewportWidth,
|
||||
onChange,
|
||||
requestRender,
|
||||
store = useImagerySplitStore(applicationPinia()),
|
||||
} = {}) {
|
||||
const key = ++splitCount;
|
||||
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 hidden = false;
|
||||
let dragging = false;
|
||||
let pointerId = null;
|
||||
// The handle that holds the drag's pointer capture.
|
||||
let captor = null;
|
||||
let destroyed = false;
|
||||
|
||||
function publish() {
|
||||
if (destroyed) return;
|
||||
const beforePercent = Math.round(value * 100);
|
||||
store.publish(
|
||||
key,
|
||||
{
|
||||
id: id ?? null,
|
||||
hidden,
|
||||
handleClass: handleClass ?? null,
|
||||
beforeLabel,
|
||||
afterLabel,
|
||||
beforeTitle: beforeTitle ?? null,
|
||||
afterTitle: afterTitle ?? null,
|
||||
ariaLabel: ariaLabel ?? null,
|
||||
valueNow: beforePercent,
|
||||
valueText: formatValueText(beforePercent, 100 - beforePercent),
|
||||
},
|
||||
controls,
|
||||
);
|
||||
}
|
||||
|
||||
function apply(next, { notify = false, render = true } = {}) {
|
||||
const clamped = clampUnit(next);
|
||||
const changed = clamped !== value;
|
||||
@@ -82,12 +92,7 @@ export function createImagerySplit({
|
||||
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) publish();
|
||||
if (changed && render) requestRender?.();
|
||||
if (changed && notify) onChange?.(value);
|
||||
return changed;
|
||||
@@ -99,53 +104,45 @@ export function createImagerySplit({
|
||||
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);
|
||||
}
|
||||
// The view's handle reports its input here.
|
||||
const controls = {
|
||||
pointerdown(event) {
|
||||
if (destroyed) return;
|
||||
if (event.button != null && event.button !== 0) return;
|
||||
event.preventDefault?.();
|
||||
dragging = true;
|
||||
pointerId = event.pointerId ?? null;
|
||||
captor = event.currentTarget ?? null;
|
||||
captor?.setPointerCapture?.(event.pointerId);
|
||||
setFromClientX(event.clientX);
|
||||
},
|
||||
pointermove(event) {
|
||||
if (!dragging) return;
|
||||
if (pointerId != null && event.pointerId !== pointerId) return;
|
||||
event.preventDefault?.();
|
||||
setFromClientX(event.clientX);
|
||||
},
|
||||
pointerup(event) {
|
||||
if (!dragging) return;
|
||||
if (pointerId != null && event.pointerId !== pointerId) return;
|
||||
captor?.releasePointerCapture?.(event.pointerId);
|
||||
dragging = false;
|
||||
pointerId = null;
|
||||
captor = null;
|
||||
},
|
||||
keydown(event) {
|
||||
if (destroyed || !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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -157,18 +154,19 @@ export function createImagerySplit({
|
||||
return apply(next);
|
||||
},
|
||||
setVisible(visible) {
|
||||
line.hidden = !visible;
|
||||
if (hidden === !visible) return;
|
||||
hidden = !visible;
|
||||
publish();
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
if (dragging && pointerId != null)
|
||||
handle.releasePointerCapture?.(pointerId);
|
||||
captor?.releasePointerCapture?.(pointerId);
|
||||
dragging = false;
|
||||
pointerId = null;
|
||||
for (const [type, listener] of listeners)
|
||||
handle.removeEventListener?.(type, listener);
|
||||
line.remove();
|
||||
captor = null;
|
||||
destroyed = true;
|
||||
store.withdraw(key);
|
||||
if (cssProperty) cssTarget.style.removeProperty(cssProperty);
|
||||
if (scene && restoreValue != null) scene.splitPosition = restoreValue;
|
||||
},
|
||||
|
||||
@@ -2,51 +2,35 @@ 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;
|
||||
}
|
||||
/** The handle the view renders: a pointer capture target. */
|
||||
function fakeHandle() {
|
||||
return {
|
||||
captured: [],
|
||||
released: [],
|
||||
setPointerCapture(id) {
|
||||
this.captured.push(id);
|
||||
},
|
||||
releasePointerCapture(id) {
|
||||
this.released.push(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The imagery split store as the split sees it. */
|
||||
function fakeStore() {
|
||||
const lines = new Map();
|
||||
return {
|
||||
lines,
|
||||
publish(key, view, controls) {
|
||||
lines.set(key, { view, controls });
|
||||
},
|
||||
withdraw(key) {
|
||||
lines.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) {
|
||||
const body = new FakeElement('body');
|
||||
const cssCalls = [];
|
||||
const changes = [];
|
||||
let renders = 0;
|
||||
@@ -61,15 +45,9 @@ function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) {
|
||||
},
|
||||
},
|
||||
};
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
body,
|
||||
createElement: (tagName) => new FakeElement(tagName),
|
||||
documentElement: cssTarget,
|
||||
};
|
||||
const store = fakeStore();
|
||||
const split = createImagerySplit({
|
||||
scene,
|
||||
parent: body,
|
||||
initialValue,
|
||||
id: 'split-line',
|
||||
handleClass: 'split-handle',
|
||||
@@ -83,37 +61,45 @@ function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) {
|
||||
requestRender: () => {
|
||||
renders += 1;
|
||||
},
|
||||
store,
|
||||
});
|
||||
globalThis.document = previous;
|
||||
const line = body.children[0];
|
||||
const handle = line.children.find((child) => child.tagName === 'button');
|
||||
const [key] = store.lines.keys();
|
||||
const line = () => store.lines.get(key) ?? null;
|
||||
const handle = fakeHandle();
|
||||
// The view's handle dispatching to the split, as Vue's listeners do.
|
||||
const dispatch = (type, event = {}) =>
|
||||
line()?.controls[type === 'pointercancel' ? 'pointerup' : type]({
|
||||
currentTarget: handle,
|
||||
...event,
|
||||
});
|
||||
return {
|
||||
split,
|
||||
scene,
|
||||
body,
|
||||
store,
|
||||
line,
|
||||
view: () => line()?.view,
|
||||
handle,
|
||||
dispatch,
|
||||
cssCalls,
|
||||
changes,
|
||||
renders: () => renders,
|
||||
};
|
||||
}
|
||||
|
||||
test('the split builds its line and applies the initial value without notifying', () => {
|
||||
test('the split publishes 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.deepEqual(env.view(), {
|
||||
id: 'split-line',
|
||||
hidden: false,
|
||||
handleClass: 'split-handle',
|
||||
beforeLabel: 'A',
|
||||
afterLabel: 'B',
|
||||
beforeTitle: 'Before',
|
||||
afterTitle: 'After',
|
||||
ariaLabel: 'Divider',
|
||||
valueNow: 62,
|
||||
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, []);
|
||||
@@ -125,7 +111,7 @@ 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', {
|
||||
env.dispatch('keydown', {
|
||||
key,
|
||||
shiftKey,
|
||||
preventDefault() {
|
||||
@@ -140,10 +126,10 @@ test('keyboard steps 1 percent, 5 percent with Shift, and jumps to Home and End'
|
||||
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(env.view().valueNow, 0);
|
||||
assert.equal(key('End'), true);
|
||||
assert.equal(env.scene.splitPosition, 1);
|
||||
assert.equal(env.handle.getAttribute('aria-valuenow'), '100');
|
||||
assert.equal(env.view().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]);
|
||||
@@ -168,15 +154,15 @@ test('values clamp to the unit range and only real changes notify or render', ()
|
||||
[],
|
||||
'setValue is the owner writing; it never echoes',
|
||||
);
|
||||
env.handle.dispatch('keydown', { key: 'Home', preventDefault() {} });
|
||||
env.handle.dispatch('keydown', { key: 'ArrowLeft', preventDefault() {} });
|
||||
env.dispatch('keydown', { key: 'Home', preventDefault() {} });
|
||||
env.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() {} });
|
||||
env.dispatch('keydown', { key: 'End', preventDefault() {} });
|
||||
env.dispatch('keydown', { key: 'End', preventDefault() {} });
|
||||
assert.deepEqual(env.changes, [1]);
|
||||
assert.equal(env.renders(), 3);
|
||||
assert.deepEqual(env.cssCalls, [
|
||||
@@ -189,7 +175,7 @@ test('values clamp to the unit range and only real changes notify or render', ()
|
||||
|
||||
test('pointer drags follow the primary pointer only', () => {
|
||||
const env = fixture();
|
||||
env.handle.dispatch('pointerdown', {
|
||||
env.dispatch('pointerdown', {
|
||||
button: 2,
|
||||
clientX: 900,
|
||||
pointerId: 1,
|
||||
@@ -200,7 +186,7 @@ test('pointer drags follow the primary pointer only', () => {
|
||||
0.5,
|
||||
'non-primary buttons never start a drag',
|
||||
);
|
||||
env.handle.dispatch('pointermove', {
|
||||
env.dispatch('pointermove', {
|
||||
clientX: 900,
|
||||
pointerId: 1,
|
||||
preventDefault() {},
|
||||
@@ -210,7 +196,7 @@ test('pointer drags follow the primary pointer only', () => {
|
||||
0.5,
|
||||
'moves without a drag are ignored',
|
||||
);
|
||||
env.handle.dispatch('pointerdown', {
|
||||
env.dispatch('pointerdown', {
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
pointerId: 4,
|
||||
@@ -218,7 +204,7 @@ test('pointer drags follow the primary pointer only', () => {
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.25);
|
||||
assert.deepEqual(env.handle.captured, [4]);
|
||||
env.handle.dispatch('pointermove', {
|
||||
env.dispatch('pointermove', {
|
||||
clientX: 900,
|
||||
pointerId: 9,
|
||||
preventDefault() {},
|
||||
@@ -228,8 +214,8 @@ test('pointer drags follow the primary pointer only', () => {
|
||||
0.25,
|
||||
'a different pointer id is ignored',
|
||||
);
|
||||
env.handle.dispatch('pointerup', { pointerId: 9 });
|
||||
env.handle.dispatch('pointermove', {
|
||||
env.dispatch('pointerup', { pointerId: 9 });
|
||||
env.dispatch('pointermove', {
|
||||
clientX: 750,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
@@ -239,7 +225,7 @@ test('pointer drags follow the primary pointer only', () => {
|
||||
0.75,
|
||||
'the drag survives a foreign pointerup',
|
||||
);
|
||||
env.handle.dispatch('pointermove', {
|
||||
env.dispatch('pointermove', {
|
||||
clientX: Number.NaN,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
@@ -249,9 +235,9 @@ test('pointer drags follow the primary pointer only', () => {
|
||||
0.75,
|
||||
'unusable coordinates are ignored',
|
||||
);
|
||||
env.handle.dispatch('pointerup', { pointerId: 4 });
|
||||
env.dispatch('pointerup', { pointerId: 4 });
|
||||
assert.deepEqual(env.handle.released, [4]);
|
||||
env.handle.dispatch('pointermove', {
|
||||
env.dispatch('pointermove', {
|
||||
clientX: 100,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
@@ -263,23 +249,24 @@ test('pointer drags follow the primary pointer only', () => {
|
||||
test('visibility toggles the line without touching the value', () => {
|
||||
const env = fixture();
|
||||
env.split.setVisible(false);
|
||||
assert.equal(env.line.hidden, true);
|
||||
assert.equal(env.view().hidden, true);
|
||||
env.split.setVisible(true);
|
||||
assert.equal(env.line.hidden, false);
|
||||
assert.equal(env.view().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', () => {
|
||||
test('destroy withdraws 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', {
|
||||
env.dispatch('pointerdown', {
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
pointerId: 2,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.1);
|
||||
const { controls } = env.line();
|
||||
env.split.destroy();
|
||||
assert.equal(env.line.removed, true);
|
||||
assert.equal(env.line(), null);
|
||||
assert.deepEqual(
|
||||
env.handle.released,
|
||||
[2],
|
||||
@@ -287,7 +274,6 @@ test('destroy removes the line, clears the CSS property, restores the scene spli
|
||||
);
|
||||
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(
|
||||
@@ -302,6 +288,8 @@ test('destroy removes the line, clears the CSS property, restores the scene spli
|
||||
'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');
|
||||
controls.keydown({ key: 'End', preventDefault() {} });
|
||||
controls.pointerdown({ button: 0, clientX: 900, pointerId: 3 });
|
||||
assert.deepEqual(env.changes, [0.1], 'its controls are inert');
|
||||
assert.equal(env.store.lines.size, 0, 'nothing republishes the line');
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// src/ui/imagerySplitView.test.mjs — the imagery dividers render from the
|
||||
// imagery split store as children of the body, over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useImagerySplitStore } from './stores/imagerySplit.js';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
import ImagerySplitView from './views/panels/ImagerySplitView.vue';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(ImagerySplitView).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useImagerySplitStore(pinia);
|
||||
const scene = { splitPosition: 0.37 };
|
||||
const changes = [];
|
||||
const split = (options = {}) =>
|
||||
createImagerySplit({
|
||||
scene,
|
||||
id: 'split-line',
|
||||
handleClass: 'split-handle',
|
||||
cssProperty: '--split',
|
||||
beforeTitle: 'Before',
|
||||
afterTitle: 'After',
|
||||
ariaLabel: 'Divider',
|
||||
getViewportWidth: () => 1000,
|
||||
onChange: (value) => changes.push(value),
|
||||
store,
|
||||
...options,
|
||||
});
|
||||
return { split, scene, changes };
|
||||
}
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
|
||||
test('the divider renders on the body with its labels and slider', async (t) => {
|
||||
const { split } = fixture(t);
|
||||
split({ initialValue: 0.62 });
|
||||
await nextTick();
|
||||
const line = $('#split-line');
|
||||
assert.equal(line.parentElement, document.body);
|
||||
const [before, handle, after] = line.children;
|
||||
assert.equal(before.className, 'before');
|
||||
assert.equal(before.textContent, 'A');
|
||||
assert.equal(before.title, 'Before');
|
||||
assert.equal(after.className, 'after');
|
||||
assert.equal(after.textContent, 'B');
|
||||
assert.equal(after.title, 'After');
|
||||
assert.equal(handle.tagName, 'BUTTON');
|
||||
assert.equal(handle.type, 'button');
|
||||
assert.equal(handle.className, 'split-handle');
|
||||
assert.equal(handle.textContent, '↔');
|
||||
assert.equal(handle.getAttribute('role'), 'slider');
|
||||
assert.equal(handle.getAttribute('aria-label'), 'Divider');
|
||||
assert.equal(handle.getAttribute('aria-valuemin'), '0');
|
||||
assert.equal(handle.getAttribute('aria-valuemax'), '100');
|
||||
assert.equal(handle.getAttribute('aria-orientation'), 'horizontal');
|
||||
assert.equal(handle.getAttribute('aria-valuenow'), '62');
|
||||
assert.equal(
|
||||
handle.getAttribute('aria-valuetext'),
|
||||
'A 62 percent, B 38 percent',
|
||||
);
|
||||
assert.equal(
|
||||
document.documentElement.style.getPropertyValue('--split'),
|
||||
'62%',
|
||||
);
|
||||
});
|
||||
|
||||
test('the handle steps by keyboard and drags with the pointer it captured', async (t) => {
|
||||
const { split, scene, changes } = fixture(t);
|
||||
split();
|
||||
await nextTick();
|
||||
const handle = $('#split-line button');
|
||||
const captured = [];
|
||||
const released = [];
|
||||
handle.setPointerCapture = (id) => captured.push(id);
|
||||
handle.releasePointerCapture = (id) => released.push(id);
|
||||
handle.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal(scene.splitPosition, 0.51);
|
||||
assert.equal(handle.getAttribute('aria-valuenow'), '51');
|
||||
const pointer = (type, init) =>
|
||||
handle.dispatchEvent(
|
||||
new PointerEvent(type, { bubbles: true, pointerId: 7, ...init }),
|
||||
);
|
||||
pointer('pointerdown', { button: 0, clientX: 250 });
|
||||
pointer('pointermove', { clientX: 800 });
|
||||
pointer('pointercancel', {});
|
||||
pointer('pointermove', { clientX: 100 });
|
||||
await nextTick();
|
||||
assert.equal(scene.splitPosition, 0.8);
|
||||
assert.deepEqual(captured, [7]);
|
||||
assert.deepEqual(released, [7]);
|
||||
assert.deepEqual(changes, [0.51, 0.25, 0.8]);
|
||||
assert.equal(handle.getAttribute('aria-valuenow'), '80');
|
||||
});
|
||||
|
||||
test('hiding and destroying a divider leave other dividers in place', async (t) => {
|
||||
const { split } = fixture(t);
|
||||
const first = split();
|
||||
const second = split({ id: 'second-line' });
|
||||
await nextTick();
|
||||
first.setVisible(false);
|
||||
await nextTick();
|
||||
assert.equal($('#split-line').hidden, true);
|
||||
assert.equal($('#second-line').hidden, false);
|
||||
first.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#split-line'), null);
|
||||
assert.ok($('#second-line'));
|
||||
second.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#second-line'), null);
|
||||
});
|
||||
|
||||
test('a divider created before the view mounts renders once it does', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const split = createImagerySplit({
|
||||
id: 'early-line',
|
||||
getViewportWidth: () => 1000,
|
||||
store: useImagerySplitStore(pinia),
|
||||
});
|
||||
const app = createApp(ImagerySplitView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
await nextTick();
|
||||
assert.equal($('#early-line').parentElement, document.body);
|
||||
split.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#early-line'), null);
|
||||
});
|
||||
@@ -705,7 +705,6 @@ export function createRecentImageryPanel({
|
||||
if (!live || split || destroyed) return;
|
||||
split = createSplit({
|
||||
scene: viewer?.scene,
|
||||
parent: document.body,
|
||||
initialValue: snapshot.split,
|
||||
id: 'recent-imagery-split-line',
|
||||
handleClass: 'recent-imagery-split-handle',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* The before/after imagery dividers on the page, as their view renders them.
|
||||
* Each split (src/ui/imagerySplit.js) publishes its line under its own key
|
||||
* and withdraws it when destroyed; the view sends pointer and keyboard input
|
||||
* back to that split's controls.
|
||||
*/
|
||||
export const useImagerySplitStore = defineStore('gev-imagery-split', () => {
|
||||
/** The dividers in creation order: `{key, view, controls}`. */
|
||||
const splits = shallowRef([]);
|
||||
|
||||
/**
|
||||
* Show or update one divider.
|
||||
* @param {number} key The split's own key.
|
||||
* @param {object} view A fresh presentation of its line.
|
||||
* @param {object} controls What the view calls for its input.
|
||||
*/
|
||||
function publish(key, view, controls) {
|
||||
const entry = markRaw({ key, view, controls });
|
||||
const index = splits.value.findIndex((split) => split.key === key);
|
||||
splits.value =
|
||||
index < 0
|
||||
? [...splits.value, entry]
|
||||
: splits.value.map((split, at) => (at === index ? entry : split));
|
||||
}
|
||||
|
||||
/** @param {number} key The split to remove. */
|
||||
function withdraw(key) {
|
||||
splits.value = splits.value.filter((split) => split.key !== key);
|
||||
}
|
||||
|
||||
return { splits, publish, withdraw };
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { useDisplayStore } from '../stores/display.js';
|
||||
import ImagerySplitView from './panels/ImagerySplitView.vue';
|
||||
|
||||
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
||||
const { style } = storeToRefs(useDisplayStore());
|
||||
@@ -69,4 +70,6 @@ const { style } = storeToRefs(useDisplayStore());
|
||||
<div id="safe-frame-overlay">
|
||||
<div id="safe-frame-box"></div>
|
||||
</div>
|
||||
|
||||
<ImagerySplitView />
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useImagerySplitStore } from '../../stores/imagerySplit.js';
|
||||
|
||||
// The before/after imagery dividers, as their splits (src/ui/imagerySplit.js)
|
||||
// present them. Each line is a direct child of the body, above the rails; its
|
||||
// handle reports pointer and keyboard input back to its split.
|
||||
const { splits } = storeToRefs(useImagerySplitStore());
|
||||
const optional = (value) => value ?? undefined;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-for="split in splits" :key="split.key" :id="optional(split.view.id)" :hidden="split.view.hidden"><span class="before" :title="optional(split.view.beforeTitle)">{{ split.view.beforeLabel }}</span><button type="button" :class="optional(split.view.handleClass)" role="slider" :aria-label="optional(split.view.ariaLabel)" aria-valuemin="0" aria-valuemax="100" aria-orientation="horizontal" :aria-valuenow="split.view.valueNow" :aria-valuetext="split.view.valueText" @pointerdown="split.controls.pointerdown($event)" @pointermove="split.controls.pointermove($event)" @pointerup="split.controls.pointerup($event)" @pointercancel="split.controls.pointerup($event)" @keydown="split.controls.keydown($event)">↔</button><span class="after" :title="optional(split.view.afterTitle)">{{ split.view.afterLabel }}</span></div>
|
||||
</Teleport>
|
||||
</template>
|
||||
Reference in new issue
Block a user