From 492b719f19af5d4aef1c72bd4dab79e1f1b6cc42 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 19:35:27 -0700 Subject: [PATCH] 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. --- docs/APPLICATION.md | 4 +- docs/VUE-MIGRATION.md | 6 +- scripts/format-scope.json | 2 + scripts/package-boundaries.json | 15 +- src/data/bhoteKoshiEvent.test.mjs | 42 +++--- src/ui/imagerySplit.js | 170 +++++++++++------------ src/ui/imagerySplit.test.mjs | 170 +++++++++++------------ src/ui/imagerySplitView.test.mjs | 139 ++++++++++++++++++ src/ui/recentImagery.js | 1 - src/ui/stores/imagerySplit.js | 35 +++++ src/ui/views/SceneChromeView.vue | 3 + src/ui/views/panels/ImagerySplitView.vue | 16 +++ 12 files changed, 402 insertions(+), 201 deletions(-) create mode 100644 src/ui/imagerySplitView.test.mjs create mode 100644 src/ui/stores/imagerySplit.js create mode 100644 src/ui/views/panels/ImagerySplitView.vue diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index ceb517133..809b106f4 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -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 diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index e012afc95..5caa59f1c 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index bc003a414..36a7ce514 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ae2600e7c..1173730e4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": { diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index 48183ee01..61cdfc3c3 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -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); diff --git a/src/ui/imagerySplit.js b/src/ui/imagerySplit.js index f22bbc208..16a48bc9d 100644 --- a/src/ui/imagerySplit.js +++ b/src/ui/imagerySplit.js @@ -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; }, diff --git a/src/ui/imagerySplit.test.mjs b/src/ui/imagerySplit.test.mjs index 23a47b60d..f96eb649d 100644 --- a/src/ui/imagerySplit.test.mjs +++ b/src/ui/imagerySplit.test.mjs @@ -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'); }); diff --git a/src/ui/imagerySplitView.test.mjs b/src/ui/imagerySplitView.test.mjs new file mode 100644 index 000000000..0bf6323b7 --- /dev/null +++ b/src/ui/imagerySplitView.test.mjs @@ -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); +}); diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index fcbc494cf..2447e874c 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -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', diff --git a/src/ui/stores/imagerySplit.js b/src/ui/stores/imagerySplit.js new file mode 100644 index 000000000..c7543a9d3 --- /dev/null +++ b/src/ui/stores/imagerySplit.js @@ -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 }; +}); diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index b954adaac..1bf8c5a74 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -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());
+ + diff --git a/src/ui/views/panels/ImagerySplitView.vue b/src/ui/views/panels/ImagerySplitView.vue new file mode 100644 index 000000000..b25c7cf46 --- /dev/null +++ b/src/ui/views/panels/ImagerySplitView.vue @@ -0,0 +1,16 @@ + + +