From ce6040bde1a95cba5affa7aae733d8d4e1b0398f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 30 Oct 2026 18:41:07 -0700 Subject: [PATCH] feat(ui): render the rail timeline as a component behind its builder The observed-history transport is RailTimeline in src/ui/rail/, rendered by createRailTimeline() with the same update and destroy API. Drag preview, the coalesced 150 ms commit, release, cancel and blur behave as before; the slider keeps the ticks it is dragged over, an update from inside a preview cannot move the handle back, and readouts follow the handle within the same task. utcClock moves to its own module and is still exported here. --- docs/CURRENT-STATE.md | 2 +- docs/VUE-MIGRATION.md | 6 +- scripts/package-boundaries.json | 6 + src/ui/rail/RailTimeline.vue | 170 ++++++++++++++++++++++++ src/ui/rail/utcClock.js | 9 ++ src/ui/railTimeline.js | 222 +++++--------------------------- src/ui/railTimeline.test.mjs | 55 ++++++-- 7 files changed, 264 insertions(+), 206 deletions(-) create mode 100644 src/ui/rail/RailTimeline.vue create mode 100644 src/ui/rail/utcClock.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2d585d8dc..ce2c9e635 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1441,7 +1441,7 @@ Ordinary sliders share the native range component in `ui/slider.js` and the `gev-quantitative-slider` surface in `ui/styles/slider.css`. Display, shader parameters, volume, replay speed, weather settings/history, imagery opacity and Street View's date filter use the same rail, thumb, value styling, keyboard focus and disabled state. -Generated controls use `createSlider`; static templates apply the same class. +Generated controls use `createSlider`; static templates and components apply the same class. Feature owners retain bounds, steps, formatted values, input/change timing and listener cleanup. The radio tuning dial, imagery comparison divider and event comparison/progression controls keep their specialized presentation and behavior. diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 1a7586052..529c0d697 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -78,9 +78,9 @@ They contain no application state. `v-for`, because re-rendering it would bring back the English; component text uses `t()` instead. Shared builders become components first and keep their imperative entry points until their owners are components: - the rail cards and card blocks (`src/ui/rail/`) render through Vue's - synchronous `render()` behind `createRailCards()` and - `createRailCardBlocks()`. Order: Scenes (done: `SceneOptions` and + the rail cards, card blocks and timeline (`src/ui/rail/`) render through + Vue's synchronous `render()` behind `createRailCards()`, + `createRailCardBlocks()` and `createRailTimeline()`. Order: Scenes (done: `SceneOptions` and `SceneShotList` over the `gev-scenes` store), Weather, Recent imagery, Street level, CCTV, Layers, Display, Context and Radio, the command dock, the phone shell, then Cockpit. Layers that write into the right rail diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3ddef5cc7..73a3a95d6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -573,6 +573,8 @@ "src/ui/rail/RailCardBlocks.vue", "src/ui/rail/RailCard.vue", "src/ui/rail/RailCards.vue", + "src/ui/rail/utcClock.js", + "src/ui/rail/RailTimeline.vue", "src/ui/rail/rangeValue.js" ], "external": ["vue"] @@ -938,6 +940,8 @@ "src/ui/rail/RailCardBlocks.vue", "src/ui/rail/RailCard.vue", "src/ui/rail/RailCards.vue", + "src/ui/rail/utcClock.js", + "src/ui/rail/RailTimeline.vue", "src/ui/rail/rangeValue.js" ], "external": ["cesium", "vue", "pinia"] @@ -2898,6 +2902,8 @@ "src/ui/rail/RailCard.vue", "src/ui/rail/RailCards.vue", "src/ui/rail/RailCardBlocks.vue", + "src/ui/rail/utcClock.js", + "src/ui/rail/RailTimeline.vue", "src/ui/rail/rangeValue.js" ], "external": [ diff --git a/src/ui/rail/RailTimeline.vue b/src/ui/rail/RailTimeline.vue new file mode 100644 index 000000000..604780394 --- /dev/null +++ b/src/ui/rail/RailTimeline.vue @@ -0,0 +1,170 @@ + + + diff --git a/src/ui/rail/utcClock.js b/src/ui/rail/utcClock.js new file mode 100644 index 000000000..59796d010 --- /dev/null +++ b/src/ui/rail/utcClock.js @@ -0,0 +1,9 @@ +/** + * "HH:MM UTC" of a time, or '' when it is not one. Computed on its own rather + * than cut from the dated readout: the localized day and month vary in length + * ("3/9" in Spanish, "09-03" in English). + */ +export const utcClock = (tick) => + Number.isFinite(Date.parse(tick)) + ? `${new Date(tick).toISOString().slice(11, 16)} UTC` + : ''; diff --git a/src/ui/railTimeline.js b/src/ui/railTimeline.js index 181f9a73f..b6dcd178e 100644 --- a/src/ui/railTimeline.js +++ b/src/ui/railTimeline.js @@ -1,23 +1,13 @@ -import { formatUtcDayMonth, t } from '../i18n/index.js'; -import { createSlider } from './slider.js'; -const set = (node, key, value) => { - if (node[key] !== value) node[key] = value; -}; -/** - * "HH:MM UTC" of a time, or '' when it is not one. Computed on its own rather - * than cut from the dated readout: the localized day and month vary in length - * ("3/9" in Spanish, "09-03" in English). - */ -export const utcClock = (tick) => - Number.isFinite(Date.parse(tick)) - ? `${new Date(tick).toISOString().slice(11, 16)} UTC` - : ''; -const utc = (tick) => - Number.isFinite(Date.parse(tick)) - ? `${formatUtcDayMonth(tick)} ${utcClock(tick)}` - : ''; +import { h, render } from 'vue'; +import RailTimeline from './rail/RailTimeline.vue'; -/** Stable native transport with local preview and a coalesced 150 ms drag commit. */ +export { utcClock } from './rail/utcClock.js'; + +/** + * Stable native transport with local preview and a coalesced 150 ms drag + * commit. Renders the RailTimeline component into `container` and patches it + * synchronously on every update. + */ export function createRailTimeline({ container, document = container?.ownerDocument, @@ -30,187 +20,37 @@ export function createRailTimeline({ onPlay = () => {}, } = {}) { if (!document?.createElement || !container) return null; - const root = document.createElement('section'); - root.className = 'rail-timeline'; - const row = document.createElement('div'); - row.className = 'rail-timeline-track'; - const makeButton = (label, title, parent) => { - const node = document.createElement('button'); - node.type = 'button'; - node.className = 'data-toggle-chip'; - node.textContent = label; - node.title = title; - parent.appendChild(node); - return node; - }; - const previous = makeButton('‹', t('Earlier observation'), row); - const slider = createSlider({ - documentRef: document, - className: sliderClassName, - min: '0', - max: '0', - step: '1', - value: '0', - ariaLabel: t('Observed history'), - }); - row.appendChild(slider); - const next = makeButton('›', t('Later observation'), row); - const controls = document.createElement('div'); - controls.className = 'rail-timeline-controls'; - const latest = makeButton( - t('Latest'), - t('Newest frame per product'), - controls, - ); - const play = makeButton(t('Play'), t('Replay observed history'), controls); - const readout = document.createElement('span'); - readout.className = 'rail-timeline-readout'; - controls.appendChild(readout); - const label = document.createElement('div'); - label.className = 'panel-title'; - label.textContent = t('Observed history'); - const endpoints = document.createElement('div'); - endpoints.className = 'rail-timeline-endpoints'; - const oldest = document.createElement('span'); - const newest = document.createElement('span'); - endpoints.appendChild(oldest); - endpoints.appendChild(newest); - row.appendChild(endpoints); - if (heading) root.appendChild(label); - root.appendChild(row); - root.appendChild(controls); - container.appendChild(root); - let props = { + let destroyed = false; + const paint = (state) => + render( + h(RailTimeline, { + state, + sliderClassName, + heading, + onCommit, + onPreview, + onStep, + onLatest, + onPlay, + }), + container, + ); + paint({ ticks: [], index: 0, mode: 'latest', playing: false, readout: '', disabled: true, - }; - let ticks = []; - let dragging = false; - let timer = null; - let pending = null; - let destroyed = false; - const cancel = () => { - if (timer !== null) clearTimeout(timer); - timer = null; - }; - const valueText = (tick) => { - const value = utc(tick); - if (slider.getAttribute('aria-valuetext') !== value) - slider.setAttribute('aria-valuetext', value); - }; - const commit = () => { - cancel(); - const value = pending; - pending = null; - if (!destroyed && value && !props.disabled) - onCommit(value.tick, value.index); - }; - const update = (nextProps) => { - if (destroyed) return; - props = nextProps; - if (props.disabled) { - cancel(); - pending = null; - dragging = false; - } - if (!dragging) { - ticks = [...props.ticks]; - set(slider, 'max', String(Math.max(0, ticks.length - 1))); - set( - slider, - 'value', - String(Math.max(0, Math.min(props.index, ticks.length - 1))), - ); - valueText(ticks[Number(slider.value)]); - set(oldest, 'textContent', utcClock(ticks[0])); - set(newest, 'textContent', utcClock(ticks.at(-1))); - set(readout, 'textContent', props.readout || ''); - } - for (const node of [slider, latest, play]) - set(node, 'disabled', Boolean(props.disabled)); - set(previous, 'disabled', Boolean(props.disabled || props.index <= 0)); - set( - next, - 'disabled', - Boolean(props.disabled || props.index >= props.ticks.length - 1), - ); - set( - latest, - 'className', - `data-toggle-chip${props.mode === 'latest' ? ' active' : ''}`, - ); - set(play, 'className', `data-toggle-chip${props.playing ? ' active' : ''}`); - set(play, 'textContent', props.playing ? t('Pause') : t('Play')); - for (const [node, active] of [ - [latest, props.mode === 'latest'], - [play, props.playing], - ]) { - if (node.getAttribute('aria-pressed') !== String(active)) - node.setAttribute('aria-pressed', String(active)); - } - }; - const preview = () => { - if (props.disabled) return; - dragging = true; - const index = Number(slider.value); - const tick = ticks[index]; - if (!tick) return; - pending = { tick, index }; - valueText(tick); - const value = onPreview(tick, index); - if (typeof value === 'string') set(readout, 'textContent', value); - if (timer === null) timer = setTimeout(commit, 150); - }; - const change = () => { - if (props.disabled) return; - pending = { - tick: ticks[Number(slider.value)], - index: Number(slider.value), - }; - dragging = false; - commit(); - update(props); - }; - const reset = () => { - cancel(); - pending = null; - dragging = false; - update(props); - }; - const act = (fn) => () => { - reset(); - if (!props.disabled) fn(); - }; - const bindings = [ - [slider, 'input', preview], - [slider, 'change', change], - [slider, 'pointercancel', reset], - [ - slider, - 'blur', - () => { - if (dragging) change(); - }, - ], - [previous, 'click', act(() => onStep(-1))], - [next, 'click', act(() => onStep(1))], - [latest, 'click', act(onLatest)], - [play, 'click', act(onPlay)], - ]; - for (const [node, event, handler] of bindings) - node.addEventListener(event, handler); + }); return { - update, + update(state) { + if (!destroyed) paint(state); + }, destroy() { + if (destroyed) return; destroyed = true; - cancel(); - for (const [node, event, handler] of bindings) - node.removeEventListener(event, handler); - root.remove(); + render(null, container); }, }; } diff --git a/src/ui/railTimeline.test.mjs b/src/ui/railTimeline.test.mjs index b7d997d0d..fd9ced861 100644 --- a/src/ui/railTimeline.test.mjs +++ b/src/ui/railTimeline.test.mjs @@ -1,7 +1,9 @@ +import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; +import { nextTick } from 'vue'; import { createRailTimeline } from './railTimeline.js'; -import { railFixture } from './railTestFixture.mjs'; +import { railDocumentFixture as railFixture } from './railTestFixture.mjs'; const ticks = [ '2026-09-22T01:00:00Z', '2026-09-22T01:05:00Z', @@ -15,7 +17,7 @@ const props = { readout: 'Latest', disabled: false, }; -test('preview is immediate, commits are coalesced and the drag DOM and tick mapping stay stable', (t) => { +test('preview is immediate, commits are coalesced and the drag DOM and tick mapping stay stable', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const f = railFixture(); const previews = []; @@ -30,12 +32,13 @@ test('preview is immediate, commits are coalesced and the drag DOM and tick mapp }); view.update(props); const slider = f.find((n) => n.tagName === 'INPUT'); - const row = slider.parent; + const row = slider.parentElement; assert.equal(slider.getAttribute('aria-label'), 'Observed history'); slider.value = '0'; slider.dispatchEvent(new Event('input')); assert.deepEqual(commits, []); assert.deepEqual(previews, [ticks[0]]); + await nextTick(); assert.equal(slider.getAttribute('aria-valuetext'), '09-22 01:00 UTC'); view.update({ ...props, @@ -43,7 +46,7 @@ test('preview is immediate, commits are coalesced and the drag DOM and tick mapp index: 1, readout: 'Refresh', }); - assert.equal(slider.parent, row); + assert.equal(slider.parentElement, row); assert.equal(row.children[1], slider); assert.equal(slider.value, '0'); assert.equal(slider.max, '2'); @@ -63,13 +66,13 @@ test('preview is immediate, commits are coalesced and the drag DOM and tick mapp assert.deepEqual(commits, [ticks[1], ticks[2]]); t.mock.timers.tick(150); assert.equal(commits.length, 2); - assert.equal(slider.max, '1'); + await nextTick(); + assert.equal(slider.max, '1', 'release applies the latest update'); view.destroy(); }); test('identical updates write nothing; controls call actions and destruction cancels commits', (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); - let writes = 0; - const f = railFixture(() => writes++); + const f = railFixture(); const calls = []; const view = createRailTimeline({ ...f, @@ -79,9 +82,9 @@ test('identical updates write nothing; controls call actions and destruction can onPlay: () => calls.push('play'), }); view.update({ ...props, index: 1 }); - writes = 0; + f.writes(); view.update({ ...props, index: 1 }); - assert.equal(writes, 0); + assert.equal(f.writes(), 0); for (const label of ['‹', '›', 'Latest', 'Play']) f.find((n) => n.textContent === label).click(); assert.deepEqual(calls, [-1, 1, 'latest', 'play']); @@ -107,12 +110,42 @@ test('observed history label and endpoint times track the advertised tick range' 'Observed history', ); const endpoints = f.find((n) => n.className === 'rail-timeline-endpoints'); - assert.equal(endpoints.parent.className, 'rail-timeline-track'); + assert.equal(endpoints.parentElement.className, 'rail-timeline-track'); assert.deepEqual( - endpoints.children.map((n) => n.textContent), + [...endpoints.children].map((n) => n.textContent), ['01:00 UTC', '01:10 UTC'], ); view.update({ ...props, ticks: ticks.slice(1) }); assert.equal(endpoints.children[0].textContent, '01:05 UTC'); view.destroy(); }); + +test('an update from inside the preview neither moves the handle back nor survives a disable', async () => { + const f = railFixture(); + let view; + let next = null; + const commits = []; + view = createRailTimeline({ + ...f, + onPreview: () => { + if (next) view.update(next); + return 'preview'; + }, + onCommit: (tick, index) => commits.push(index), + }); + view.update(props); + const slider = f.find((n) => n.tagName === 'INPUT'); + next = { ...props, readout: 'Refresh' }; + slider.value = '0'; + slider.dispatchEvent(new Event('input')); + assert.equal(slider.value, '0'); + slider.dispatchEvent(new Event('change')); + assert.deepEqual(commits, [0]); + next = { ...props, disabled: true }; + slider.value = '1'; + slider.dispatchEvent(new Event('input')); + await nextTick(); + assert.equal(slider.value, '2', 'the disabled owner keeps its position'); + assert.equal(slider.getAttribute('aria-valuetext'), '09-22 01:10 UTC'); + view.destroy(); +});