mirror of
https://github.com/python/cpython.git
synced 2026-02-21 22:50:55 +00:00
1163 lines
34 KiB
JavaScript
1163 lines
34 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
})();
|