mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04: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
+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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user