/** * 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 };