| /** |
| * Sampling Profiler Visualization |
| */ |
| (function () { |
| "use strict"; |
| |
| // ============================================================================ |
| // Configuration |
| // ============================================================================ |
| |
| const TIMINGS = { |
| sampleIntervalMin: 100, |
| sampleIntervalMax: 500, |
| sampleIntervalDefault: 200, |
| sampleToFlame: 600, |
| defaultSpeed: 0.05, |
| }; |
| |
| const LAYOUT = { frameSpacing: 6 }; |
| |
| // Function name to color mapping |
| const FUNCTION_COLORS = { |
| main: "#306998", |
| fibonacci: "#D4A910", |
| add: "#E65100", |
| multiply: "#7B1FA2", |
| calculate: "#D4A910", |
| }; |
| const DEFAULT_FUNCTION_COLOR = "#306998"; |
| |
| // Easing functions - cubic-bezier approximations |
| const EASING_MAP = { |
| linear: "linear", |
| easeOutQuad: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", |
| easeOutCubic: "cubic-bezier(0.215, 0.61, 0.355, 1)", |
| }; |
| |
| function getFunctionColor(funcName) { |
| return FUNCTION_COLORS[funcName] || DEFAULT_FUNCTION_COLOR; |
| } |
| |
| // ============================================================================ |
| // Animation Manager |
| // ============================================================================ |
| |
| class AnimationManager { |
| constructor() { |
| this.activeAnimations = new Set(); |
| } |
| |
| to(element, props, duration, easing = "easeOutQuad", onComplete = null) { |
| this.killAnimationsOf(element); |
| |
| const cssEasing = EASING_MAP[easing] || EASING_MAP.easeOutQuad; |
| |
| const transformProps = {}; |
| const otherProps = {}; |
| |
| for (const [key, value] of Object.entries(props)) { |
| if (key === "position") { |
| if (typeof value.x === "number") transformProps.x = value.x; |
| if (typeof value.y === "number") transformProps.y = value.y; |
| } else if (key === "x" || key === "y") { |
| transformProps[key] = value; |
| } else if (key === "scale") { |
| transformProps.scale = value; |
| } else if (key === "alpha" || key === "opacity") { |
| otherProps.opacity = value; |
| } else { |
| otherProps[key] = value; |
| } |
| } |
| |
| const computedStyle = getComputedStyle(element); |
| const matrix = new DOMMatrix(computedStyle.transform); |
| const currentScale = Math.sqrt( |
| matrix.m11 * matrix.m11 + matrix.m21 * matrix.m21, |
| ); |
| |
| transformProps.x ??= matrix.m41; |
| transformProps.y ??= matrix.m42; |
| transformProps.scale ??= currentScale; |
| |
| const initialTransform = this._buildTransformString( |
| matrix.m41, |
| matrix.m42, |
| currentScale, |
| ); |
| |
| const finalTransform = this._buildTransformString( |
| transformProps.x, |
| transformProps.y, |
| transformProps.scale, |
| ); |
| |
| const initialKeyframe = { transform: initialTransform }; |
| const finalKeyframe = { transform: finalTransform }; |
| |
| for (const [key, value] of Object.entries(otherProps)) { |
| const currentVal = |
| key === "opacity" |
| ? element.style.opacity || computedStyle.opacity |
| : element.style[key]; |
| initialKeyframe[key] = currentVal; |
| finalKeyframe[key] = value; |
| } |
| |
| const animation = element.animate([initialKeyframe, finalKeyframe], { |
| duration, |
| easing: cssEasing, |
| fill: "forwards", |
| }); |
| |
| this.activeAnimations.add(animation); |
| animation.onfinish = () => { |
| this.activeAnimations.delete(animation); |
| element.style.transform = finalTransform; |
| for (const [key, value] of Object.entries(finalKeyframe)) { |
| if (key !== "transform") { |
| element.style[key] = typeof value === "number" ? `${value}` : value; |
| } |
| } |
| if (onComplete) onComplete(); |
| }; |
| |
| return animation; |
| } |
| |
| killAnimationsOf(element) { |
| element.getAnimations().forEach((animation) => animation.cancel()); |
| this.activeAnimations.forEach((animation) => { |
| if (animation.effect && animation.effect.target === element) { |
| animation.cancel(); |
| this.activeAnimations.delete(animation); |
| } |
| }); |
| } |
| |
| _buildTransformString(x, y, scale = 1) { |
| return `translate(${x}px, ${y}px) scale(${scale})`; |
| } |
| } |
| |
| const anim = new AnimationManager(); |
| |
| // ============================================================================ |
| // Execution Trace Model |
| // ============================================================================ |
| |
| class ExecutionEvent { |
| constructor( |
| type, |
| functionName, |
| lineno, |
| timestamp, |
| args = null, |
| value = null, |
| ) { |
| this.type = type; |
| this.functionName = functionName; |
| this.lineno = lineno; |
| this.timestamp = timestamp; |
| this.args = args; |
| this.value = value; |
| } |
| } |
| |
| class ExecutionTrace { |
| constructor(source, events) { |
| this.source = source; |
| this.events = events.map( |
| (e) => |
| new ExecutionEvent(e.type, e.func, e.line, e.ts, e.args, e.value), |
| ); |
| this.duration = events.length > 0 ? events[events.length - 1].ts : 0; |
| } |
| |
| getEventsUntil(timestamp) { |
| return this.events.filter((e) => e.timestamp <= timestamp); |
| } |
| |
| getStackAt(timestamp) { |
| const stack = []; |
| const events = this.getEventsUntil(timestamp); |
| |
| for (const event of events) { |
| if (event.type === "call") { |
| stack.push({ |
| func: event.functionName, |
| line: event.lineno, |
| args: event.args, |
| }); |
| } else if (event.type === "return") { |
| stack.pop(); |
| } else if (event.type === "line") { |
| if (stack.length > 0) { |
| stack[stack.length - 1].line = event.lineno; |
| } |
| } |
| } |
| return stack; |
| } |
| |
| getNextEvent(timestamp) { |
| return this.events.find((e) => e.timestamp > timestamp); |
| } |
| |
| getSourceLines() { |
| return this.source.split("\n"); |
| } |
| } |
| |
| // ============================================================================ |
| // Demo Data |
| // ============================================================================ |
| |
| // This placeholder is replaced by the profiling_trace Sphinx extension |
| // during the documentation build with dynamically generated trace data. |
| const DEMO_SIMPLE = /* PROFILING_TRACE_DATA */ null; |
| |
| // ============================================================================ |
| // Code Panel Component |
| // ============================================================================ |
| |
| class CodePanel { |
| constructor(source) { |
| this.source = source; |
| this.currentLine = null; |
| |
| this.element = document.createElement("div"); |
| this.element.id = "code-panel"; |
| |
| const title = document.createElement("div"); |
| title.className = "code-panel-title"; |
| title.textContent = "source code"; |
| this.element.appendChild(title); |
| |
| this.codeContainer = document.createElement("pre"); |
| this.codeContainer.className = "code-container"; |
| this.element.appendChild(this.codeContainer); |
| |
| this._renderSource(); |
| } |
| |
| updateSource(source) { |
| this.source = source; |
| this.codeContainer.innerHTML = ""; |
| this._renderSource(); |
| this.currentLine = null; |
| } |
| |
| _renderSource() { |
| const lines = this.source.split("\n"); |
| |
| lines.forEach((line, index) => { |
| const lineNumber = index + 1; |
| const lineDiv = document.createElement("div"); |
| lineDiv.className = "line"; |
| lineDiv.dataset.line = lineNumber; |
| |
| const lineNumSpan = document.createElement("span"); |
| lineNumSpan.className = "line-number"; |
| lineNumSpan.textContent = lineNumber; |
| lineDiv.appendChild(lineNumSpan); |
| |
| const codeSpan = document.createElement("span"); |
| codeSpan.className = "line-content"; |
| codeSpan.innerHTML = this._highlightSyntax(line); |
| lineDiv.appendChild(codeSpan); |
| |
| this.codeContainer.appendChild(lineDiv); |
| }); |
| } |
| |
| _highlightSyntax(line) { |
| return line |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/(f?"[^"]*"|f?'[^']*')/g, '<span class="string">$1</span>') |
| .replace(/(#.*$)/g, '<span class="comment">$1</span>') |
| .replace( |
| /\b(def|if|elif|else|return|for|in|range|print|__name__|__main__)\b/g, |
| '<span class="keyword">$1</span>', |
| ) |
| .replace( |
| /<span class="keyword">def<\/span>\s+(\w+)/g, |
| '<span class="keyword">def</span> <span class="function">$1</span>', |
| ) |
| .replace(/\b(\d+)\b/g, '<span class="number">$1</span>'); |
| } |
| |
| highlightLine(lineNumber) { |
| if (this.currentLine === lineNumber) return; |
| |
| if (this.currentLine !== null) { |
| const prevLine = this.codeContainer.querySelector( |
| `[data-line="${this.currentLine}"]`, |
| ); |
| if (prevLine) prevLine.classList.remove("highlighted"); |
| } |
| |
| if (lineNumber === null || lineNumber === undefined) { |
| this.currentLine = null; |
| return; |
| } |
| |
| this.currentLine = lineNumber; |
| const newLine = this.codeContainer.querySelector( |
| `[data-line="${lineNumber}"]`, |
| ); |
| if (newLine) { |
| newLine.classList.add("highlighted"); |
| } |
| } |
| |
| reset() { |
| this.highlightLine(null); |
| this.codeContainer.scrollTop = 0; |
| } |
| |
| destroy() { |
| this.element.remove(); |
| } |
| } |
| |
| // ============================================================================ |
| // Stack Frame Component |
| // ============================================================================ |
| |
| class DOMStackFrame { |
| constructor(functionName, lineno, args = null) { |
| this.functionName = functionName; |
| this.lineno = lineno; |
| this.args = args; |
| this.isActive = false; |
| this.color = getFunctionColor(functionName); |
| |
| this.element = document.createElement("div"); |
| this.element.className = "stack-frame"; |
| this.element.dataset.function = functionName; |
| |
| this.bgElement = document.createElement("div"); |
| this.bgElement.className = "stack-frame-bg"; |
| this.bgElement.style.backgroundColor = this.color; |
| this.element.appendChild(this.bgElement); |
| |
| this.textElement = document.createElement("span"); |
| this.textElement.className = "stack-frame-text"; |
| this.textElement.textContent = functionName; |
| this.element.appendChild(this.textElement); |
| |
| this.flashElement = document.createElement("div"); |
| this.flashElement.className = "stack-frame-flash"; |
| this.element.appendChild(this.flashElement); |
| |
| this.element.addEventListener("pointerover", this._onHover.bind(this)); |
| this.element.addEventListener("pointerout", this._onHoverOut.bind(this)); |
| } |
| |
| destroy() { |
| this.element.parentNode?.removeChild(this.element); |
| } |
| |
| updateLine(lineno) { |
| this.lineno = lineno; |
| this.textElement.textContent = this.functionName; |
| } |
| |
| setActive(isActive) { |
| if (this.isActive === isActive) return; |
| this.isActive = isActive; |
| this.bgElement.style.opacity = isActive ? "1.0" : "0.9"; |
| } |
| |
| _onHover() { |
| this.bgElement.style.opacity = "0.8"; |
| } |
| |
| _onHoverOut() { |
| this.bgElement.style.opacity = this.isActive ? "1.0" : "0.9"; |
| } |
| |
| flash(duration = 150) { |
| this.flashElement.animate([{ opacity: 1 }, { opacity: 0 }], { |
| duration, |
| easing: "ease-out", |
| }); |
| } |
| |
| getPosition() { |
| const rect = this.element.getBoundingClientRect(); |
| return { x: rect.left, y: rect.top }; |
| } |
| } |
| |
| // ============================================================================ |
| // Stack Visualization Component |
| // ============================================================================ |
| |
| class DOMStackVisualization { |
| constructor() { |
| this.frames = []; |
| this.frameSpacing = LAYOUT.frameSpacing; |
| |
| this.element = document.createElement("div"); |
| this.element.className = "stack-visualization"; |
| } |
| |
| processEvent(event) { |
| if (event.type === "call") { |
| this.pushFrame(event.functionName, event.lineno, event.args); |
| } else if (event.type === "return") { |
| this.popFrame(); |
| } else if (event.type === "line") { |
| this.updateTopFrameLine(event.lineno); |
| } |
| } |
| |
| updateTopFrameLine(lineno) { |
| if (this.frames.length > 0) { |
| this.frames[this.frames.length - 1].updateLine(lineno); |
| } |
| } |
| |
| pushFrame(functionName, lineno, args = null) { |
| if (this.frames.length > 0) { |
| this.frames[this.frames.length - 1].setActive(false); |
| } |
| |
| const frame = new DOMStackFrame(functionName, lineno, args); |
| frame.setActive(true); |
| this.element.appendChild(frame.element); |
| this.frames.push(frame); |
| |
| requestAnimationFrame(() => { |
| frame.element.classList.add("visible"); |
| }); |
| } |
| |
| popFrame() { |
| if (this.frames.length === 0) return; |
| |
| const frame = this.frames.pop(); |
| frame.element.classList.remove("visible"); |
| setTimeout(() => frame.destroy(), 300); |
| |
| if (this.frames.length > 0) { |
| this.frames[this.frames.length - 1].setActive(true); |
| } |
| } |
| |
| clear() { |
| this.frames.forEach((frame) => frame.destroy()); |
| this.frames = []; |
| this.element.innerHTML = ""; |
| } |
| |
| flashAll() { |
| this.frames.forEach((frame) => frame.flash()); |
| } |
| |
| createStackClone(container) { |
| const clone = this.element.cloneNode(false); |
| clone.className = "stack-visualization flying-clone"; |
| |
| const elementRect = this.element.getBoundingClientRect(); |
| const containerRect = container.getBoundingClientRect(); |
| |
| // Position relative to container since contain: strict makes position:fixed relative to container |
| clone.style.position = "absolute"; |
| clone.style.left = elementRect.left - containerRect.left + "px"; |
| clone.style.top = elementRect.top - containerRect.top + "px"; |
| clone.style.width = elementRect.width + "px"; |
| clone.style.pointerEvents = "none"; |
| clone.style.zIndex = "1000"; |
| |
| this.frames.forEach((frame) => { |
| const frameClone = frame.element.cloneNode(true); |
| frameClone.classList.add("visible"); |
| frameClone.style.opacity = "1"; |
| frameClone.style.transform = "translateY(0)"; |
| frameClone.style.transition = "none"; |
| clone.appendChild(frameClone); |
| }); |
| |
| container.appendChild(clone); |
| return clone; |
| } |
| |
| updateToMatch(targetStack) { |
| while (this.frames.length > targetStack.length) { |
| this.popFrame(); |
| } |
| |
| targetStack.forEach(({ func, line, args }, index) => { |
| if (index < this.frames.length) { |
| const frame = this.frames[index]; |
| if (frame.functionName !== func) { |
| frame.updateLine(line); |
| } |
| frame.setActive(index === targetStack.length - 1); |
| } else { |
| this.pushFrame(func, line, args); |
| } |
| }); |
| |
| if (this.frames.length > 0) { |
| this.frames[this.frames.length - 1].setActive(true); |
| } |
| } |
| } |
| |
| // ============================================================================ |
| // Sampling Panel Component |
| // ============================================================================ |
| |
| class DOMSamplingPanel { |
| constructor() { |
| this.samples = []; |
| this.functionCounts = {}; |
| this.totalSamples = 0; |
| this.sampleInterval = TIMINGS.sampleIntervalDefault; |
| this.groundTruthFunctions = new Set(); |
| this.bars = {}; |
| |
| this.element = document.createElement("div"); |
| this.element.className = "sampling-panel"; |
| |
| const header = document.createElement("div"); |
| header.className = "sampling-header"; |
| |
| const title = document.createElement("h3"); |
| title.className = "sampling-title"; |
| title.textContent = "Sampling Profiler"; |
| header.appendChild(title); |
| |
| const stats = document.createElement("div"); |
| stats.className = "sampling-stats"; |
| |
| this.sampleCountEl = document.createElement("span"); |
| this.sampleCountEl.textContent = "Samples: 0"; |
| stats.appendChild(this.sampleCountEl); |
| |
| this.intervalEl = document.createElement("span"); |
| this.intervalEl.textContent = `Interval: ${this.sampleInterval}ms`; |
| stats.appendChild(this.intervalEl); |
| |
| this.missedFunctionsEl = document.createElement("span"); |
| this.missedFunctionsEl.className = "missed"; |
| stats.appendChild(this.missedFunctionsEl); |
| |
| header.appendChild(stats); |
| this.element.appendChild(header); |
| |
| this.barsContainer = document.createElement("div"); |
| this.barsContainer.className = "sampling-bars"; |
| this.element.appendChild(this.barsContainer); |
| } |
| |
| setSampleInterval(interval) { |
| this.sampleInterval = interval; |
| this.intervalEl.textContent = `Interval: ${interval}ms`; |
| } |
| |
| setGroundTruth(allFunctions) { |
| this.groundTruthFunctions = new Set(allFunctions); |
| this._updateMissedCount(); |
| } |
| |
| addSample(stack) { |
| this.totalSamples++; |
| this.sampleCountEl.textContent = `Samples: ${this.totalSamples}`; |
| |
| stack.forEach((frame) => { |
| const funcName = frame.func; |
| this.functionCounts[funcName] = |
| (this.functionCounts[funcName] || 0) + 1; |
| }); |
| |
| this._updateBars(); |
| this._updateMissedCount(); |
| } |
| |
| reset() { |
| this.samples = []; |
| this.functionCounts = {}; |
| this.totalSamples = 0; |
| this.sampleCountEl.textContent = "Samples: 0"; |
| this.missedFunctionsEl.textContent = ""; |
| this.barsContainer.innerHTML = ""; |
| this.bars = {}; |
| } |
| |
| _updateMissedCount() { |
| if (this.groundTruthFunctions.size === 0) return; |
| |
| const capturedFunctions = new Set(Object.keys(this.functionCounts)); |
| const notYetSeen = [...this.groundTruthFunctions].filter( |
| (f) => !capturedFunctions.has(f), |
| ); |
| |
| if (notYetSeen.length > 0) { |
| this.missedFunctionsEl.textContent = `Not yet seen: ${notYetSeen.length}`; |
| this.missedFunctionsEl.classList.add("missed"); |
| this.missedFunctionsEl.style.color = ""; |
| } else if (this.totalSamples > 0) { |
| this.missedFunctionsEl.textContent = "All captured!"; |
| this.missedFunctionsEl.classList.remove("missed"); |
| this.missedFunctionsEl.style.color = "var(--color-green)"; |
| } else { |
| this.missedFunctionsEl.textContent = ""; |
| } |
| } |
| |
| _updateBars() { |
| const sorted = Object.entries(this.functionCounts).sort( |
| (a, b) => b[1] - a[1], |
| ); |
| |
| sorted.forEach(([funcName, count], index) => { |
| const percentage = |
| this.totalSamples > 0 ? count / this.totalSamples : 0; |
| |
| if (!this.bars[funcName]) { |
| const row = this._createBarRow(funcName); |
| this.barsContainer.appendChild(row); |
| this.bars[funcName] = row; |
| } |
| |
| const row = this.bars[funcName]; |
| const barFill = row.querySelector(".bar-fill"); |
| barFill.style.width = `${percentage * 100}%`; |
| |
| const percentEl = row.querySelector(".bar-percent"); |
| percentEl.textContent = `${(percentage * 100).toFixed(0)}%`; |
| |
| const currentIndex = Array.from(this.barsContainer.children).indexOf( |
| row, |
| ); |
| if (currentIndex !== index) { |
| this.barsContainer.insertBefore( |
| row, |
| this.barsContainer.children[index], |
| ); |
| } |
| }); |
| } |
| |
| _createBarRow(funcName) { |
| const row = document.createElement("div"); |
| row.className = "sampling-bar-row"; |
| row.dataset.function = funcName; |
| |
| const label = document.createElement("span"); |
| label.className = "bar-label"; |
| label.textContent = funcName; |
| row.appendChild(label); |
| |
| const barContainer = document.createElement("div"); |
| barContainer.className = "bar-container"; |
| |
| const barFill = document.createElement("div"); |
| barFill.className = "bar-fill"; |
| barFill.style.backgroundColor = getFunctionColor(funcName); |
| barContainer.appendChild(barFill); |
| |
| row.appendChild(barContainer); |
| |
| const percent = document.createElement("span"); |
| percent.className = "bar-percent"; |
| percent.textContent = "0%"; |
| row.appendChild(percent); |
| |
| return row; |
| } |
| |
| getTargetPosition() { |
| const rect = this.barsContainer.getBoundingClientRect(); |
| return { x: rect.left + rect.width / 2, y: rect.top + 50 }; |
| } |
| |
| showImpactEffect(position) { |
| const impact = document.createElement("div"); |
| impact.className = "impact-circle"; |
| impact.style.position = "fixed"; |
| impact.style.left = `${position.x}px`; |
| impact.style.top = `${position.y}px`; |
| |
| // Append to barsContainer parent to avoid triggering scroll |
| this.element.appendChild(impact); |
| |
| impact.animate( |
| [ |
| { transform: "translate(-50%, -50%) scale(1)", opacity: 0.6 }, |
| { transform: "translate(-50%, -50%) scale(4)", opacity: 0 }, |
| ], |
| { |
| duration: 300, |
| easing: "ease-out", |
| }, |
| ).onfinish = () => impact.remove(); |
| } |
| } |
| |
| // ============================================================================ |
| // Control Panel Component |
| // ============================================================================ |
| |
| class ControlPanel { |
| constructor( |
| container, |
| onPlay, |
| onPause, |
| onReset, |
| onSpeedChange, |
| onSeek, |
| onStep, |
| onSampleIntervalChange = null, |
| ) { |
| this.container = container; |
| this.onPlay = onPlay; |
| this.onPause = onPause; |
| this.onReset = onReset; |
| this.onSpeedChange = onSpeedChange; |
| this.onSeek = onSeek; |
| this.onStep = onStep; |
| this.onSampleIntervalChange = onSampleIntervalChange; |
| |
| this.isPlaying = false; |
| this.speed = TIMINGS.defaultSpeed; |
| |
| this._createControls(); |
| } |
| |
| _createControls() { |
| const panel = document.createElement("div"); |
| panel.id = "control-panel"; |
| |
| const sampleIntervalHtml = this.onSampleIntervalChange |
| ? ` |
| <div class="control-group"> |
| <label>Sample interval:</label> |
| <input type="range" id="sample-interval" |
| min="${TIMINGS.sampleIntervalMin}" |
| max="${TIMINGS.sampleIntervalMax}" |
| value="${TIMINGS.sampleIntervalDefault}" |
| step="100" |
| aria-label="Sample interval in milliseconds"> |
| <span id="interval-display">${TIMINGS.sampleIntervalDefault}ms</span> |
| </div> |
| ` |
| : ""; |
| |
| panel.innerHTML = ` |
| <div class="control-group"> |
| <button id="play-pause-btn" class="control-btn" aria-label="Play animation">▶ Play</button> |
| <button id="reset-btn" class="control-btn" aria-label="Reset visualization to beginning">↻ Reset</button> |
| <button id="step-btn" class="control-btn" aria-label="Step to next event">→ Step</button> |
| </div> |
| |
| ${sampleIntervalHtml} |
| |
| <div class="control-group timeline-scrubber"> |
| <input type="range" id="timeline-scrubber" min="0" max="100" value="0" step="0.1" aria-label="Timeline position"> |
| <span id="time-display">0ms</span> |
| </div> |
| `; |
| |
| this.container.appendChild(panel); |
| |
| this.playPauseBtn = panel.querySelector("#play-pause-btn"); |
| this.resetBtn = panel.querySelector("#reset-btn"); |
| this.stepBtn = panel.querySelector("#step-btn"); |
| this.scrubber = panel.querySelector("#timeline-scrubber"); |
| this.timeDisplay = panel.querySelector("#time-display"); |
| |
| this.playPauseBtn.addEventListener("click", () => |
| this._togglePlayPause(), |
| ); |
| this.resetBtn.addEventListener("click", () => this._handleReset()); |
| this.stepBtn.addEventListener("click", () => this._handleStep()); |
| this.scrubber.addEventListener("input", (e) => this._handleSeek(e)); |
| |
| if (this.onSampleIntervalChange) { |
| this.sampleIntervalSlider = panel.querySelector("#sample-interval"); |
| this.intervalDisplay = panel.querySelector("#interval-display"); |
| this.sampleIntervalSlider.addEventListener("input", (e) => |
| this._handleSampleIntervalChange(e), |
| ); |
| } |
| } |
| |
| _handleSampleIntervalChange(e) { |
| const interval = parseInt(e.target.value); |
| this.intervalDisplay.textContent = `${interval}ms`; |
| this.onSampleIntervalChange(interval); |
| } |
| |
| _togglePlayPause() { |
| this.isPlaying = !this.isPlaying; |
| |
| if (this.isPlaying) { |
| this.playPauseBtn.textContent = "⏸ Pause"; |
| this.playPauseBtn.classList.add("active"); |
| this.onPlay(); |
| } else { |
| this.playPauseBtn.textContent = "▶ Play"; |
| this.playPauseBtn.classList.remove("active"); |
| this.onPause(); |
| } |
| } |
| |
| _handleReset() { |
| this.isPlaying = false; |
| this.playPauseBtn.textContent = "▶ Play"; |
| this.playPauseBtn.classList.remove("active"); |
| this.scrubber.value = 0; |
| this.timeDisplay.textContent = "0ms"; |
| this.onReset(); |
| } |
| |
| _handleStep() { |
| if (this.onStep) this.onStep(); |
| } |
| |
| _handleSeek(e) { |
| const percentage = parseFloat(e.target.value); |
| this.onSeek(percentage / 100); |
| } |
| |
| updateTimeDisplay(currentTime, totalTime) { |
| this.timeDisplay.textContent = `${Math.floor(currentTime)}ms / ${Math.floor(totalTime)}ms`; |
| const percentage = (currentTime / totalTime) * 100; |
| this.scrubber.value = percentage; |
| } |
| |
| setDuration(duration) { |
| this.duration = duration; |
| } |
| |
| pause() { |
| if (this.isPlaying) this._togglePlayPause(); |
| } |
| |
| destroy() { |
| const panel = this.container.querySelector("#control-panel"); |
| if (panel) panel.remove(); |
| } |
| } |
| |
| // ============================================================================ |
| // Visual Effects Manager |
| // ============================================================================ |
| |
| class VisualEffectsManager { |
| constructor(container) { |
| this.container = container; |
| this.flyingAnimationInProgress = false; |
| |
| this.flashOverlay = document.createElement("div"); |
| this.flashOverlay.className = "flash-overlay"; |
| this.container.appendChild(this.flashOverlay); |
| } |
| |
| triggerSamplingEffect(stackViz, samplingPanel, currentTime, trace) { |
| if (this.flyingAnimationInProgress) return; |
| |
| const stack = trace.getStackAt(currentTime); |
| |
| if (stack.length === 0) { |
| samplingPanel.addSample(stack); |
| return; |
| } |
| |
| this.flyingAnimationInProgress = true; |
| stackViz.flashAll(); |
| |
| const clone = stackViz.createStackClone(this.container); |
| const targetPosition = samplingPanel.getTargetPosition(); |
| |
| this._animateFlash(); |
| this._animateFlyingStack(clone, targetPosition, () => { |
| samplingPanel.showImpactEffect(targetPosition); |
| clone.remove(); |
| |
| const currentStack = trace.getStackAt(currentTime); |
| samplingPanel.addSample(currentStack); |
| this.flyingAnimationInProgress = false; |
| }); |
| } |
| |
| _animateFlash() { |
| anim.to(this.flashOverlay, { opacity: 0.1 }, 0).onfinish = () => { |
| anim.to(this.flashOverlay, { opacity: 0 }, 150, "easeOutQuad"); |
| }; |
| } |
| |
| _animateFlyingStack(clone, targetPosition, onComplete) { |
| const containerRect = this.container.getBoundingClientRect(); |
| const cloneRect = clone.getBoundingClientRect(); |
| |
| // Convert viewport coordinates to container-relative |
| const startX = cloneRect.left - containerRect.left + cloneRect.width / 2; |
| const startY = cloneRect.top - containerRect.top + cloneRect.height / 2; |
| const targetX = targetPosition.x - containerRect.left; |
| const targetY = targetPosition.y - containerRect.top; |
| |
| const deltaX = targetX - startX; |
| const deltaY = targetY - startY; |
| |
| anim.to( |
| clone, |
| { |
| x: deltaX, |
| y: deltaY, |
| scale: 0.3, |
| opacity: 0.6, |
| }, |
| TIMINGS.sampleToFlame, |
| "easeOutCubic", |
| onComplete, |
| ); |
| } |
| } |
| |
| // ============================================================================ |
| // Main Visualization Class |
| // ============================================================================ |
| |
| class SamplingVisualization { |
| constructor(container) { |
| this.container = container; |
| |
| this.trace = new ExecutionTrace(DEMO_SIMPLE.source, DEMO_SIMPLE.trace); |
| |
| this.currentTime = 0; |
| this.isPlaying = false; |
| this.playbackSpeed = TIMINGS.defaultSpeed; |
| this.eventIndex = 0; |
| |
| this.sampleInterval = TIMINGS.sampleIntervalDefault; |
| this.lastSampleTime = 0; |
| |
| this._createLayout(); |
| |
| this.effectsManager = new VisualEffectsManager(this.vizColumn); |
| |
| this.lastTime = performance.now(); |
| this._animate(); |
| } |
| |
| _createLayout() { |
| this.codePanel = new CodePanel(this.trace.source); |
| this.container.appendChild(this.codePanel.element); |
| |
| this.vizColumn = document.createElement("div"); |
| this.vizColumn.className = "viz-column"; |
| this.container.appendChild(this.vizColumn); |
| |
| const stackSection = document.createElement("div"); |
| stackSection.className = "stack-section"; |
| |
| const stackTitle = document.createElement("div"); |
| stackTitle.className = "stack-section-title"; |
| stackTitle.textContent = "Call Stack"; |
| stackSection.appendChild(stackTitle); |
| |
| this.stackViz = new DOMStackVisualization(); |
| stackSection.appendChild(this.stackViz.element); |
| this.vizColumn.appendChild(stackSection); |
| |
| this.samplingPanel = new DOMSamplingPanel(); |
| this.samplingPanel.setGroundTruth(this._getGroundTruthFunctions()); |
| this.vizColumn.appendChild(this.samplingPanel.element); |
| |
| this.controls = new ControlPanel( |
| this.vizColumn, |
| () => this.play(), |
| () => this.pause(), |
| () => this.reset(), |
| (speed) => this.setSpeed(speed), |
| (progress) => this.seek(progress), |
| () => this.step(), |
| (interval) => this.setSampleInterval(interval), |
| ); |
| this.controls.setDuration(this.trace.duration); |
| } |
| |
| _getGroundTruthFunctions() { |
| const functions = new Set(); |
| this.trace.events.forEach((event) => { |
| if (event.type === "call") { |
| functions.add(event.functionName); |
| } |
| }); |
| return [...functions]; |
| } |
| |
| play() { |
| this.isPlaying = true; |
| } |
| |
| pause() { |
| this.isPlaying = false; |
| } |
| |
| reset() { |
| this.currentTime = 0; |
| this.eventIndex = 0; |
| this.isPlaying = false; |
| this.lastSampleTime = 0; |
| this.stackViz.clear(); |
| this.codePanel.reset(); |
| this.samplingPanel.reset(); |
| this.controls.updateTimeDisplay(0, this.trace.duration); |
| } |
| |
| setSpeed(speed) { |
| this.playbackSpeed = speed; |
| } |
| |
| setSampleInterval(interval) { |
| this.sampleInterval = interval; |
| this.samplingPanel.setSampleInterval(interval); |
| } |
| |
| seek(progress) { |
| this.currentTime = progress * this.trace.duration; |
| this.eventIndex = 0; |
| this.lastSampleTime = 0; |
| this._rebuildState(); |
| } |
| |
| step() { |
| this.pause(); |
| |
| const nextEvent = this.trace.getNextEvent(this.currentTime); |
| |
| if (nextEvent) { |
| // Calculate delta to reach next event + epsilon |
| const targetTime = nextEvent.timestamp + 0.1; |
| const delta = targetTime - this.currentTime; |
| if (delta > 0) { |
| this._advanceTime(delta); |
| } |
| } |
| } |
| |
| _animate(currentTime = performance.now()) { |
| const deltaTime = currentTime - this.lastTime; |
| this.lastTime = currentTime; |
| |
| this.update(deltaTime); |
| requestAnimationFrame((t) => this._animate(t)); |
| } |
| |
| update(deltaTime) { |
| if (!this.isPlaying) { |
| this.controls.updateTimeDisplay(this.currentTime, this.trace.duration); |
| return; |
| } |
| |
| const virtualDelta = deltaTime * this.playbackSpeed; |
| this._advanceTime(virtualDelta); |
| } |
| |
| _advanceTime(virtualDelta) { |
| this.currentTime += virtualDelta; |
| |
| if (this.currentTime >= this.trace.duration) { |
| this.currentTime = this.trace.duration; |
| this.isPlaying = false; |
| this.controls.pause(); |
| } |
| |
| while (this.eventIndex < this.trace.events.length) { |
| const event = this.trace.events[this.eventIndex]; |
| |
| if (event.timestamp > this.currentTime) break; |
| |
| this._processEvent(event); |
| this.eventIndex++; |
| } |
| |
| this.controls.updateTimeDisplay(this.currentTime, this.trace.duration); |
| |
| if (this.currentTime - this.lastSampleTime >= this.sampleInterval) { |
| this._takeSample(); |
| this.lastSampleTime = this.currentTime; |
| } |
| } |
| |
| _processEvent(event) { |
| this.stackViz.processEvent(event); |
| |
| if (event.type === "call") { |
| this.codePanel.highlightLine(event.lineno); |
| } else if (event.type === "return") { |
| const currentStack = this.trace.getStackAt(this.currentTime); |
| if (currentStack.length > 0) { |
| this.codePanel.highlightLine( |
| currentStack[currentStack.length - 1].line, |
| ); |
| } else { |
| this.codePanel.highlightLine(null); |
| } |
| } else if (event.type === "line") { |
| this.codePanel.highlightLine(event.lineno); |
| } |
| } |
| |
| _takeSample() { |
| this.effectsManager.triggerSamplingEffect( |
| this.stackViz, |
| this.samplingPanel, |
| this.currentTime, |
| this.trace, |
| ); |
| } |
| |
| _rebuildState() { |
| this.stackViz.clear(); |
| this.codePanel.reset(); |
| this.samplingPanel.reset(); |
| |
| for (let t = 0; t < this.currentTime; t += this.sampleInterval) { |
| const stack = this.trace.getStackAt(t); |
| this.samplingPanel.addSample(stack); |
| this.lastSampleTime = t; |
| } |
| |
| const stack = this.trace.getStackAt(this.currentTime); |
| this.stackViz.updateToMatch(stack); |
| |
| if (stack.length > 0) { |
| this.codePanel.highlightLine(stack[stack.length - 1].line); |
| } |
| |
| this.eventIndex = this.trace.getEventsUntil(this.currentTime).length; |
| } |
| } |
| |
| // ============================================================================ |
| // Initialize |
| // ============================================================================ |
| |
| function init() { |
| // If trace data hasn't been injected yet (local dev), don't initialize |
| if (!DEMO_SIMPLE) return; |
| |
| const appContainer = document.getElementById("sampling-profiler-viz"); |
| if (appContainer) { |
| new SamplingVisualization(appContainer); |
| } |
| } |
| |
| if (document.readyState === "loading") { |
| document.addEventListener("DOMContentLoaded", init); |
| } else { |
| init(); |
| } |
| })(); |