// 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();