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:
Sameh Khamis committed 2026-10-30 18:41:31 -07:00
1 parent dcecf5c34c
commit ce6040bde1
7 files changed
+264 -206

No files matched your search

+31 -191
View File
@@ -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);
},
};
}