// Timeline scrubber + event markers (spec M4 transport + M7 markers). // // Renders a draggable scrubber over [0, tGlobalMax] with the playhead, plus GET /api/events as // colored ticks (color by event_type, legend in the corner). Hover a marker -> description // tooltip; click a marker -> jump the playhead there. Event data is already seeded // synthetically; classification QUALITY is lane D's concern, not this renderer's. import { state } from "./state.js"; export const EVENT_COLORS = { bass_drop: "#ff3b6b", pyro: "#ff8c1a", confetti: "#ffd23b", crowd_wave: "#3bc9ff", artist_moment: "#c77dff", light_show: "#59d499", quiet_moment: "#8a92a6", candidate: "#c0c0c0", other: "#9aa3b2", }; export function eventColor(type) { return EVENT_COLORS[type] || EVENT_COLORS.other; } function fmtTime(s) { s = Math.max(0, s); const m = Math.floor(s / 60); const sec = s - m * 60; return `${m}:${sec.toFixed(1).padStart(4, "0")}`; } export class Timeline { constructor() { this.max = 1; } init(root, transport) { this.transport = transport; this.max = state.tGlobalMax || 1; root.innerHTML = ""; const track = document.createElement("div"); track.className = "tl-track"; const fill = document.createElement("div"); fill.className = "tl-fill"; const markers = document.createElement("div"); markers.className = "tl-markers"; const playhead = document.createElement("div"); playhead.className = "tl-playhead"; track.append(fill, markers, playhead); const tip = document.createElement("div"); tip.className = "tl-tooltip"; tip.style.display = "none"; root.append(track, tip); this.track = track; this.fill = fill; this.playhead = playhead; this.tip = tip; this._buildMarkers(markers, tip); this._buildLegend(root); this._wireScrub(track); } _buildMarkers(container, tip) { for (const ev of state.events) { const m = document.createElement("div"); m.className = "tl-marker"; m.style.left = `${(ev.t_global_s / this.max) * 100}%`; m.style.background = eventColor(ev.event_type); m.addEventListener("click", (e) => { e.stopPropagation(); this.transport.seek(ev.t_global_s); }); const show = (e) => { tip.innerHTML = `${ev.event_type.replace(/_/g, " ")} · ${fmtTime(ev.t_global_s)}` + `
${ev.description || ""}` + (ev.confidence != null ? `
confidence ${(ev.confidence * 100).toFixed(0)}% · ${ev.source}` : ""); tip.style.display = "block"; const rect = this.track.getBoundingClientRect(); const x = (ev.t_global_s / this.max) * rect.width; tip.style.left = `${Math.max(4, Math.min(rect.width - 4, x))}px`; }; m.addEventListener("mouseenter", show); m.addEventListener("mousemove", show); m.addEventListener("mouseleave", () => (tip.style.display = "none")); container.append(m); } } _buildLegend(root) { const types = [...new Set(state.events.map((e) => e.event_type))]; if (types.length === 0) return; const legend = document.createElement("div"); legend.className = "tl-legend"; for (const t of types) { const item = document.createElement("span"); item.className = "tl-legend-item"; item.innerHTML = `${t.replace(/_/g, " ")}`; legend.append(item); } root.append(legend); } _wireScrub(track) { let dragging = false; const seekTo = (clientX) => { const rect = track.getBoundingClientRect(); const f = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); this.transport.seek(f * this.max); }; track.addEventListener("pointerdown", (e) => { dragging = true; track.setPointerCapture(e.pointerId); seekTo(e.clientX); }); track.addEventListener("pointermove", (e) => { if (dragging) seekTo(e.clientX); }); const end = (e) => { dragging = false; try { track.releasePointerCapture(e.pointerId); } catch { /* ignore */ } }; track.addEventListener("pointerup", end); track.addEventListener("pointercancel", end); } /** Move the playhead + fill to the current master time. */ update(tGlobal) { const f = this.max > 0 ? Math.max(0, Math.min(1, tGlobal / this.max)) : 0; this.playhead.style.left = `${f * 100}%`; this.fill.style.width = `${f * 100}%`; } } export const timeline = new Timeline();