benchdash / assets /dataset_link.js
josephsoo's picture
Pause linked playback while hovering
d11386a
Raw
History Blame Contribute Delete
12.9 kB
(function () {
"use strict";
const CURSOR_COLOR = "#D55E00";
const PLAYBACK_DURATION_MS = 4200;
let activeState = null;
let scheduleGeneration = 0;
function clamp(value, low, high) {
return Math.max(low, Math.min(high, value));
}
function traceRole(trace) {
return trace && trace.meta && trace.meta.benchdash_role;
}
function traceIndex(graph, role) {
return graph.data.findIndex((trace) => traceRole(trace) === role);
}
function graphDataset(graph) {
return graph && graph.layout && graph.layout.meta && graph.layout.meta.benchdash_dataset;
}
function formatNumber(value, digits) {
if (!Number.isFinite(Number(value))) return "—";
const number = Number(value);
const magnitude = Math.abs(number);
const precision = magnitude >= 10 ? 2 : digits;
return number.toFixed(precision).replace(/^-/, "−");
}
function formatSignedTime(value) {
const rounded = Math.round(Number(value));
if (rounded === 0) return "0 ms";
return `${rounded < 0 ? "−" : "+"}${Math.abs(rounded)} ms`;
}
function makeElement(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function buildControls(state) {
const lead = makeElement("div", "dataset-link-lead");
lead.append(
makeElement("strong", "dataset-link-title", "Linked trial"),
makeElement(
"span",
"dataset-link-hint",
"Playback loops automatically. Hover to pause; scrub to inspect."
)
);
const button = makeElement("button", "dataset-link-button", "Play");
button.type = "button";
button.setAttribute("aria-label", "Play linked neural activity and target trajectory");
const slider = makeElement("input", "dataset-link-slider");
slider.type = "range";
slider.min = "0";
slider.max = String(state.payload.time_ms.length - 1);
slider.step = "1";
slider.value = String(state.payload.initial_index || 0);
slider.setAttribute("aria-label", "Linked trial time bin");
const readout = makeElement("div", "dataset-link-readout");
const time = makeElement("span", "dataset-link-time");
const value = makeElement("span", "dataset-link-value");
readout.append(time, value);
const transport = makeElement("div", "dataset-link-transport");
transport.append(button, slider, readout);
state.controls.replaceChildren(lead, transport);
state.button = button;
state.slider = slider;
state.timeReadout = time;
state.valueReadout = value;
}
function setButton(state) {
state.button.textContent = state.playing ? "Pause" : "Play";
state.button.setAttribute(
"aria-label",
state.playing
? "Pause linked neural activity and target trajectory"
: "Play linked neural activity and target trajectory"
);
}
function positionPlayhead(state) {
if (!state.neural.isConnected || !state.neural._fullLayout) return;
const layout = state.neural._fullLayout;
const axis = layout.xaxis;
const size = layout._size;
if (!axis || !size) return;
const time = Number(state.payload.time_ms[state.index]);
const left = size.l + axis.l2p(time);
state.playhead.style.left = `${left}px`;
state.playhead.style.top = `${size.t}px`;
state.playhead.style.height = `${size.h}px`;
}
function updateReadout(state) {
const index = state.index;
const x = Number(state.payload.plot_x[index]);
const y = Number(state.payload.plot_y[index]);
const neuralTime = Number(state.payload.time_ms[index]);
const targetLag = Number(state.payload.target_lag_ms || 0);
state.timeReadout.textContent = `Neural time ${formatSignedTime(neuralTime)}`;
if (targetLag !== 0) {
state.valueReadout.textContent = (
`Target time ${formatSignedTime(neuralTime + targetLag)}`
+ ` · x ${formatNumber(x, 3)} · y ${formatNumber(y, 3)}`
);
} else if (state.payload.value_kind === "force") {
state.valueReadout.textContent = `Paired force ${formatNumber(y, 3)}`;
} else {
state.valueReadout.textContent = (
`Paired target x ${formatNumber(x, 3)} · y ${formatNumber(y, 3)}`
);
}
}
function renderIndex(state, requestedIndex) {
const lastIndex = state.payload.time_ms.length - 1;
const index = clamp(Math.round(Number(requestedIndex)), 0, lastIndex);
if (!Number.isFinite(index)) return;
state.index = index;
state.slider.value = String(index);
positionPlayhead(state);
updateReadout(state);
if (state.renderFrame !== null) cancelAnimationFrame(state.renderFrame);
state.renderFrame = requestAnimationFrame(() => {
state.renderFrame = null;
if (!state.target.isConnected || !state.target._fullLayout || !window.Plotly) return;
const progressX = state.payload.plot_x.slice(0, index + 1);
const progressY = state.payload.plot_y.slice(0, index + 1);
window.Plotly.restyle(
state.target,
{
x: [progressX, [state.payload.plot_x[index]]],
y: [progressY, [state.payload.plot_y[index]]],
},
[state.progressTrace, state.cursorTrace]
);
});
}
function pause(state) {
state.playing = false;
if (state.animationFrame !== null) cancelAnimationFrame(state.animationFrame);
state.animationFrame = null;
setButton(state);
}
function play(state) {
pause(state);
const finalIndex = state.payload.time_ms.length - 1;
if (state.index >= finalIndex) renderIndex(state, 0);
const startIndex = state.index;
const stepDuration = PLAYBACK_DURATION_MS / Math.max(finalIndex, 1);
const start = performance.now();
state.playing = true;
setButton(state);
const advance = (now) => {
if (!state.playing || activeState !== state) return;
const elapsedSteps = Math.floor((now - start) / stepDuration);
const nextIndex = Math.min(finalIndex, startIndex + elapsedSteps);
if (nextIndex !== state.index) renderIndex(state, nextIndex);
if (nextIndex >= finalIndex) {
state.animationFrame = requestAnimationFrame(() => {
if (!state.playing || activeState !== state) return;
renderIndex(state, 0);
play(state);
});
return;
}
state.animationFrame = requestAnimationFrame(advance);
};
state.animationFrame = requestAnimationFrame(advance);
}
function eventIndex(state, event) {
const point = event && event.points && event.points[0];
const custom = point && point.customdata;
if (Array.isArray(custom) && Number.isFinite(Number(custom[1]))) {
return Number(custom[1]);
}
const time = point && Number(point.x);
if (!Number.isFinite(time)) return null;
let bestIndex = 0;
let bestDistance = Infinity;
state.payload.time_ms.forEach((candidate, index) => {
const distance = Math.abs(Number(candidate) - time);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = index;
}
});
return bestIndex;
}
function bindInteractions(state) {
const abort = state.abortController;
const pauseForHover = (event, fromTarget) => {
const point = event && event.points && event.points[0];
if (fromTarget && (!point || point.curveNumber !== state.exampleTrace)) return;
const index = eventIndex(state, event);
if (index === null) return;
if (state.playing) state.resumeAfterHover = true;
pause(state);
renderIndex(state, index);
};
const holdSelection = (event, fromTarget) => {
const point = event && event.points && event.points[0];
if (fromTarget && (!point || point.curveNumber !== state.exampleTrace)) return;
const index = eventIndex(state, event);
if (index === null) return;
state.resumeAfterHover = false;
pause(state);
renderIndex(state, index);
};
const neuralHover = (event) => pauseForHover(event, false);
const neuralClick = (event) => holdSelection(event, false);
const targetHover = (event) => pauseForHover(event, true);
const targetClick = (event) => holdSelection(event, true);
const resumeAfterHover = () => {
if (!state.resumeAfterHover || activeState !== state) return;
state.resumeAfterHover = false;
play(state);
};
state.neural.on("plotly_hover", neuralHover);
state.neural.on("plotly_click", neuralClick);
state.target.on("plotly_hover", targetHover);
state.target.on("plotly_click", targetClick);
state.plotlyListeners = [
[state.neural, "plotly_hover", neuralHover],
[state.neural, "plotly_click", neuralClick],
[state.target, "plotly_hover", targetHover],
[state.target, "plotly_click", targetClick],
];
state.neural.addEventListener("mouseleave", resumeAfterHover, { signal: abort.signal });
state.target.addEventListener("mouseleave", resumeAfterHover, { signal: abort.signal });
state.button.addEventListener("click", () => {
state.resumeAfterHover = false;
if (state.playing) pause(state);
else play(state);
}, { signal: abort.signal });
state.slider.addEventListener("input", () => {
state.resumeAfterHover = false;
pause(state);
renderIndex(state, Number(state.slider.value));
}, { signal: abort.signal });
state.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => positionPlayhead(state));
});
state.resizeObserver.observe(state.neural);
}
function maybeAutoplay(state) {
state.autoplayTimer = window.setTimeout(() => {
if (activeState === state) {
renderIndex(state, 0);
play(state);
}
}, 150);
}
function destroy(state) {
if (!state) return;
pause(state);
if (state.renderFrame !== null) cancelAnimationFrame(state.renderFrame);
if (state.autoplayTimer !== null) window.clearTimeout(state.autoplayTimer);
if (state.resizeObserver) state.resizeObserver.disconnect();
if (state.abortController) state.abortController.abort();
(state.plotlyListeners || []).forEach(([graph, event, listener]) => {
if (graph && typeof graph.removeListener === "function") {
graph.removeListener(event, listener);
}
});
if (state.playhead) state.playhead.remove();
}
function mount(payload) {
const controls = document.getElementById("dataset-link-controls");
const neural = document.querySelector("#dataset-neural-example .js-plotly-plot");
const target = document.querySelector("#dataset-target-trajectory .js-plotly-plot");
if (!controls || !neural || !target || !neural._fullLayout || !target._fullLayout) {
return false;
}
if (graphDataset(neural) !== payload.dataset || graphDataset(target) !== payload.dataset) {
return false;
}
const exampleTrace = traceIndex(target, "example_trajectory");
const progressTrace = traceIndex(target, "linked_target_progress");
const cursorTrace = traceIndex(target, "linked_target_cursor");
if ([exampleTrace, progressTrace, cursorTrace].some((index) => index < 0)) return false;
const state = {
payload,
controls,
neural,
target,
exampleTrace,
progressTrace,
cursorTrace,
index: payload.initial_index || 0,
playing: false,
resumeAfterHover: false,
animationFrame: null,
renderFrame: null,
autoplayTimer: null,
resizeObserver: null,
abortController: new AbortController(),
plotlyListeners: [],
};
const playhead = makeElement("div", "dataset-linked-playhead");
playhead.style.backgroundColor = CURSOR_COLOR;
playhead.setAttribute("aria-hidden", "true");
neural.style.position = "relative";
neural.appendChild(playhead);
state.playhead = playhead;
buildControls(state);
bindInteractions(state);
activeState = state;
renderIndex(state, state.index);
maybeAutoplay(state);
return true;
}
function schedule(payload, activeTab) {
scheduleGeneration += 1;
const generation = scheduleGeneration;
destroy(activeState);
activeState = null;
const controls = document.getElementById("dataset-link-controls");
if (!payload || !payload.enabled || activeTab !== "datasets") {
if (controls) controls.replaceChildren();
return `${payload && payload.dataset ? payload.dataset : "none"}:inactive`;
}
let attempts = 0;
const tryMount = () => {
if (generation !== scheduleGeneration) return;
if (mount(payload)) return;
attempts += 1;
if (attempts < 180) requestAnimationFrame(tryMount);
};
requestAnimationFrame(tryMount);
return `${payload.dataset}:scheduled`;
}
window.benchdashDatasetLink = { schedule };
})();