wip(mobile): checkpoint engine and experience round three in progress

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-23 07:29:27 +00:00
1 parent a6f98ce3c1
commit a720b10913
83 files changed
+50005 -416

No files matched your search

+546 -183
View File
@@ -1,50 +1,123 @@
/**
* Time ribbon: −72 h ← NOW → +120 h with momentum drag, haptic hour detents,
* event pips and a LIVE pill that snaps back. Fast drags move in hours,
* slow drags in minutes. Calls `onTime(Date | null)` (null = live).
* 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 } from './dom.js';
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);
export function createTimeRibbon(parent, { getWindow, onTime }) {
/** "−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 glass-strong liquid" role="group" aria-label="Time">
<button class="rb-live is-live" type="button" aria-label="Return to live"><span class="live-dot"></span><span>LIVE</span></button>
<div class="rb-track" role="slider" tabindex="0" aria-label="Scrub time" aria-valuetext="Live">
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" aria-hidden="true"></div>
<div class="rb-head tm-head" aria-hidden="true"><i></i></div>
<div class="tm-callout tele" hidden></div>
</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 class="rb-read" aria-live="off"><b class="num">Now</b><span class="num"></span></div>
</div>`);
parent.appendChild(el);
const track = el.querySelector('.rb-track');
const track = el.querySelector('.tm-track');
const canvas = el.querySelector('canvas');
const ctx = canvas.getContext('2d');
const liveBtn = el.querySelector('.rb-live');
const readB = el.querySelector('.rb-read b');
const readS = el.querySelector('.rb-read span');
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 custom = null; // { start, end, label, pips, format }
let rec = null; // { start, end }
let custom = null;
let pips = [];
let t = Date.now(); // cursor time (ms)
let t = Date.now();
let live = true;
let pxPerHour = 7;
let pxPerHour = 80;
let W = 0;
let Hh = 0;
let dpr = 1;
let raf = 0;
let vel = 0; // ms per frame
let lastHour = Math.floor(t / HOUR);
let lastTick = 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 span = () => (custom ? custom.end - custom.start : win.end - win.start);
const start = () => (custom ? custom.start : win.start.getTime());
const start = () =>
custom
? custom.start
: Math.min(win.start.getTime(), rec?.start ?? 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() };
} catch {
rec = null;
}
}
function resize() {
const r = track.getBoundingClientRect();
@@ -53,107 +126,149 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
Hh = r.height;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(Hh * dpr);
// Custom windows (minutes long) scale to fit ~1.6 track widths.
pxPerHour = custom ? (W * 1.6) / (span() / HOUR) : 7;
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 x(ms) {
return W / 2 + ((ms - t) / HOUR) * pxPerHour;
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 mid = Hh / 2 + 4;
// Window body
ctx.fillStyle = 'rgba(255,255,255,0.04)';
const xs = Math.max(0, x(s));
const xe = Math.min(W, x(e));
ctx.fillRect(xs, mid - 9, xe - xs, 18);
// Forecast region
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) {
const xn = x(n);
// Recorded range: where the app remembers every contact.
if (rec) {
const a = clamp(x(rec.start), -2, W + 2);
const g = ctx.createLinearGradient(0, base - 12, 0, base + 12);
g.addColorStop(0, 'rgba(163,216,255,0.22)');
g.addColorStop(1, 'rgba(163,216,255,0.06)');
ctx.fillStyle = g;
ctx.fillRect(a, base - 12, xn - a, 24);
ctx.fillStyle = 'rgba(163,216,255,0.85)';
ctx.fillRect(a, base - 12, xn - a, 1.2);
if (a > 4 && a < W - 60) {
ctx.font = '600 8.5px "JetBrains Mono", ui-monospace, monospace';
ctx.fillStyle = 'rgba(163,216,255,0.8)';
ctx.textAlign = 'left';
ctx.fillText('RECORDED', a + 4, base + 9);
}
}
// Forecast
if (xn < W) {
const g = ctx.createLinearGradient(xn, 0, Math.min(W, xe), 0);
g.addColorStop(0, 'rgba(138,180,248,0.16)');
g.addColorStop(1, 'rgba(138,180,248,0.03)');
g.addColorStop(0, 'rgba(179,157,255,0.2)');
g.addColorStop(1, 'rgba(179,157,255,0.02)');
ctx.fillStyle = g;
ctx.fillRect(
Math.max(0, xn),
mid - 9,
Math.min(W, xe) - Math.max(0, xn),
18,
);
ctx.fillRect(xn, base - 12, Math.min(W, xe) - xn, 24);
if (xn > -40 && xn < W - 70) {
ctx.font = '600 8.5px "JetBrains Mono", ui-monospace, monospace';
ctx.fillStyle = 'rgba(199,180,255,0.75)';
ctx.textAlign = 'left';
ctx.fillText('FORECAST', xn + 6, base + 9);
}
}
}
// Ticks
const stepMin = custom ? (span() / HOUR < 3 ? 5 : 15) : 60;
const step = stepMin * 60e3;
const first = Math.ceil((t - (W / 2 / pxPerHour) * HOUR) / step) * step;
const last = t + (W / 2 / pxPerHour) * HOUR;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
for (
let m = Math.max(first, s - (s % step));
m <= Math.min(last, e);
m += step
) {
const px = x(m);
const d = new Date(m);
const major = custom
? (m - s) % (stepMin * 4 * 60e3) === 0
: d.getHours() === 0;
const mid6 = !custom && d.getHours() % 6 === 0;
const future = !custom && m > n;
const len = major ? 14 : mid6 ? 9 : 5;
ctx.strokeStyle = future
? 'rgba(170,200,255,0.42)'
: major
? 'rgba(255,255,255,0.75)'
: 'rgba(255,255,255,0.32)';
ctx.lineWidth = major ? 1.3 : 1;
if (future) ctx.setLineDash([1.5, 2]);
ctx.beginPath();
ctx.moveTo(px, mid - len / 2);
ctx.lineTo(px, mid + len / 2);
ctx.stroke();
ctx.setLineDash([]);
if (major || (mid6 && pxPerHour > 4)) {
ctx.font = `${major ? 650 : 500} 9.5px ui-rounded, system-ui, sans-serif`;
ctx.fillStyle = major
? 'rgba(255,255,255,0.8)'
: 'rgba(255,255,255,0.38)';
const label = custom
? custom.format(m)
: major
? d
.toLocaleDateString(undefined, { weekday: 'short' })
.toUpperCase()
: `${String(d.getHours()).padStart(2, '0')}`;
ctx.fillText(label, px, mid - 12);
}
}
// NOW marker
if (!custom) {
const xn = x(n);
if (xn > -10 && xn < W + 10) {
ctx.fillStyle = '#FFFFFF';
// 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.arc(xn, mid + 13, 2.4, 0, Math.PI * 2);
ctx.roundRect(px - 2.5, 3, 5, Hh - 6, 2.5);
ctx.fill();
}
}
// Event pips
// 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';
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')}`;
ctx.fillText(label, px, base - 15);
}
}
// 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 < -6 || px > W + 6) continue;
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, mid, 3.4, 0, Math.PI * 2);
ctx.arc(px, base, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(5,7,11,0.9)';
ctx.lineWidth = 1.2;
@@ -170,61 +285,39 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
ctx.fillRect(Math.min(x0, x1), 0, Math.abs(x1 - x0), Hh);
ctx.restore();
};
fade(0, 26);
fade(W, W - 26);
fade(0, 22);
fade(W, W - 22);
updateReadout();
}
function fmtRel(ms) {
const d = ms / 60e3;
const sign = d < 0 ? '−' : '+';
const a = Math.abs(d);
if (a < 60) return `${sign}${Math.round(a)} min`;
const hrs = Math.floor(a / 60);
const mins = Math.round(a % 60);
return `${sign}${hrs} h${mins ? ` ${mins}` : ''}`;
}
function updateReadout() {
if (custom) {
readB.textContent = custom.format(t);
readS.textContent = custom.label || '';
readD.textContent = custom.format(t);
readA.textContent = custom.label || '';
track.setAttribute(
'aria-valuetext',
`${custom.format(t)} ${custom.label || ''}`,
);
return;
}
if (live) {
readB.textContent = 'Now';
readS.textContent = new Date().toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
} else {
readB.textContent = fmtRel(t - Date.now());
readS.textContent = new Date(t).toLocaleString(undefined, {
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
const abs = utc(new Date(live ? Date.now() : t));
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' : `${readB.textContent}, ${readS.textContent}`,
live ? `Live, ${abs}` : `${readD.textContent}, ${abs}`,
);
}
function setLive(v) {
live = v;
el.classList.toggle('is-live', v);
liveBtn.classList.toggle('is-live', v);
liveBtn.querySelector('span:last-child').textContent = v
? 'LIVE'
: custom
? 'RESET'
: 'LIVE';
liveBtn.setAttribute('aria-pressed', String(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) {
@@ -235,84 +328,208 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
if (immediate) {
clearTimeout(emitTimer);
fire();
} else if (!emitTimer) emitTimer = setTimeout(fire, 90);
} else if (!emitTimer) emitTimer = setTimeout(fire, 60);
}
function setCursor(ms, { fromUser = false, quant = 0 } = {}) {
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.2 + 1)) * HOUR * 2;
if (v > e) v = e + (1 - 1 / (((v - e) / HOUR) * 0.2 + 1)) * HOUR * 2;
if (quant) v = Math.round(v / quant) * quant;
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;
const hr = Math.floor(t / HOUR);
if (fromUser && hr !== lastHour) {
const now = performance.now();
if (now - lastTick > 55) {
haptic('tick');
lastTick = now;
}
lastHour = hr;
}
if (fromUser) {
const near = !custom && Math.abs(t - Date.now()) < 4 * 60e3;
setLive(near);
// 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();
}
/* ---------- drag with momentum ---------- */
/* ---------- 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();
const a = drag.samples[0];
const b = drag.samples[drag.samples.length - 1];
const speed = b.ts > a.ts ? Math.abs((b.x - a.x) / (b.ts - a.ts)) : 0; // px/ms
const quant = custom ? 60e3 : speed > 0.6 ? HOUR : 5 * 60e3;
setCursor(drag.t0 - (dx / pxPerHour) * HOUR, { fromUser: true, quant });
setCursor(drag.t0 - (dx / pxPerHour) * HOUR, { fromUser: true });
});
const release = () => {
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;
const fast = Math.abs(pxPerMs) > 0.6;
if (reducedMotion() || Math.abs(pxPerMs) < 0.05) return settle(fast);
if (reducedMotion() || Math.abs(pxPerMs) < 0.08) return settle();
const stepFn = () => {
vel *= 0.93;
vel *= 0.94;
setCursor(t + vel, { fromUser: true });
if (Math.abs(vel) > 60e3 && t > start() && t < end())
if (Math.abs(vel) > (0.4 / pxPerHour) * HOUR && t > start() && t < end())
raf = requestAnimationFrame(stepFn);
else settle(fast);
else settle();
};
raf = requestAnimationFrame(stepFn);
};
track.addEventListener('pointerup', release);
track.addEventListener('pointercancel', release);
function settle(fast) {
const s = start();
const e = end();
const target = clamp(t, s, e);
const q = custom ? 60e3 : fast ? HOUR : 5 * 60e3;
// 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);
haptic('tick');
return animateTo(Date.now(), () => emit(true));
}
const q = custom ? 1000 : stepFor() >= HOUR ? 5 * MIN : MIN / 4;
animateTo(Math.round(target / q) * q, () => emit(true));
}
@@ -320,7 +537,7 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
cancelAnimationFrame(raf);
const from = t;
const t0 = performance.now();
const dur = reducedMotion() ? 1 : 420;
const dur = reducedMotion() ? 1 : 460;
const step = () => {
const k = clamp((performance.now() - t0) / dur, 0, 1);
const e = 1 - (1 - k) ** 3;
@@ -334,6 +551,7 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
function goLive() {
haptic('tick');
stopPlay();
if (custom) {
animateTo(custom.start, () => emit(true));
return;
@@ -341,38 +559,153 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
setLive(true);
animateTo(Date.now(), () => emit(true));
}
liveBtn.addEventListener('click', goLive);
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 ? 60e3 : e.shiftKey ? 6 * HOUR : HOUR;
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 === 'Home' || e.key === 'End') goLive();
else if (e.key === 'End') goLive();
else if (e.key === ' ' || e.key === 'k') playing ? stopPlay() : play();
else return;
e.preventDefault();
});
// Live: keep the cursor glued to now while the planet turns.
// Live: keep the playhead glued to now while the planet turns.
setInterval(() => {
if (live && !custom && !drag) {
t = Date.now();
win = getWindow();
if (!custom && !drag) {
refreshHistory();
if (live) {
t = Date.now();
win = getWindow();
}
draw();
}
}, 15000);
}, 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,
@@ -388,24 +721,40 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
} else {
win = getWindow();
t = Date.now();
resize.done = false;
setLive(true);
}
resize();
},
/** Animate to a moment and tell the globe (post anchors). */
scrubTo(ms) {
const lo = start();
const hi = end();
const target = Math.min(hi, Math.max(lo, ms));
stopPlay();
setLive(false);
animateTo(target, () => emit());
animateTo(clamp(ms, start(), end()), () => emit(true));
},
/** Programmatic scrub (narrative playback). */
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);
@@ -413,8 +762,22 @@ export function createTimeRibbon(parent, { getWindow, onTime }) {
get isLive() {
return live && !custom;
},
get playing() {
return playing;
},
get debug() {
return { t, pxPerHour, W, Hh, live, custom: !!custom };
return {
t,
pxPerHour,
W,
Hh,
live,
custom: !!custom,
playing,
speed,
range,
rec,
};
},
};
}