mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
887 lines
27 KiB
JavaScript
887 lines
27 KiB
JavaScript
/**
|
||
* The time machine (DESIGN.md §12.5).
|
||
*
|
||
* A fixed playhead over a moving track: recorded range shading (history()),
|
||
* forecast range, event pips coloured by altitude band with detents, momentum
|
||
* scrubbing, a NOW snap, play/pause at 1×/10×/60×/600×, a big readable time
|
||
* readout ("−42 MIN · 14:02:11Z"), and range select → "Clip this Moment".
|
||
* Works with touch, mouse drag, trackpad swipe (wheel) and pinch (ctrl+wheel).
|
||
*
|
||
* The public surface of the round-two ribbon is kept: setPips, setWindow,
|
||
* scrubTo, seek, goLive, time, isLive. Calls `onTime(Date | null)`.
|
||
*/
|
||
|
||
import { h, clamp, haptic, reducedMotion, esc } from './dom.js';
|
||
import { icon } from './icons.js';
|
||
import { BAND_COLOR, utc } from './bands.js';
|
||
|
||
const MIN = 60e3;
|
||
const HOUR = 3600e3;
|
||
const SPEEDS = [1, 10, 60, 600];
|
||
const STEPS = [1, 5, 10, 15, 30, 60, 180, 360, 720, 1440].map((m) => m * MIN);
|
||
|
||
/** "−42 MIN", "−3 H 12 MIN", "+2 H" */
|
||
export function fmtDelta(ms) {
|
||
const sign = ms < 0 ? '−' : '+';
|
||
if (Math.abs(ms) < MIN) return `${sign}${Math.round(Math.abs(ms) / 1000)} S`;
|
||
const a = Math.round(Math.abs(ms) / MIN);
|
||
if (a < 60) return `${sign}${a} MIN`;
|
||
const hrs = Math.floor(a / 60);
|
||
const m = a % 60;
|
||
if (hrs >= 48) return `${sign}${Math.round(hrs / 24)} D`;
|
||
return `${sign}${hrs} H${m ? ` ${m} MIN` : ''}`;
|
||
}
|
||
|
||
export function createTimeRibbon(
|
||
parent,
|
||
{
|
||
getWindow,
|
||
onTime,
|
||
playback = null,
|
||
getHistory = null,
|
||
onClip = null,
|
||
onPip = null,
|
||
},
|
||
) {
|
||
const el =
|
||
h(`<div class="ribbon tm glass-strong liquid is-live" role="group" aria-label="Time machine">
|
||
<div class="tm-row">
|
||
<button class="tm-play" type="button" aria-label="Play">${icon('play')}</button>
|
||
<div class="tm-read" aria-live="off">
|
||
<b class="tm-delta num">LIVE</b><span class="tm-abs num"></span>
|
||
</div>
|
||
<div class="tm-speeds" role="radiogroup" aria-label="Playback speed">
|
||
${SPEEDS.map((s) => `<button type="button" role="radio" data-speed="${s}" aria-checked="${s === 60}">${s}×</button>`).join('')}
|
||
</div>
|
||
<button class="tm-clip icon-btn" type="button" aria-pressed="false" aria-label="Select a range to clip">${icon('scissors')}</button>
|
||
<button class="rb-live tm-now is-live" type="button" aria-label="Return to now"><span class="live-dot"></span><span>NOW</span></button>
|
||
</div>
|
||
<div class="rb-track tm-track" role="slider" tabindex="0" aria-label="Scrub time" aria-valuetext="Live">
|
||
<canvas></canvas>
|
||
<div class="rb-head tm-head" aria-hidden="true"><i></i></div>
|
||
</div>
|
||
<div class="tm-callout tele" hidden></div>
|
||
<div class="tm-cliprow" hidden>
|
||
<span class="tm-cliplen tele"></span>
|
||
<button class="pill-btn plain tm-clipcancel" type="button">Cancel</button>
|
||
<button class="pill-btn primary tm-clipgo" type="button">${icon('film')}Clip this Moment</button>
|
||
</div>
|
||
</div>`);
|
||
parent.appendChild(el);
|
||
const track = el.querySelector('.tm-track');
|
||
const canvas = el.querySelector('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const playBtn = el.querySelector('.tm-play');
|
||
const nowBtn = el.querySelector('.tm-now');
|
||
const clipBtn = el.querySelector('.tm-clip');
|
||
const readD = el.querySelector('.tm-delta');
|
||
const readA = el.querySelector('.tm-abs');
|
||
const callout = el.querySelector('.tm-callout');
|
||
const clipRow = el.querySelector('.tm-cliprow');
|
||
const clipLen = el.querySelector('.tm-cliplen');
|
||
|
||
let win = getWindow();
|
||
let rec = null; // { start, end }
|
||
let arch = null; // the bundled recording: { start, end, label }
|
||
let custom = null;
|
||
let pips = [];
|
||
let t = Date.now();
|
||
let live = true;
|
||
let pxPerHour = 80;
|
||
let W = 0;
|
||
let Hh = 0;
|
||
let dpr = 1;
|
||
let raf = 0;
|
||
let vel = 0;
|
||
let emitTimer = 0;
|
||
let speed = 60;
|
||
let playing = false;
|
||
let clipMode = false;
|
||
let range = null; // { a, b }
|
||
let hotPip = null;
|
||
let calloutTimer = 0;
|
||
|
||
const start = () =>
|
||
custom
|
||
? custom.start
|
||
: Math.min(
|
||
win.start.getTime(),
|
||
rec?.start ?? Infinity,
|
||
arch ? arch.start - HOUR : Infinity,
|
||
);
|
||
const end = () => (custom ? custom.end : win.end.getTime());
|
||
const nowMs = () => (custom ? (custom.now ?? custom.start) : Date.now());
|
||
const x = (ms) => W / 2 + ((ms - t) / HOUR) * pxPerHour;
|
||
const tAt = (px) => t + ((px - W / 2) / pxPerHour) * HOUR;
|
||
|
||
function refreshHistory() {
|
||
try {
|
||
const hst = getHistory?.();
|
||
if (hst && Number.isFinite(+hst.start))
|
||
rec = { start: +hst.start, end: +hst.end || Date.now() };
|
||
const a = hst?.archive;
|
||
arch =
|
||
a && Number.isFinite(+a.start)
|
||
? {
|
||
start: +a.start,
|
||
end: +a.end,
|
||
label: `RECORDING · ${new Date(+a.start)
|
||
.toLocaleDateString('en-GB', {
|
||
day: 'numeric',
|
||
month: 'short',
|
||
timeZone: 'UTC',
|
||
})
|
||
.toUpperCase()}`,
|
||
}
|
||
: null;
|
||
} catch {
|
||
rec = null;
|
||
}
|
||
}
|
||
|
||
function resize() {
|
||
const r = track.getBoundingClientRect();
|
||
dpr = Math.min(2, window.devicePixelRatio || 1);
|
||
W = r.width;
|
||
Hh = r.height;
|
||
canvas.width = Math.round(W * dpr);
|
||
canvas.height = Math.round(Hh * dpr);
|
||
if (custom)
|
||
pxPerHour = (W * 0.9) / (Math.max(MIN, custom.end - custom.start) / HOUR);
|
||
else if (!resize.done) {
|
||
// Six recorded hours span about 1.3 track widths.
|
||
pxPerHour = clamp(W / 4.6, 40, 240);
|
||
resize.done = true;
|
||
}
|
||
draw();
|
||
}
|
||
|
||
function stepFor() {
|
||
for (const s of STEPS) if ((s / HOUR) * pxPerHour >= 11) return s;
|
||
return STEPS[STEPS.length - 1];
|
||
}
|
||
|
||
function draw() {
|
||
if (!W) return;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, W, Hh);
|
||
const s = start();
|
||
const e = end();
|
||
const n = nowMs();
|
||
const base = Math.round(Hh * 0.6);
|
||
const xs = clamp(x(s), -2, W + 2);
|
||
const xe = clamp(x(e), -2, W + 2);
|
||
const xn = clamp(x(n), -2, W + 2);
|
||
|
||
// Body of the window
|
||
ctx.fillStyle = 'rgba(255,255,255,0.035)';
|
||
ctx.fillRect(xs, base - 12, xe - xs, 24);
|
||
if (!custom) {
|
||
// Range labels sit inside the band's lower edge. Each is placed only
|
||
// where it fits inside its own range and clear of the labels already
|
||
// placed, so they never run into each other ("RECORDEDCAST").
|
||
ctx.font = '600 8.5px "JetBrains Mono", ui-monospace, monospace';
|
||
const placed = [];
|
||
const label = (texts, from, to, color, align = 'left') => {
|
||
// The longest wording that fits inside its range.
|
||
const all = [].concat(texts);
|
||
const text =
|
||
all.find(
|
||
(tx) =>
|
||
ctx.measureText(tx).width + 8 <=
|
||
Math.min(to, W - 24) - Math.max(from, 24),
|
||
) || all[all.length - 1];
|
||
const w = ctx.measureText(text).width;
|
||
const lo = Math.max(from, 24);
|
||
const hi = Math.min(to, W - 24);
|
||
if (hi - lo < w + 8) return;
|
||
const x0 = align === 'right' ? hi - 4 - w : lo + 4;
|
||
if (placed.some(([a, b]) => x0 < b + 8 && x0 + w > a - 8)) return;
|
||
placed.push([x0, x0 + w]);
|
||
ctx.fillStyle = color;
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(text, x0, base + 9);
|
||
};
|
||
const band = (a, b, top, bottom, edge) => {
|
||
const g = ctx.createLinearGradient(0, base - 12, 0, base + 12);
|
||
g.addColorStop(0, top);
|
||
g.addColorStop(1, bottom);
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(a, base - 12, b - a, 24);
|
||
ctx.fillStyle = edge;
|
||
ctx.fillRect(a, base - 12, b - a, 1.2);
|
||
};
|
||
// The bundled recording: real positions at their real times.
|
||
if (arch) {
|
||
const a = clamp(x(arch.start), -2, W + 2);
|
||
const b = clamp(x(Math.min(arch.end, n)), -2, W + 2);
|
||
if (b > a) {
|
||
band(
|
||
a,
|
||
b,
|
||
'rgba(122,231,199,0.2)',
|
||
'rgba(122,231,199,0.05)',
|
||
'rgba(122,231,199,0.8)',
|
||
);
|
||
}
|
||
}
|
||
// Recorded range: where this device remembers every contact.
|
||
if (rec) {
|
||
const a = clamp(x(rec.start), -2, W + 2);
|
||
band(
|
||
a,
|
||
xn,
|
||
'rgba(163,216,255,0.22)',
|
||
'rgba(163,216,255,0.06)',
|
||
'rgba(163,216,255,0.85)',
|
||
);
|
||
}
|
||
// Forecast
|
||
if (xn < W) {
|
||
const g = ctx.createLinearGradient(xn, 0, Math.min(W, xe), 0);
|
||
g.addColorStop(0, 'rgba(179,157,255,0.2)');
|
||
g.addColorStop(1, 'rgba(179,157,255,0.02)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(xn, base - 12, Math.min(W, xe) - xn, 24);
|
||
}
|
||
// Labels: forecast first (anchored at now), then the recordings.
|
||
if (xn < W)
|
||
label('FORECAST', xn, Math.min(W, xe), 'rgba(199,180,255,0.75)');
|
||
if (rec)
|
||
label(
|
||
['RECORDED', 'REC'],
|
||
x(rec.start),
|
||
xn,
|
||
'rgba(163,216,255,0.8)',
|
||
'right',
|
||
);
|
||
if (arch)
|
||
label(
|
||
[arch.label, 'RECORDING', 'REC'],
|
||
x(arch.start),
|
||
x(Math.min(arch.end, n)),
|
||
'rgba(122,231,199,0.85)',
|
||
);
|
||
}
|
||
// Clip range
|
||
if (range) {
|
||
const a = x(Math.min(range.a, range.b));
|
||
const b = x(Math.max(range.a, range.b));
|
||
ctx.fillStyle = 'rgba(235,199,119,0.24)';
|
||
ctx.fillRect(a, 2, b - a, Hh - 4);
|
||
ctx.fillStyle = '#ebc777';
|
||
for (const px of [a, b]) {
|
||
ctx.beginPath();
|
||
ctx.roundRect(px - 2.5, 3, 5, Hh - 6, 2.5);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
// Ticks
|
||
const step = stepFor();
|
||
const major =
|
||
step *
|
||
(step < HOUR ? (step <= 5 * MIN ? 6 : 4) : step < 24 * HOUR ? 4 : 1);
|
||
const t0 = Math.ceil(tAt(-10) / step) * step;
|
||
const t1 = tAt(W + 10);
|
||
ctx.textAlign = 'center';
|
||
let lastLabelEnd = -Infinity;
|
||
for (let m = t0; m <= t1; m += step) {
|
||
if (m < s - step || m > e + step) continue;
|
||
const px = x(m);
|
||
const isMajor = m % major === 0;
|
||
const day = m % (24 * HOUR) === 0;
|
||
const future = !custom && m > n;
|
||
const len = day ? 16 : isMajor ? 11 : 5;
|
||
ctx.strokeStyle = future
|
||
? 'rgba(199,180,255,0.45)'
|
||
: isMajor
|
||
? 'rgba(255,255,255,0.7)'
|
||
: 'rgba(255,255,255,0.28)';
|
||
ctx.lineWidth = isMajor ? 1.2 : 1;
|
||
if (future) ctx.setLineDash([1.5, 2]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(px, base - len / 2);
|
||
ctx.lineTo(px, base + len / 2);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
if (isMajor) {
|
||
const d = new Date(m);
|
||
ctx.font = `${day ? 700 : 550} 9.5px "JetBrains Mono", ui-monospace, monospace`;
|
||
ctx.fillStyle = day
|
||
? 'rgba(255,255,255,0.9)'
|
||
: 'rgba(255,255,255,0.55)';
|
||
const label = custom
|
||
? custom.format(m)
|
||
: day
|
||
? d
|
||
.toLocaleDateString('en-GB', {
|
||
weekday: 'short',
|
||
timeZone: 'UTC',
|
||
})
|
||
.toUpperCase()
|
||
: `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
|
||
const lw = ctx.measureText(label).width;
|
||
// Skip a label that would collide with the previous one or be
|
||
// cut by the edge fades.
|
||
if (
|
||
px - lw / 2 > lastLabelEnd + 6 &&
|
||
px - lw / 2 > 14 &&
|
||
px + lw / 2 < W - 14
|
||
) {
|
||
ctx.fillText(label, px, base - 15);
|
||
lastLabelEnd = px + lw / 2;
|
||
}
|
||
}
|
||
}
|
||
// NOW marker
|
||
if (!custom && x(n) > -10 && x(n) < W + 10) {
|
||
ctx.fillStyle = '#fff';
|
||
ctx.beginPath();
|
||
ctx.arc(x(n), base + 15, 2.6, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
// Event pips (by altitude band, sized by severity)
|
||
for (const p of pips) {
|
||
const px = x(p.at);
|
||
if (px < -8 || px > W + 8) continue;
|
||
const r = 2.8 + (p.severity || 0) * 0.9;
|
||
if (p === hotPip) {
|
||
ctx.fillStyle = `${p.color || '#fff'}44`;
|
||
ctx.beginPath();
|
||
ctx.arc(px, base, r + 6, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
ctx.fillStyle = p.color || '#FFD166';
|
||
ctx.beginPath();
|
||
ctx.arc(px, base, r, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.strokeStyle = 'rgba(5,7,11,0.9)';
|
||
ctx.lineWidth = 1.2;
|
||
ctx.stroke();
|
||
}
|
||
// Edge fades
|
||
const fade = (x0, x1) => {
|
||
const g = ctx.createLinearGradient(x0, 0, x1, 0);
|
||
g.addColorStop(0, 'rgba(0,0,0,1)');
|
||
g.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.save();
|
||
ctx.globalCompositeOperation = 'destination-out';
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(Math.min(x0, x1), 0, Math.abs(x1 - x0), Hh);
|
||
ctx.restore();
|
||
};
|
||
fade(0, 22);
|
||
fade(W, W - 22);
|
||
updateReadout();
|
||
}
|
||
|
||
function updateReadout() {
|
||
if (custom) {
|
||
readD.textContent = custom.format(t);
|
||
readA.textContent = custom.label || '';
|
||
track.setAttribute(
|
||
'aria-valuetext',
|
||
`${custom.format(t)} ${custom.label || ''}`,
|
||
);
|
||
return;
|
||
}
|
||
const at = new Date(live ? Date.now() : t);
|
||
// Another UTC day: say which one, so a recording reads honestly.
|
||
const otherDay =
|
||
!live &&
|
||
at.toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10);
|
||
const abs = otherDay
|
||
? `${at.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }).toUpperCase()} · ${utc(at)}`
|
||
: utc(at);
|
||
readD.textContent = live ? 'LIVE' : fmtDelta(t - Date.now());
|
||
readA.textContent = abs;
|
||
el.dataset.future = String(!live && t > Date.now());
|
||
track.setAttribute(
|
||
'aria-valuetext',
|
||
live ? `Live, ${abs}` : `${readD.textContent}, ${abs}`,
|
||
);
|
||
}
|
||
|
||
function setLive(v) {
|
||
live = v;
|
||
el.classList.toggle('is-live', v);
|
||
nowBtn.classList.toggle('is-live', v);
|
||
nowBtn.querySelector('span:last-child').textContent = custom
|
||
? 'START'
|
||
: 'NOW';
|
||
nowBtn.setAttribute('aria-pressed', String(v));
|
||
}
|
||
|
||
function emit(immediate = false) {
|
||
const fire = () => {
|
||
emitTimer = 0;
|
||
onTime(live && !custom ? null : new Date(t));
|
||
};
|
||
if (immediate) {
|
||
clearTimeout(emitTimer);
|
||
fire();
|
||
} else if (!emitTimer) emitTimer = setTimeout(fire, 60);
|
||
}
|
||
|
||
let lastDetent = 0;
|
||
function setCursor(ms, { fromUser = false } = {}) {
|
||
const s = start();
|
||
const e = end();
|
||
let v = ms;
|
||
if (v < s) v = s - (1 - 1 / (((s - v) / HOUR) * 0.4 + 1)) * HOUR * 0.6;
|
||
if (v > e) v = e + (1 - 1 / (((v - e) / HOUR) * 0.4 + 1)) * HOUR * 0.6;
|
||
t = v;
|
||
if (fromUser) {
|
||
// Detents: a tick as the playhead crosses a pip.
|
||
const near = pips.find((p) => Math.abs(x(p.at) - W / 2) < 3);
|
||
if (near && near !== hotPip) {
|
||
const pn = performance.now();
|
||
if (pn - lastDetent > 70) haptic('tick');
|
||
lastDetent = pn;
|
||
}
|
||
hotPip = near || null;
|
||
const isNear =
|
||
!custom && Math.abs(t - Date.now()) < (6 / pxPerHour) * HOUR;
|
||
setLive(isNear);
|
||
if (playing) stopPlay();
|
||
emit();
|
||
}
|
||
draw();
|
||
}
|
||
|
||
/* ---------- gestures ---------- */
|
||
let drag = null;
|
||
let press = 0;
|
||
track.addEventListener('pointerdown', (e) => {
|
||
if (e.button !== 0) return;
|
||
track.setPointerCapture(e.pointerId);
|
||
cancelAnimationFrame(raf);
|
||
const r = track.getBoundingClientRect();
|
||
const px = e.clientX - r.left;
|
||
drag = {
|
||
x: e.clientX,
|
||
px,
|
||
t0: t,
|
||
moved: false,
|
||
samples: [{ x: e.clientX, ts: performance.now() }],
|
||
handle: null,
|
||
};
|
||
if (range) {
|
||
const a = x(range.a);
|
||
const b = x(range.b);
|
||
if (Math.abs(px - a) < 16) drag.handle = 'a';
|
||
else if (Math.abs(px - b) < 16) drag.handle = 'b';
|
||
}
|
||
if (clipMode && !drag.handle) {
|
||
range = { a: tAt(px), b: tAt(px) };
|
||
drag.handle = 'b';
|
||
}
|
||
clearTimeout(press);
|
||
if (!clipMode && !drag.handle)
|
||
press = setTimeout(() => {
|
||
if (!drag || drag.moved) return;
|
||
setClipMode(true);
|
||
haptic('medium');
|
||
range = { a: tAt(drag.px), b: tAt(drag.px) };
|
||
drag.handle = 'b';
|
||
draw();
|
||
}, 420);
|
||
el.classList.add('scrubbing');
|
||
});
|
||
track.addEventListener('pointermove', (e) => {
|
||
if (!drag) return;
|
||
const dx = e.clientX - drag.x;
|
||
if (Math.abs(dx) > 3) drag.moved = true;
|
||
if (drag.handle) {
|
||
const r = track.getBoundingClientRect();
|
||
range[drag.handle] = tAt(e.clientX - r.left);
|
||
showClipRow();
|
||
draw();
|
||
return;
|
||
}
|
||
drag.samples.push({ x: e.clientX, ts: performance.now() });
|
||
if (drag.samples.length > 6) drag.samples.shift();
|
||
setCursor(drag.t0 - (dx / pxPerHour) * HOUR, { fromUser: true });
|
||
});
|
||
const release = (e) => {
|
||
clearTimeout(press);
|
||
if (!drag) return;
|
||
el.classList.remove('scrubbing');
|
||
if (drag.handle) {
|
||
drag = null;
|
||
if (range && Math.abs(range.b - range.a) < MIN) {
|
||
range = null;
|
||
showClipRow();
|
||
}
|
||
draw();
|
||
return;
|
||
}
|
||
if (!drag.moved) {
|
||
// A tap: on a pip jumps to it; elsewhere centres that instant.
|
||
const r = track.getBoundingClientRect();
|
||
const px = (e?.clientX ?? drag.x) - r.left;
|
||
drag = null;
|
||
const pip = pipAt(px);
|
||
if (pip) return jumpToPip(pip);
|
||
animateTo(tAt(px), () => settleLive());
|
||
return;
|
||
}
|
||
const a = drag.samples[0];
|
||
const b = drag.samples[drag.samples.length - 1];
|
||
const pxPerMs = b.ts > a.ts ? (b.x - a.x) / (b.ts - a.ts) : 0;
|
||
drag = null;
|
||
vel = (-(pxPerMs * 16.7) / pxPerHour) * HOUR;
|
||
if (reducedMotion() || Math.abs(pxPerMs) < 0.08) return settle();
|
||
const stepFn = () => {
|
||
vel *= 0.94;
|
||
setCursor(t + vel, { fromUser: true });
|
||
if (Math.abs(vel) > (0.4 / pxPerHour) * HOUR && t > start() && t < end())
|
||
raf = requestAnimationFrame(stepFn);
|
||
else settle();
|
||
};
|
||
raf = requestAnimationFrame(stepFn);
|
||
};
|
||
track.addEventListener('pointerup', release);
|
||
track.addEventListener('pointercancel', release);
|
||
|
||
// Trackpad: two-finger swipe scrubs, pinch (ctrl+wheel) zooms the scale.
|
||
track.addEventListener(
|
||
'wheel',
|
||
(e) => {
|
||
e.preventDefault();
|
||
if (e.ctrlKey || e.metaKey) {
|
||
pxPerHour = clamp(pxPerHour * Math.exp(-e.deltaY * 0.01), 6, 3000);
|
||
draw();
|
||
return;
|
||
}
|
||
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
||
setCursor(t + (d / pxPerHour) * HOUR, { fromUser: true });
|
||
clearTimeout(track._wt);
|
||
track._wt = setTimeout(settle, 160);
|
||
},
|
||
{ passive: false },
|
||
);
|
||
|
||
function pipAt(px) {
|
||
let best = null;
|
||
let bd = 12;
|
||
for (const p of pips) {
|
||
const d = Math.abs(x(p.at) - px);
|
||
if (d < bd) {
|
||
bd = d;
|
||
best = p;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function showCallout(p) {
|
||
if (!p?.label) return;
|
||
callout.textContent = p.label;
|
||
callout.style.setProperty('--c', p.color || '#fff');
|
||
callout.hidden = false;
|
||
clearTimeout(calloutTimer);
|
||
calloutTimer = setTimeout(() => (callout.hidden = true), 2600);
|
||
}
|
||
|
||
function jumpToPip(p) {
|
||
haptic('light');
|
||
hotPip = p;
|
||
showCallout(p);
|
||
setLive(false);
|
||
animateTo(p.at, () => {
|
||
emit(true);
|
||
onPip?.(p);
|
||
});
|
||
}
|
||
|
||
function settleLive() {
|
||
const isNear = !custom && Math.abs(t - Date.now()) < (6 / pxPerHour) * HOUR;
|
||
if (isNear) {
|
||
setLive(true);
|
||
animateTo(Date.now(), () => emit(true));
|
||
} else emit(true);
|
||
}
|
||
|
||
function settle() {
|
||
const target = clamp(t, start(), end());
|
||
// Detent: settle onto a pip within 10 px of the playhead.
|
||
const near = pips
|
||
.map((p) => ({ p, d: Math.abs(x(p.at) - x(target)) }))
|
||
.filter((o) => o.d < 10)
|
||
.sort((a, b) => a.d - b.d)[0];
|
||
if (near) {
|
||
haptic('light');
|
||
hotPip = near.p;
|
||
showCallout(near.p);
|
||
return animateTo(near.p.at, () => emit(true));
|
||
}
|
||
if (!custom && Math.abs(target - Date.now()) < (8 / pxPerHour) * HOUR) {
|
||
setLive(true);
|
||
snapPulse();
|
||
return animateTo(Date.now(), () => emit(true));
|
||
}
|
||
const q = custom ? 1000 : stepFor() >= HOUR ? 5 * MIN : MIN / 4;
|
||
animateTo(Math.round(target / q) * q, () => emit(true));
|
||
}
|
||
|
||
function animateTo(target, done) {
|
||
cancelAnimationFrame(raf);
|
||
const from = t;
|
||
const t0 = performance.now();
|
||
const dur = reducedMotion() ? 1 : 460;
|
||
const step = () => {
|
||
const k = clamp((performance.now() - t0) / dur, 0, 1);
|
||
const e = 1 - (1 - k) ** 3;
|
||
t = from + (target - from) * e;
|
||
draw();
|
||
if (k < 1) raf = requestAnimationFrame(step);
|
||
else done?.();
|
||
};
|
||
raf = requestAnimationFrame(step);
|
||
}
|
||
|
||
/** The NOW snap: a haptic tick and a pulse ring, like a detent landing. */
|
||
function snapPulse() {
|
||
haptic('medium');
|
||
el.classList.remove('snap');
|
||
void el.offsetWidth;
|
||
el.classList.add('snap');
|
||
clearTimeout(snapPulse.t);
|
||
snapPulse.t = setTimeout(() => el.classList.remove('snap'), 900);
|
||
}
|
||
|
||
function goLive() {
|
||
snapPulse();
|
||
stopPlay();
|
||
if (clipMode) setClipMode(false);
|
||
if (custom) {
|
||
animateTo(custom.start, () => emit(true));
|
||
return;
|
||
}
|
||
setLive(true);
|
||
animateTo(Date.now(), () => emit(true));
|
||
}
|
||
nowBtn.addEventListener('click', goLive);
|
||
|
||
/* ---------- playback ---------- */
|
||
function setPlaying(v) {
|
||
playing = v;
|
||
el.classList.toggle('is-playing', v);
|
||
playBtn.innerHTML = icon(v ? 'pause' : 'play');
|
||
playBtn.setAttribute('aria-label', v ? 'Pause' : 'Play');
|
||
}
|
||
function stopPlay() {
|
||
if (!playing) return;
|
||
playback?.stop();
|
||
setPlaying(false);
|
||
}
|
||
function play(s = speed) {
|
||
if (custom || !playback) return;
|
||
speed = s;
|
||
markSpeed();
|
||
if (live) {
|
||
// From live, replay the last hour of the recording.
|
||
const from = Math.max(rec?.start ?? -Infinity, Date.now() - HOUR);
|
||
t = from;
|
||
setLive(false);
|
||
onTime(new Date(t));
|
||
}
|
||
setPlaying(true);
|
||
playback.play({ speed });
|
||
haptic('light');
|
||
}
|
||
playBtn.addEventListener('click', () => (playing ? stopPlay() : play()));
|
||
function markSpeed() {
|
||
el.querySelectorAll('[data-speed]').forEach((b) =>
|
||
b.setAttribute('aria-checked', String(Number(b.dataset.speed) === speed)),
|
||
);
|
||
}
|
||
el.querySelector('.tm-speeds').addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-speed]');
|
||
if (!b) return;
|
||
haptic('tick');
|
||
speed = Number(b.dataset.speed);
|
||
markSpeed();
|
||
if (playing) playback?.play({ speed });
|
||
});
|
||
|
||
/** The engine reports its clock while playing. */
|
||
function onPlayback(st) {
|
||
if (!st) return;
|
||
if (st.playing !== playing) setPlaying(!!st.playing);
|
||
if (Number.isFinite(st.t) && st.playing) {
|
||
t = st.t;
|
||
setLive(false);
|
||
draw();
|
||
} else if (
|
||
!st.playing &&
|
||
(st.t == null || Math.abs(st.t - Date.now()) < 3000)
|
||
) {
|
||
setLive(true);
|
||
t = Date.now();
|
||
draw();
|
||
}
|
||
}
|
||
|
||
/* ---------- clip ---------- */
|
||
function setClipMode(v) {
|
||
clipMode = v;
|
||
clipBtn.setAttribute('aria-pressed', String(v));
|
||
el.classList.toggle('is-clipping', v);
|
||
if (!v) range = null;
|
||
showClipRow();
|
||
draw();
|
||
}
|
||
function showClipRow() {
|
||
const ok = !!range && Math.abs(range.b - range.a) >= MIN;
|
||
clipRow.hidden = !clipMode;
|
||
el.querySelector('.tm-clipgo').disabled = !ok;
|
||
clipLen.innerHTML = ok
|
||
? `${esc(utc(new Date(Math.min(range.a, range.b)), { seconds: false }).slice(0, 5))}–${esc(utc(new Date(Math.max(range.a, range.b)), { seconds: false }))}<br/><b>${esc(fmtDelta(Math.abs(range.b - range.a)).slice(1))}</b>`
|
||
: 'Drag across the track to select a range';
|
||
}
|
||
clipBtn.addEventListener('click', () => {
|
||
haptic('tick');
|
||
setClipMode(!clipMode);
|
||
});
|
||
el.querySelector('.tm-clipcancel').addEventListener('click', () =>
|
||
setClipMode(false),
|
||
);
|
||
el.querySelector('.tm-clipgo').addEventListener('click', () => {
|
||
if (!range) return;
|
||
const r = {
|
||
start: Math.min(range.a, range.b),
|
||
end: Math.max(range.a, range.b),
|
||
};
|
||
haptic('success');
|
||
setClipMode(false);
|
||
onClip?.(r);
|
||
});
|
||
|
||
/* ---------- keyboard ---------- */
|
||
track.addEventListener('keydown', (e) => {
|
||
const stepMs = custom ? 10e3 : e.shiftKey ? HOUR : 5 * MIN;
|
||
if (e.key === 'ArrowRight') setCursor(t + stepMs, { fromUser: true });
|
||
else if (e.key === 'ArrowLeft') setCursor(t - stepMs, { fromUser: true });
|
||
else if (e.key === 'End') goLive();
|
||
else if (e.key === ' ' || e.key === 'k') playing ? stopPlay() : play();
|
||
else return;
|
||
e.preventDefault();
|
||
});
|
||
|
||
// Live: keep the playhead glued to now while the planet turns.
|
||
setInterval(() => {
|
||
if (!custom && !drag) {
|
||
refreshHistory();
|
||
if (live) {
|
||
t = Date.now();
|
||
win = getWindow();
|
||
}
|
||
draw();
|
||
}
|
||
}, 1000);
|
||
|
||
new ResizeObserver(resize).observe(track);
|
||
refreshHistory();
|
||
resize();
|
||
setLive(true);
|
||
|
||
return {
|
||
el,
|
||
/** [{ at, label, color, severity?, ev? }] */
|
||
setPips(list) {
|
||
pips = list.filter((p) => Number.isFinite(p.at));
|
||
draw();
|
||
},
|
||
/** Timeline events from globe.timeline(), coloured by band. */
|
||
setEvents(evts) {
|
||
this.setPips(
|
||
evts.map((ev) => ({
|
||
at: ev.t,
|
||
label: ev.title,
|
||
color: BAND_COLOR[ev.band] || '#fff',
|
||
severity: ev.severity || 0,
|
||
ev,
|
||
})),
|
||
);
|
||
},
|
||
/** Use a custom window (e.g. a reconstruction's schematic clock). */
|
||
setWindow(w) {
|
||
stopPlay();
|
||
custom = w
|
||
? {
|
||
...w,
|
||
start: +w.start,
|
||
end: +w.end,
|
||
format: w.format || ((ms) => new Date(ms).toLocaleTimeString()),
|
||
}
|
||
: null;
|
||
el.classList.toggle('custom', !!custom);
|
||
if (custom) {
|
||
t = custom.start;
|
||
setLive(false);
|
||
} else {
|
||
win = getWindow();
|
||
t = Date.now();
|
||
resize.done = false;
|
||
setLive(true);
|
||
}
|
||
resize();
|
||
},
|
||
scrubTo(ms) {
|
||
stopPlay();
|
||
setLive(false);
|
||
animateTo(clamp(ms, start(), end()), () => emit(true));
|
||
},
|
||
seek(ms) {
|
||
cancelAnimationFrame(raf);
|
||
t = ms;
|
||
draw();
|
||
},
|
||
/** Deterministic setup (director): jump, then play at a speed. */
|
||
jump(ms, { play: p = false, speed: s = speed } = {}) {
|
||
stopPlay();
|
||
cancelAnimationFrame(raf);
|
||
t = ms;
|
||
setLive(false);
|
||
onTime(new Date(t));
|
||
draw();
|
||
if (p) play(s);
|
||
},
|
||
selectRange(a, b) {
|
||
setClipMode(true);
|
||
range = { a, b };
|
||
showClipRow();
|
||
draw();
|
||
},
|
||
play,
|
||
stop: stopPlay,
|
||
onPlayback,
|
||
goLive,
|
||
get time() {
|
||
return live && !custom ? null : new Date(t);
|
||
},
|
||
get isLive() {
|
||
return live && !custom;
|
||
},
|
||
get playing() {
|
||
return playing;
|
||
},
|
||
get debug() {
|
||
return {
|
||
t,
|
||
pxPerHour,
|
||
W,
|
||
Hh,
|
||
live,
|
||
custom: !!custom,
|
||
playing,
|
||
speed,
|
||
range,
|
||
rec,
|
||
};
|
||
},
|
||
};
|
||
}
|