/**
* Performance Visualization Components
*
* Provides flamegraph and timeline visualization for performance profiling data.
*
* @module PerformanceVisualizer
*/
/**
* Flamegraph Visualizer
*
* Renders interactive flamegraph from profiling samples
*/
export class FlamegraphVisualizer {
constructor(container, options = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) {
throw new Error('Container element not found');
}
this.options = {
width: options.width ?? this.container.clientWidth,
height: options.height ?? 400,
barHeight: options.barHeight ?? 20,
barPadding: options.barPadding ?? 2,
colorScheme: options.colorScheme ?? 'category', // 'category', 'duration', 'monochrome'
minWidth: options.minWidth ?? 0.5, // Minimum width in pixels to render
...options
};
this.data = null;
this.svg = null;
this.tooltip = null;
this.selectedNode = null;
this.init();
}
/**
* Initialize SVG canvas
* @private
*/
init() {
// Create SVG element
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.svg.setAttribute('width', this.options.width);
this.svg.setAttribute('height', this.options.height);
this.svg.style.fontFamily = 'monospace';
this.svg.style.fontSize = '12px';
// Create tooltip
this.tooltip = document.createElement('div');
this.tooltip.style.position = 'absolute';
this.tooltip.style.padding = '8px';
this.tooltip.style.background = 'rgba(0, 0, 0, 0.8)';
this.tooltip.style.color = 'white';
this.tooltip.style.borderRadius = '4px';
this.tooltip.style.pointerEvents = 'none';
this.tooltip.style.display = 'none';
this.tooltip.style.zIndex = '1000';
this.tooltip.style.fontSize = '12px';
this.container.style.position = 'relative';
this.container.appendChild(this.svg);
this.container.appendChild(this.tooltip);
}
/**
* Render flamegraph from data
* @param {Object} data - Flamegraph data (hierarchical structure)
*/
render(data) {
this.data = data;
this.clear();
if (!data) {
return;
}
// Calculate total value for width scaling
const totalValue = this.calculateTotalValue(data);
// Render nodes recursively
this.renderNode(data, 0, 0, this.options.width, totalValue);
}
/**
* Render a single flamegraph node
* @private
*/
renderNode(node, x, y, width, totalValue) {
const { barHeight, barPadding, minWidth } = this.options;
// Skip if too small to render
if (width < minWidth) {
return;
}
// Create rectangle
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', x);
rect.setAttribute('y', y);
rect.setAttribute('width', width);
rect.setAttribute('height', barHeight - barPadding);
rect.setAttribute('fill', this.getColor(node));
rect.setAttribute('stroke', '#fff');
rect.setAttribute('stroke-width', '0.5');
rect.style.cursor = 'pointer';
// Add interactivity
rect.addEventListener('mouseenter', (e) => this.showTooltip(e, node));
rect.addEventListener('mouseleave', () => this.hideTooltip());
rect.addEventListener('click', () => this.selectNode(node));
this.svg.appendChild(rect);
// Create text label
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', x + 4);
text.setAttribute('y', y + barHeight / 2 + 4);
text.setAttribute('fill', this.getTextColor(node));
text.setAttribute('pointer-events', 'none');
text.textContent = this.truncateText(node.name, width);
this.svg.appendChild(text);
// Render children
if (node.children && node.children.length > 0) {
let childX = x;
const childY = y + barHeight;
for (const child of node.children) {
const childWidth = (child.value / node.value) * width;
this.renderNode(child, childX, childY, childWidth, totalValue);
childX += childWidth;
}
}
}
/**
* Calculate total value in tree
* @private
*/
calculateTotalValue(node) {
if (!node.children || node.children.length === 0) {
return node.value;
}
return node.children.reduce((sum, child) => {
return sum + this.calculateTotalValue(child);
}, 0);
}
/**
* Get color for node based on color scheme
* @private
*/
getColor(node) {
if (this.options.colorScheme === 'monochrome') {
return '#3b82f6';
}
if (this.options.colorScheme === 'duration') {
// Color based on node value (duration)
const intensity = Math.min(node.value / 100, 1);
const r = Math.floor(255 * intensity);
const g = Math.floor(255 * (1 - intensity));
return `rgb(${r}, ${g}, 100)`;
}
// Category-based coloring (default)
const colorMap = {
'action': '#4CAF50',
'render': '#2196F3',
'state': '#FF9800',
'network': '#9C27B0',
'dom': '#F44336',
'compute': '#00BCD4'
};
// Determine category from function name
for (const [keyword, color] of Object.entries(colorMap)) {
if (node.name.toLowerCase().includes(keyword)) {
return color;
}
}
// Hash-based color for consistency
return this.hashColor(node.name);
}
/**
* Get text color for contrast
* @private
*/
getTextColor(node) {
return '#fff'; // White text for all colors (good contrast)
}
/**
* Hash function for consistent colors
* @private
*/
hashColor(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = hash % 360;
return `hsl(${hue}, 70%, 50%)`;
}
/**
* Truncate text to fit width
* @private
*/
truncateText(text, width) {
const charWidth = 7; // Approximate character width
const maxChars = Math.floor(width / charWidth) - 2;
if (text.length <= maxChars) {
return text;
}
return text.substring(0, maxChars) + '…';
}
/**
* Show tooltip with node information
* @private
*/
showTooltip(event, node) {
const percentage = this.data
? ((node.value / this.calculateTotalValue(this.data)) * 100).toFixed(2)
: '0';
this.tooltip.innerHTML = `
${node.name}
Time: ${node.value.toFixed(2)}ms
${percentage}% of total
${node.children ? `${node.children.length} child(ren)` : 'Leaf node'}
`;
this.tooltip.style.display = 'block';
this.tooltip.style.left = `${event.pageX + 10}px`;
this.tooltip.style.top = `${event.pageY + 10}px`;
}
/**
* Hide tooltip
* @private
*/
hideTooltip() {
this.tooltip.style.display = 'none';
}
/**
* Select node (zoom/highlight)
* @private
*/
selectNode(node) {
this.selectedNode = node;
console.log('[Flamegraph] Selected node:', node);
// Could implement zoom functionality here
// this.render(node);
}
/**
* Clear flamegraph
*/
clear() {
while (this.svg.firstChild) {
this.svg.removeChild(this.svg.firstChild);
}
}
/**
* Export flamegraph as SVG
* @returns {string} SVG string
*/
exportSVG() {
return this.svg.outerHTML;
}
/**
* Export flamegraph as PNG
* @returns {Promise} PNG image blob
*/
async exportPNG() {
const svgData = new XMLSerializer().serializeToString(this.svg);
const canvas = document.createElement('canvas');
canvas.width = this.options.width;
canvas.height = this.options.height;
const ctx = canvas.getContext('2d');
const img = new Image();
return new Promise((resolve, reject) => {
img.onload = () => {
ctx.drawImage(img, 0, 0);
canvas.toBlob(resolve, 'image/png');
};
img.onerror = reject;
img.src = 'data:image/svg+xml;base64,' + btoa(svgData);
});
}
}
/**
* Timeline Visualizer
*
* Renders interactive timeline from profiling events
*/
export class TimelineVisualizer {
constructor(container, options = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) {
throw new Error('Container element not found');
}
this.options = {
width: options.width ?? this.container.clientWidth,
height: options.height ?? 200,
trackHeight: options.trackHeight ?? 30,
padding: options.padding ?? { top: 20, right: 20, bottom: 30, left: 60 },
...options
};
this.events = [];
this.canvas = null;
this.ctx = null;
this.tooltip = null;
this.scale = { x: 1, y: 1 };
this.offset = { x: 0, y: 0 };
this.init();
}
/**
* Initialize canvas
* @private
*/
init() {
// Create canvas
this.canvas = document.createElement('canvas');
this.canvas.width = this.options.width;
this.canvas.height = this.options.height;
this.canvas.style.cursor = 'crosshair';
this.ctx = this.canvas.getContext('2d');
// Create tooltip
this.tooltip = document.createElement('div');
this.tooltip.style.position = 'absolute';
this.tooltip.style.padding = '8px';
this.tooltip.style.background = 'rgba(0, 0, 0, 0.8)';
this.tooltip.style.color = 'white';
this.tooltip.style.borderRadius = '4px';
this.tooltip.style.pointerEvents = 'none';
this.tooltip.style.display = 'none';
this.tooltip.style.zIndex = '1000';
this.tooltip.style.fontSize = '12px';
this.container.style.position = 'relative';
this.container.appendChild(this.canvas);
this.container.appendChild(this.tooltip);
// Add mouse events
this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
this.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
}
/**
* Render timeline from events
* @param {Array} events - Timeline events
*/
render(events) {
this.events = events;
this.clear();
if (!events || events.length === 0) {
return;
}
// Calculate time range
const times = events.map(e => e.timestamp || e.relativeTime || 0);
const minTime = Math.min(...times);
const maxTime = Math.max(...times);
const duration = maxTime - minTime;
// Calculate scale
const { padding } = this.options;
const plotWidth = this.options.width - padding.left - padding.right;
const plotHeight = this.options.height - padding.top - padding.bottom;
this.scale.x = plotWidth / duration;
// Draw axes
this.drawAxes(minTime, maxTime, duration);
// Draw events
const tracks = this.organizeIntoTracks(events);
this.scale.y = plotHeight / tracks.length;
tracks.forEach((track, index) => {
track.forEach(event => {
this.drawEvent(event, index, minTime);
});
});
}
/**
* Organize events into non-overlapping tracks
* @private
*/
organizeIntoTracks(events) {
const tracks = [];
const sortedEvents = [...events].sort((a, b) => {
const aTime = a.timestamp || a.relativeTime || 0;
const bTime = b.timestamp || b.relativeTime || 0;
return aTime - bTime;
});
for (const event of sortedEvents) {
const eventStart = event.timestamp || event.relativeTime || 0;
const eventEnd = event.type === 'measure'
? eventStart + event.duration
: eventStart + 0.1;
// Find track where event fits
let placed = false;
for (const track of tracks) {
const conflicts = track.some(e => {
const eStart = e.timestamp || e.relativeTime || 0;
const eEnd = e.type === 'measure'
? eStart + e.duration
: eStart + 0.1;
return eventStart < eEnd && eventEnd > eStart;
});
if (!conflicts) {
track.push(event);
placed = true;
break;
}
}
if (!placed) {
tracks.push([event]);
}
}
return tracks;
}
/**
* Draw axes and grid
* @private
*/
drawAxes(minTime, maxTime, duration) {
const { padding } = this.options;
const { ctx } = this;
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
// Y-axis
ctx.beginPath();
ctx.moveTo(padding.left, padding.top);
ctx.lineTo(padding.left, this.options.height - padding.bottom);
ctx.stroke();
// X-axis
ctx.beginPath();
ctx.moveTo(padding.left, this.options.height - padding.bottom);
ctx.lineTo(this.options.width - padding.right, this.options.height - padding.bottom);
ctx.stroke();
// Time labels
ctx.fillStyle = '#666';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
const numLabels = 5;
for (let i = 0; i <= numLabels; i++) {
const time = minTime + (duration / numLabels) * i;
const x = padding.left + (time - minTime) * this.scale.x;
const y = this.options.height - padding.bottom + 15;
ctx.fillText(`${time.toFixed(1)}ms`, x, y);
}
}
/**
* Draw a single event
* @private
*/
drawEvent(event, trackIndex, minTime) {
const { padding } = this.options;
const { ctx } = this;
const startTime = event.timestamp || event.relativeTime || 0;
const x = padding.left + (startTime - minTime) * this.scale.x;
const y = padding.top + trackIndex * this.scale.y;
const height = this.scale.y - 4;
if (event.type === 'mark') {
// Draw mark as vertical line
ctx.strokeStyle = event.color || '#2196F3';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x, y + height);
ctx.stroke();
// Draw marker
ctx.fillStyle = event.color || '#2196F3';
ctx.beginPath();
ctx.arc(x, y + height / 2, 4, 0, Math.PI * 2);
ctx.fill();
} else if (event.type === 'measure') {
// Draw measure as rectangle
const width = Math.max(event.duration * this.scale.x, 2);
ctx.fillStyle = event.color || '#4CAF50';
ctx.fillRect(x, y, width, height);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 0.5;
ctx.strokeRect(x, y, width, height);
// Draw label if wide enough
if (width > 30) {
ctx.fillStyle = '#fff';
ctx.font = '10px monospace';
ctx.textAlign = 'left';
ctx.fillText(event.name, x + 4, y + height / 2 + 4);
}
}
// Store event bounds for hover detection
event._bounds = {
x,
y,
width: event.type === 'measure' ? event.duration * this.scale.x : 8,
height
};
}
/**
* Handle mouse move for tooltip
* @private
*/
handleMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Find hovered event
const hoveredEvent = this.events.find(event => {
if (!event._bounds) return false;
const { x, y, width, height } = event._bounds;
return mouseX >= x && mouseX <= x + width &&
mouseY >= y && mouseY <= y + height;
});
if (hoveredEvent) {
this.showTooltip(e, hoveredEvent);
} else {
this.hideTooltip();
}
}
/**
* Handle mouse leave
* @private
*/
handleMouseLeave() {
this.hideTooltip();
}
/**
* Show tooltip with event information
* @private
*/
showTooltip(event, timelineEvent) {
const time = timelineEvent.timestamp || timelineEvent.relativeTime || 0;
this.tooltip.innerHTML = `
${timelineEvent.name}
Type: ${timelineEvent.type}
Time: ${time.toFixed(2)}ms
${timelineEvent.duration ? `Duration: ${timelineEvent.duration.toFixed(2)}ms` : ''}
`;
this.tooltip.style.display = 'block';
this.tooltip.style.left = `${event.pageX + 10}px`;
this.tooltip.style.top = `${event.pageY + 10}px`;
}
/**
* Hide tooltip
* @private
*/
hideTooltip() {
this.tooltip.style.display = 'none';
}
/**
* Clear timeline
*/
clear() {
this.ctx.clearRect(0, 0, this.options.width, this.options.height);
}
/**
* Export timeline as PNG
* @returns {Promise} PNG image blob
*/
async exportPNG() {
return new Promise((resolve) => {
this.canvas.toBlob(resolve, 'image/png');
});
}
}
export default { FlamegraphVisualizer, TimelineVisualizer };