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