mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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.
This commit is contained in:
1 parent
dcecf5c34c
commit
ce6040bde1
7 files changed
+264
-206
No files matched your search
@@ -0,0 +1,170 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
|
||||
import { vRangeValue } from './rangeValue.js';
|
||||
import { formatUtcDayMonth, t } from '../../i18n/index.js';
|
||||
import { utcClock } from './utcClock.js';
|
||||
|
||||
// Stable native transport with local preview and a coalesced 150 ms drag
|
||||
// commit. While the handle is dragged the slider keeps the ticks it was
|
||||
// dragged over; the owner's updates apply once the drag ends.
|
||||
const props = defineProps({
|
||||
state: { type: Object, required: true },
|
||||
sliderClassName: { type: String, default: '' },
|
||||
heading: { type: Boolean, default: true },
|
||||
onCommit: { type: Function, default: () => {} },
|
||||
onPreview: { type: Function, default: () => {} },
|
||||
onStep: { type: Function, default: () => {} },
|
||||
onLatest: { type: Function, default: () => {} },
|
||||
onPlay: { type: Function, default: () => {} },
|
||||
});
|
||||
|
||||
const utc = (tick) =>
|
||||
Number.isFinite(Date.parse(tick))
|
||||
? `${formatUtcDayMonth(tick)} ${utcClock(tick)}`
|
||||
: '';
|
||||
|
||||
// What the slider shows: the owner's ticks, index and readout, frozen while
|
||||
// dragging.
|
||||
const shown = shallowRef({ ticks: [], index: 0, readout: '' });
|
||||
let dragging = false;
|
||||
let timer = null;
|
||||
let pending = null;
|
||||
let live = true;
|
||||
|
||||
const cancel = () => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
const sync = () => {
|
||||
if (dragging) return;
|
||||
const ticks = [...props.state.ticks];
|
||||
shown.value = {
|
||||
ticks,
|
||||
index: Math.max(0, Math.min(props.state.index, ticks.length - 1)),
|
||||
readout: props.state.readout || '',
|
||||
};
|
||||
};
|
||||
watch(
|
||||
() => props.state,
|
||||
(state) => {
|
||||
if (state.disabled) {
|
||||
cancel();
|
||||
pending = null;
|
||||
dragging = false;
|
||||
}
|
||||
sync();
|
||||
},
|
||||
{ immediate: true, flush: 'sync' },
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
live = false;
|
||||
cancel();
|
||||
});
|
||||
|
||||
const disabled = computed(() => Boolean(props.state.disabled));
|
||||
const latestActive = computed(() => props.state.mode === 'latest');
|
||||
const playing = computed(() => Boolean(props.state.playing));
|
||||
|
||||
function commit() {
|
||||
cancel();
|
||||
const value = pending;
|
||||
pending = null;
|
||||
if (live && value && !props.state.disabled)
|
||||
props.onCommit(value.tick, value.index);
|
||||
}
|
||||
function preview(event) {
|
||||
if (props.state.disabled) return;
|
||||
dragging = true;
|
||||
const index = Number(event.target.value);
|
||||
const tick = shown.value.ticks[index];
|
||||
if (!tick) return;
|
||||
pending = { tick, index };
|
||||
// Shown before the owner hears of it: an update from inside onPreview
|
||||
// must not move the handle back.
|
||||
shown.value = { ...shown.value, index };
|
||||
const readout = props.onPreview(tick, index);
|
||||
// The owner may have ended the drag (disabled) from inside onPreview.
|
||||
if (!dragging) return;
|
||||
if (typeof readout === 'string') shown.value = { ...shown.value, readout };
|
||||
if (timer === null) timer = setTimeout(commit, 150);
|
||||
}
|
||||
function change(event) {
|
||||
if (props.state.disabled) return;
|
||||
const index = Number(event.target.value);
|
||||
pending = { tick: shown.value.ticks[index], index };
|
||||
dragging = false;
|
||||
commit();
|
||||
sync();
|
||||
}
|
||||
function reset() {
|
||||
cancel();
|
||||
pending = null;
|
||||
dragging = false;
|
||||
sync();
|
||||
}
|
||||
function blur(event) {
|
||||
if (dragging) change(event);
|
||||
}
|
||||
const act = (fn) => {
|
||||
reset();
|
||||
if (live && !props.state.disabled) fn();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rail-timeline"
|
||||
><div v-if="heading" class="panel-title">{{ t('Observed history') }}</div
|
||||
><div class="rail-timeline-track"
|
||||
><button
|
||||
type="button"
|
||||
class="data-toggle-chip"
|
||||
:title="t('Earlier observation')"
|
||||
:disabled="disabled || state.index <= 0"
|
||||
@click="act(() => onStep(-1))"
|
||||
>‹</button
|
||||
><input
|
||||
type="range"
|
||||
:class="`gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`"
|
||||
min="0"
|
||||
:max="String(Math.max(0, shown.ticks.length - 1))"
|
||||
step="1"
|
||||
v-range-value="String(shown.index)"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('Observed history')"
|
||||
:aria-valuetext="utc(shown.ticks[shown.index])"
|
||||
@input="preview"
|
||||
@change="change"
|
||||
@pointercancel="reset"
|
||||
@blur="blur"
|
||||
/><button
|
||||
type="button"
|
||||
class="data-toggle-chip"
|
||||
:title="t('Later observation')"
|
||||
:disabled="disabled || state.index >= state.ticks.length - 1"
|
||||
@click="act(() => onStep(1))"
|
||||
>›</button
|
||||
><div class="rail-timeline-endpoints"
|
||||
><span>{{ utcClock(shown.ticks[0]) }}</span
|
||||
><span>{{ utcClock(shown.ticks.at(-1)) }}</span></div
|
||||
></div
|
||||
><div class="rail-timeline-controls"
|
||||
><button
|
||||
type="button"
|
||||
:class="`data-toggle-chip${latestActive ? ' active' : ''}`"
|
||||
:title="t('Newest frame per product')"
|
||||
:disabled="disabled"
|
||||
:aria-pressed="String(latestActive)"
|
||||
@click="act(onLatest)"
|
||||
>{{ t('Latest') }}</button
|
||||
><button
|
||||
type="button"
|
||||
:class="`data-toggle-chip${playing ? ' active' : ''}`"
|
||||
:title="t('Replay observed history')"
|
||||
:disabled="disabled"
|
||||
:aria-pressed="String(playing)"
|
||||
@click="act(onPlay)"
|
||||
>{{ playing ? t('Pause') : t('Play') }}</button
|
||||
><span class="rail-timeline-readout">{{ shown.readout }}</span></div
|
||||
></section
|
||||
>
|
||||
</template>
|
||||
@@ -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`
|
||||
: '';
|
||||
+31
-191
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user