| const EMBEDDED_DATA = {{FLAMEGRAPH_DATA}}; | |
| // Global string table for resolving string indices | |
| let stringTable = []; | |
| let normalData = null; | |
| let invertedData = null; | |
| let currentThreadFilter = 'all'; | |
| let isInverted = false; | |
| let useModuleNames = true; | |
| let zoomedNodeValue = null; | |
| // Heat colors are now defined in CSS variables (--heat-1 through --heat-8) | |
| // and automatically switch with theme changes - no JS color arrays needed! | |
| // Opcode mappings - loaded from embedded data (generated by Python) | |
| let OPCODE_NAMES = {}; | |
| let DEOPT_MAP = {}; | |
| // Initialize opcode mappings from embedded data | |
| function initOpcodeMapping(data) { | |
| if (data && data.opcode_mapping) { | |
| OPCODE_NAMES = data.opcode_mapping.names || {}; | |
| DEOPT_MAP = data.opcode_mapping.deopt || {}; | |
| } | |
| } | |
| // Get opcode info from opcode number | |
| function getOpcodeInfo(opcode) { | |
| const opname = OPCODE_NAMES[opcode] || `<${opcode}>`; | |
| const baseOpcode = DEOPT_MAP[opcode]; | |
| const isSpecialized = baseOpcode !== undefined; | |
| const baseOpname = isSpecialized ? (OPCODE_NAMES[baseOpcode] || `<${baseOpcode}>`) : opname; | |
| return { | |
| opname: opname, | |
| baseOpname: baseOpname, | |
| isSpecialized: isSpecialized | |
| }; | |
| } | |
| // ============================================================================ | |
| // String Resolution | |
| // ============================================================================ | |
| function resolveString(index, table = stringTable) { | |
| if (index === null || index === undefined) { | |
| return null; | |
| } | |
| if (typeof index === 'number' && index >= 0 && index < table.length) { | |
| return table[index]; | |
| } | |
| return String(index); | |
| } | |
| function resolveStringIndices(node, table) { | |
| if (!node) return node; | |
| const resolved = { ...node }; | |
| if (typeof resolved.name === 'number') { | |
| resolved.name = resolveString(resolved.name, table); | |
| } | |
| if (typeof resolved.filename === 'number') { | |
| resolved.filename = resolveString(resolved.filename, table); | |
| } | |
| if (typeof resolved.funcname === 'number') { | |
| resolved.funcname = resolveString(resolved.funcname, table); | |
| } | |
| if (typeof resolved.module === 'number') { | |
| resolved.module = resolveString(resolved.module, table); | |
| } | |
| if (typeof resolved.label === 'number') { | |
| resolved.label = resolveString(resolved.label, table); | |
| } | |
| if (Array.isArray(resolved.source)) { | |
| resolved.source = resolved.source.map(index => | |
| typeof index === 'number' ? resolveString(index, table) : index | |
| ); | |
| } | |
| if (Array.isArray(resolved.children)) { | |
| resolved.children = resolved.children.map(child => resolveStringIndices(child, table)); | |
| } | |
| return resolved; | |
| } | |
| // Escape HTML special characters | |
| function escapeHtml(str) { | |
| return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| } | |
| // Get display path based on user preference (module or full path) | |
| function getDisplayName(moduleName, filename) { | |
| if (useModuleNames) { | |
| return moduleName || filename; | |
| } | |
| return filename; | |
| } | |
| function samplesToMilliseconds(samples, data) { | |
| return (samples * data.stats.sample_interval_usec / 1000).toFixed(2); | |
| } | |
| function selectFlamegraphData(selectedThreadId = null) { | |
| let baseData = isShowingElided ? elidedFlamegraphData : normalData; | |
| if (selectedThreadId !== null) { | |
| baseData = filterDataByThread(baseData, selectedThreadId); | |
| } | |
| if (!isInverted) { | |
| return baseData; | |
| } | |
| // Thread-filtered trees have different values, so invert them after filtering | |
| // instead of using the cached all-thread tree. | |
| if (selectedThreadId !== null) { | |
| return generateInvertedFlamegraph(baseData); | |
| } | |
| if (isShowingElided) { | |
| if (!invertedElidedData) { | |
| invertedElidedData = generateInvertedFlamegraph(baseData); | |
| } | |
| return invertedElidedData; | |
| } | |
| if (!invertedData) { | |
| invertedData = generateInvertedFlamegraph(baseData); | |
| } | |
| return invertedData; | |
| } | |
| function updateFlamegraphView() { | |
| const selectedThreadId = currentThreadFilter !== 'all' ? parseInt(currentThreadFilter, 10) : null; | |
| const selectedData = selectFlamegraphData(selectedThreadId); | |
| const tooltip = createPythonTooltip(selectedData); | |
| const chart = createFlamegraph(tooltip, selectedData.value, selectedData); | |
| renderFlamegraph(chart, selectedData); | |
| populateThreadStats(selectedData, selectedThreadId); | |
| } | |
| // ============================================================================ | |
| // Theme & UI Controls | |
| // ============================================================================ | |
| function toggleTheme() { | |
| toggleAndSaveTheme(); | |
| // Re-render flamegraph with new theme colors | |
| if (window.flamegraphData && normalData) { | |
| updateFlamegraphView(); | |
| } | |
| } | |
| function toggleSidebar() { | |
| const sidebar = document.getElementById('sidebar'); | |
| if (sidebar) { | |
| const isCollapsing = !sidebar.classList.contains('collapsed'); | |
| if (isCollapsing) { | |
| // Save current width before collapsing | |
| const currentWidth = sidebar.offsetWidth; | |
| sidebar.dataset.expandedWidth = currentWidth; | |
| localStorage.setItem('flamegraph-sidebar-width', currentWidth); | |
| } else { | |
| // Restore width when expanding | |
| const savedWidth = sidebar.dataset.expandedWidth || localStorage.getItem('flamegraph-sidebar-width'); | |
| if (savedWidth) { | |
| sidebar.style.width = savedWidth + 'px'; | |
| } | |
| } | |
| sidebar.classList.toggle('collapsed'); | |
| localStorage.setItem('flamegraph-sidebar', sidebar.classList.contains('collapsed') ? 'collapsed' : 'expanded'); | |
| // Resize chart after sidebar animation | |
| setTimeout(() => { | |
| resizeChart(); | |
| }, 300); | |
| } | |
| } | |
| function resizeChart() { | |
| if (window.flamegraphChart && window.flamegraphData) { | |
| const chartArea = document.querySelector('.chart-area'); | |
| if (chartArea) { | |
| window.flamegraphChart.width(chartArea.clientWidth - 32); | |
| d3.select("#chart").datum(window.flamegraphData).call(window.flamegraphChart); | |
| } | |
| } | |
| } | |
| function toggleSection(sectionId) { | |
| const section = document.getElementById(sectionId); | |
| if (section) { | |
| section.classList.toggle('collapsed'); | |
| // Save state | |
| const collapsedSections = JSON.parse(localStorage.getItem('flamegraph-collapsed-sections') || '{}'); | |
| collapsedSections[sectionId] = section.classList.contains('collapsed'); | |
| localStorage.setItem('flamegraph-collapsed-sections', JSON.stringify(collapsedSections)); | |
| } | |
| } | |
| // Restore theme from localStorage, or use browser preference | |
| function restoreUIState() { | |
| applyTheme(getPreferredTheme()); | |
| // Restore sidebar state | |
| const savedSidebar = localStorage.getItem('flamegraph-sidebar'); | |
| if (savedSidebar === 'collapsed') { | |
| const sidebar = document.getElementById('sidebar'); | |
| if (sidebar) sidebar.classList.add('collapsed'); | |
| } | |
| // Restore sidebar width | |
| const savedWidth = localStorage.getItem('flamegraph-sidebar-width'); | |
| if (savedWidth) { | |
| const sidebar = document.getElementById('sidebar'); | |
| if (sidebar) { | |
| sidebar.style.width = savedWidth + 'px'; | |
| } | |
| } | |
| // Restore collapsed sections | |
| const collapsedSections = JSON.parse(localStorage.getItem('flamegraph-collapsed-sections') || '{}'); | |
| for (const [sectionId, isCollapsed] of Object.entries(collapsedSections)) { | |
| if (isCollapsed) { | |
| const section = document.getElementById(sectionId); | |
| if (section) section.classList.add('collapsed'); | |
| } | |
| } | |
| } | |
| // ============================================================================ | |
| // Logo/Favicon Setup | |
| // ============================================================================ | |
| function setupLogos() { | |
| const logo = document.querySelector('.sidebar-logo-img img'); | |
| if (!logo) return; | |
| const navbarLogoContainer = document.getElementById('navbar-logo'); | |
| if (navbarLogoContainer) { | |
| const navbarLogo = logo.cloneNode(true); | |
| navbarLogoContainer.appendChild(navbarLogo); | |
| } | |
| const favicon = document.createElement('link'); | |
| favicon.rel = 'icon'; | |
| favicon.type = 'image/png'; | |
| favicon.href = logo.src; | |
| document.head.appendChild(favicon); | |
| } | |
| // ============================================================================ | |
| // Status Bar | |
| // ============================================================================ | |
| function updateStatusBar(nodeData, rootValue, data) { | |
| const funcname = resolveString(nodeData.funcname) || resolveString(nodeData.name) || "--"; | |
| const filename = resolveString(nodeData.filename) || ""; | |
| const moduleName = resolveString(nodeData.module) || ""; | |
| const lineno = nodeData.lineno; | |
| const timeMs = samplesToMilliseconds(nodeData.value, data); | |
| const percent = rootValue > 0 ? ((nodeData.value / rootValue) * 100).toFixed(1) : "0.0"; | |
| const brandEl = document.getElementById('status-brand'); | |
| const taglineEl = document.getElementById('status-tagline'); | |
| if (brandEl) brandEl.style.display = 'none'; | |
| if (taglineEl) taglineEl.style.display = 'none'; | |
| const locationEl = document.getElementById('status-location'); | |
| const funcItem = document.getElementById('status-func-item'); | |
| const timeItem = document.getElementById('status-time-item'); | |
| const percentItem = document.getElementById('status-percent-item'); | |
| if (locationEl) locationEl.style.display = filename && filename !== "~" ? 'flex' : 'none'; | |
| if (funcItem) funcItem.style.display = 'flex'; | |
| if (timeItem) timeItem.style.display = 'flex'; | |
| if (percentItem) percentItem.style.display = 'flex'; | |
| const fileEl = document.getElementById('status-file'); | |
| if (fileEl && filename && filename !== "~") { | |
| const displayName = getDisplayName(moduleName, filename); | |
| fileEl.textContent = lineno ? `${displayName}:${lineno}` : displayName; | |
| } | |
| const funcEl = document.getElementById('status-func'); | |
| if (funcEl) funcEl.textContent = funcname.length > 40 ? funcname.substring(0, 37) + '...' : funcname; | |
| const timeEl = document.getElementById('status-time'); | |
| if (timeEl) timeEl.textContent = `${timeMs} ms`; | |
| const percentEl = document.getElementById('status-percent'); | |
| if (percentEl) percentEl.textContent = `${percent}%`; | |
| } | |
| function clearStatusBar() { | |
| const ids = ['status-location', 'status-func-item', 'status-time-item', 'status-percent-item']; | |
| ids.forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) el.style.display = 'none'; | |
| }); | |
| const brandEl = document.getElementById('status-brand'); | |
| const taglineEl = document.getElementById('status-tagline'); | |
| if (brandEl) brandEl.style.display = 'flex'; | |
| if (taglineEl) taglineEl.style.display = 'flex'; | |
| } | |
| // ============================================================================ | |
| // Tooltip | |
| // ============================================================================ | |
| function createPythonTooltip(data) { | |
| const pythonTooltip = flamegraph.tooltip.defaultFlamegraphTooltip(); | |
| pythonTooltip.show = function (d, element) { | |
| if (!this._tooltip) { | |
| this._tooltip = d3.select("body") | |
| .append("div") | |
| .attr("class", "python-tooltip") | |
| .style("opacity", 0); | |
| } | |
| const timeMs = samplesToMilliseconds(d.data.value, data); | |
| const selfSamples = d.data.self || 0; | |
| const selfMs = samplesToMilliseconds(selfSamples, data); | |
| const percentage = ((d.data.value / data.value) * 100).toFixed(2); | |
| const relativePercentage = Math.min(100, ((d.data.value / (zoomedNodeValue ?? data.value)) * 100)).toFixed(2); | |
| const calls = d.data.calls || 0; | |
| const childCount = d.children ? d.children.length : 0; | |
| const source = d.data.source; | |
| const funcname = resolveString(d.data.funcname) || resolveString(d.data.name); | |
| const filename = resolveString(d.data.filename) || ""; | |
| const moduleName = resolveString(d.data.module) || ""; | |
| const displayName = escapeHtml(useModuleNames ? (moduleName || filename) : filename); | |
| const isSpecialFrame = filename === "~"; | |
| // Build source section | |
| let sourceSection = ""; | |
| if (source && Array.isArray(source) && source.length > 0) { | |
| const sourceLines = source | |
| .map((line) => { | |
| const isCurrent = line.startsWith("→"); | |
| const escaped = escapeHtml(line); | |
| return `<div class="tooltip-source-line${isCurrent ? ' current' : ''}">${escaped}</div>`; | |
| }) | |
| .join(""); | |
| sourceSection = ` | |
| <div class="tooltip-source"> | |
| <div class="tooltip-source-title">Source Code:</div> | |
| <div class="tooltip-source-code">${sourceLines}</div> | |
| </div>`; | |
| } | |
| // Create bytecode/opcode section if available | |
| let opcodeSection = ""; | |
| const opcodes = d.data.opcodes; | |
| if (opcodes && typeof opcodes === 'object' && Object.keys(opcodes).length > 0) { | |
| // Sort opcodes by sample count (descending) | |
| const sortedOpcodes = Object.entries(opcodes) | |
| .sort((a, b) => b[1] - a[1]) | |
| .slice(0, 8); // Limit to top 8 | |
| const totalOpcodeSamples = sortedOpcodes.reduce((sum, [, count]) => sum + count, 0); | |
| const maxCount = sortedOpcodes[0][1] || 1; | |
| const opcodeLines = sortedOpcodes.map(([opcode, count]) => { | |
| const opcodeInfo = getOpcodeInfo(parseInt(opcode, 10)); | |
| const pct = ((count / totalOpcodeSamples) * 100).toFixed(1); | |
| const barWidth = (count / maxCount) * 100; | |
| const specializedBadge = opcodeInfo.isSpecialized | |
| ? '<span class="tooltip-opcode-badge">SPECIALIZED</span>' | |
| : ''; | |
| const baseOpHint = opcodeInfo.isSpecialized | |
| ? `<span class="tooltip-opcode-base-hint">(${opcodeInfo.baseOpname})</span>` | |
| : ''; | |
| const nameClass = opcodeInfo.isSpecialized | |
| ? 'tooltip-opcode-name specialized' | |
| : 'tooltip-opcode-name'; | |
| return ` | |
| <div class="tooltip-opcode-row"> | |
| <div class="${nameClass}"> | |
| ${opcodeInfo.opname}${baseOpHint}${specializedBadge} | |
| </div> | |
| <div class="tooltip-opcode-count">${count.toLocaleString()} (${pct}%)</div> | |
| <div class="tooltip-opcode-bar"> | |
| <div class="tooltip-opcode-bar-fill" style="width: ${barWidth}%;"></div> | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| opcodeSection = ` | |
| <div class="tooltip-opcodes"> | |
| <div class="tooltip-opcodes-title">Bytecode Instructions:</div> | |
| <div class="tooltip-opcodes-list"> | |
| ${opcodeLines} | |
| </div> | |
| </div>`; | |
| } | |
| const fileLocationHTML = isSpecialFrame ? "" : ` | |
| <div class="tooltip-location">${displayName}${d.data.lineno ? ":" + d.data.lineno : ""}</div>`; | |
| // Differential stats section | |
| let diffSection = ""; | |
| if (d.data.diff !== undefined && d.data.baseline !== undefined) { | |
| const baselineSelf = samplesToMilliseconds(d.data.baseline, data); | |
| const currentSelf = samplesToMilliseconds(d.data.self_time || 0, data); | |
| const diffMs = samplesToMilliseconds(d.data.diff, data); | |
| const diffPct = d.data.diff_pct; | |
| const sign = d.data.diff >= 0 ? "+" : ""; | |
| const diffClass = d.data.diff > 0 ? "regression" : (d.data.diff < 0 ? "improvement" : "neutral"); | |
| diffSection = ` | |
| <div class="tooltip-diff"> | |
| <div class="tooltip-diff-title">Self-Time Comparison:</div> | |
| <div class="tooltip-diff-row"> | |
| <span class="tooltip-stat-label">Baseline Self:</span> | |
| <span class="tooltip-stat-value">${baselineSelf} ms</span> | |
| </div> | |
| <div class="tooltip-diff-row"> | |
| <span class="tooltip-stat-label">Current Self:</span> | |
| <span class="tooltip-stat-value">${currentSelf} ms</span> | |
| </div> | |
| <div class="tooltip-diff-row ${diffClass}"> | |
| <span class="tooltip-stat-label">Difference:</span> | |
| <span class="tooltip-stat-value">${sign}${diffMs} ms (${sign}${diffPct.toFixed(1)}%)</span> | |
| </div> | |
| </div>`; | |
| } | |
| const tooltipHTML = ` | |
| <div class="tooltip-header"> | |
| <div class="tooltip-title">${funcname}</div> | |
| ${fileLocationHTML} | |
| </div> | |
| <div class="tooltip-stats"> | |
| <span class="tooltip-stat-label">Total Time:</span> | |
| <span class="tooltip-stat-value">${timeMs} ms</span> | |
| ${selfSamples > 0 ? ` | |
| <span class="tooltip-stat-label">Self Time:</span> | |
| <span class="tooltip-stat-value">${selfMs} ms</span> | |
| ` : ''} | |
| <span class="tooltip-stat-label">Percentage:</span> | |
| <span class="tooltip-stat-value accent">${percentage}%</span> | |
| ${relativePercentage != percentage && relativePercentage != "100.00" ? ` | |
| <span class="tooltip-stat-label">Relative Percentage:</span> | |
| <span class="tooltip-stat-value accent">${relativePercentage}%</span> | |
| ` : ''} | |
| ${calls > 0 ? ` | |
| <span class="tooltip-stat-label">Function Calls:</span> | |
| <span class="tooltip-stat-value">${calls.toLocaleString()}</span> | |
| ` : ''} | |
| ${childCount > 0 ? ` | |
| <span class="tooltip-stat-label">Child Functions:</span> | |
| <span class="tooltip-stat-value">${childCount}</span> | |
| ` : ''} | |
| </div> | |
| ${diffSection} | |
| ${sourceSection} | |
| ${opcodeSection} | |
| <div class="tooltip-hint"> | |
| ${childCount > 0 ? "Click to zoom into this function" : "Leaf function - no children"} | |
| </div> | |
| `; | |
| // Position tooltip | |
| const event = d3.event || window.event; | |
| const mouseX = event.pageX || event.clientX; | |
| const mouseY = event.pageY || event.clientY; | |
| const padding = 12; | |
| this._tooltip.html(tooltipHTML); | |
| // Measure tooltip | |
| const node = this._tooltip.style("display", "block").style("opacity", 0).node(); | |
| const tooltipWidth = node.offsetWidth || 320; | |
| const tooltipHeight = node.offsetHeight || 200; | |
| // Calculate position | |
| let left = mouseX + padding; | |
| let top = mouseY + padding; | |
| if (left + tooltipWidth > window.innerWidth) { | |
| left = mouseX - tooltipWidth - padding; | |
| if (left < 0) left = padding; | |
| } | |
| if (top + tooltipHeight > window.innerHeight) { | |
| top = mouseY - tooltipHeight - padding; | |
| if (top < 0) top = padding; | |
| } | |
| this._tooltip | |
| .style("left", left + "px") | |
| .style("top", top + "px") | |
| .transition() | |
| .duration(150) | |
| .style("opacity", 1); | |
| // Update status bar | |
| updateStatusBar(d.data, data.value, data); | |
| }; | |
| pythonTooltip.hide = function () { | |
| if (this._tooltip) { | |
| this._tooltip.transition().duration(150).style("opacity", 0); | |
| } | |
| clearStatusBar(); | |
| }; | |
| return pythonTooltip; | |
| } | |
| // ============================================================================ | |
| // Flamegraph Creation | |
| // ============================================================================ | |
| function ensureLibraryLoaded() { | |
| if (typeof flamegraph === "undefined") { | |
| console.error("d3-flame-graph library not loaded"); | |
| document.getElementById("chart").innerHTML = | |
| '<div style="padding: 40px; text-align: center; color: var(--text-muted);">Error: d3-flame-graph library failed to load</div>'; | |
| throw new Error("d3-flame-graph library failed to load"); | |
| } | |
| } | |
| const HEAT_THRESHOLDS = [ | |
| [0.6, 8], | |
| [0.35, 7], | |
| [0.18, 6], | |
| [0.12, 5], | |
| [0.06, 4], | |
| [0.03, 3], | |
| [0.01, 2], | |
| ]; | |
| function getHeatLevel(percentage) { | |
| for (const [threshold, level] of HEAT_THRESHOLDS) { | |
| if (percentage >= threshold) return level; | |
| } | |
| return 1; | |
| } | |
| function getHeatColors() { | |
| const style = getComputedStyle(document.documentElement); | |
| const colors = {}; | |
| for (let i = 1; i <= 8; i++) { | |
| colors[i] = style.getPropertyValue(`--heat-${i}`).trim(); | |
| } | |
| return colors; | |
| } | |
| function getDiffColors() { | |
| const style = getComputedStyle(document.documentElement); | |
| return { | |
| elided: style.getPropertyValue('--diff-elided').trim(), | |
| new: style.getPropertyValue('--diff-new').trim(), | |
| neutral: style.getPropertyValue('--diff-neutral').trim(), | |
| regressionDeep: style.getPropertyValue('--diff-regression-deep').trim(), | |
| regressionMedium: style.getPropertyValue('--diff-regression-medium').trim(), | |
| regressionLight: style.getPropertyValue('--diff-regression-light').trim(), | |
| regressionVerylight: style.getPropertyValue('--diff-regression-verylight').trim(), | |
| improvementDeep: style.getPropertyValue('--diff-improvement-deep').trim(), | |
| improvementMedium: style.getPropertyValue('--diff-improvement-medium').trim(), | |
| improvementLight: style.getPropertyValue('--diff-improvement-light').trim(), | |
| improvementVerylight: style.getPropertyValue('--diff-improvement-verylight').trim(), | |
| }; | |
| } | |
| function getDiffColorForNode(node, diffColors) { | |
| if (isShowingElided) { | |
| return diffColors.elided; | |
| } | |
| const diff_pct = node.data.diff_pct || 0; | |
| const diff_samples = node.data.diff || 0; | |
| const self_time = node.data.self_time || 0; | |
| if (diff_pct === 100 && self_time > 0 && Math.abs(diff_samples - self_time) < 0.1) { | |
| return diffColors.new; | |
| } | |
| // Neutral zone: small percentage change | |
| if (Math.abs(diff_pct) < 15) { | |
| return diffColors.neutral; | |
| } | |
| // Regression (red scale) | |
| if (diff_pct > 0) { | |
| if (diff_pct >= 100) return diffColors.regressionDeep; | |
| if (diff_pct > 50) return diffColors.regressionMedium; | |
| if (diff_pct > 30) return diffColors.regressionLight; | |
| return diffColors.regressionVerylight; | |
| } | |
| // Improvement (blue scale) | |
| if (diff_pct <= -100) return diffColors.improvementDeep; | |
| if (diff_pct < -50) return diffColors.improvementMedium; | |
| if (diff_pct < -30) return diffColors.improvementLight; | |
| return diffColors.improvementVerylight; | |
| } | |
| function createFlamegraph(tooltip, rootValue, data) { | |
| const chartArea = document.querySelector('.chart-area'); | |
| const width = chartArea ? chartArea.clientWidth - 32 : window.innerWidth - 320; | |
| const heatColors = getHeatColors(); | |
| const isDifferential = data && data.stats && data.stats.is_differential; | |
| const diffColors = isDifferential ? getDiffColors() : null; | |
| let chart = flamegraph() | |
| .width(width) | |
| .cellHeight(20) | |
| .transitionDuration(300) | |
| .minFrameSize(1) | |
| .tooltip(tooltip) | |
| .inverted(true) | |
| .getName(d => resolveString(useModuleNames ? d.data.label : d.data.name) || resolveString(d.data.name) || '') | |
| .setColorMapper(function (d) { | |
| if (d.depth === 0) return 'transparent'; | |
| if (isDifferential) { | |
| return getDiffColorForNode(d, diffColors); | |
| } | |
| const percentage = d.data.value / rootValue; | |
| const level = getHeatLevel(percentage); | |
| return heatColors[level]; | |
| }) | |
| .onClick(function (d) { | |
| zoomedNodeValue = d.data.value; | |
| }); | |
| return chart; | |
| } | |
| function renderFlamegraph(chart, data) { | |
| d3.select("#chart").datum(data).call(chart); | |
| window.flamegraphChart = chart; | |
| window.flamegraphData = data; | |
| zoomedNodeValue = null; | |
| populateStats(data); | |
| } | |
| // ============================================================================ | |
| // Search | |
| // ============================================================================ | |
| function updateSearchHighlight(searchTerm, searchInput) { | |
| d3.selectAll("#chart rect") | |
| .classed("search-match", false) | |
| .classed("search-dim", false); | |
| // Clear active state from all hotspots | |
| document.querySelectorAll('.hotspot').forEach(h => h.classList.remove('active')); | |
| if (searchTerm && searchTerm.length > 0) { | |
| let matchCount = 0; | |
| d3.selectAll("#chart rect").each(function (d) { | |
| if (d && d.data) { | |
| const name = resolveString(d.data.name) || ""; | |
| const funcname = resolveString(d.data.funcname) || ""; | |
| const filename = resolveString(d.data.filename) || ""; | |
| const moduleName = resolveString(d.data.module) || ""; | |
| const displayName = getDisplayName(moduleName, filename); | |
| const lineno = d.data.lineno; | |
| const term = searchTerm.toLowerCase(); | |
| // Check if search term looks like path:line pattern | |
| const fileLineMatch = term.match(/^(.+):(\d+)$/); | |
| let matches = false; | |
| if (fileLineMatch) { | |
| const searchFile = fileLineMatch[1]; | |
| const searchLine = parseInt(fileLineMatch[2], 10); | |
| matches = displayName.toLowerCase().includes(searchFile) && lineno === searchLine; | |
| } else { | |
| // Regular substring search | |
| matches = | |
| name.toLowerCase().includes(term) || | |
| funcname.toLowerCase().includes(term) || | |
| displayName.toLowerCase().includes(term); | |
| } | |
| if (matches) { | |
| matchCount++; | |
| d3.select(this).classed("search-match", true); | |
| } else { | |
| d3.select(this).classed("search-dim", true); | |
| } | |
| } | |
| }); | |
| if (searchInput) { | |
| searchInput.classList.remove("has-matches", "no-matches"); | |
| searchInput.classList.add(matchCount > 0 ? "has-matches" : "no-matches"); | |
| } | |
| // Mark matching hotspot as active | |
| document.querySelectorAll('.hotspot').forEach(h => { | |
| if (h.dataset.searchterm && h.dataset.searchterm.toLowerCase() === searchTerm.toLowerCase()) { | |
| h.classList.add('active'); | |
| } | |
| }); | |
| } else if (searchInput) { | |
| searchInput.classList.remove("has-matches", "no-matches"); | |
| } | |
| } | |
| function searchForHotspot(funcname) { | |
| const searchInput = document.getElementById('search-input'); | |
| const searchWrapper = document.querySelector('.search-wrapper'); | |
| if (searchInput) { | |
| // Toggle: if already searching for this term, clear it | |
| if (searchInput.value.trim() === funcname) { | |
| clearSearch(); | |
| } else { | |
| searchInput.value = funcname; | |
| if (searchWrapper) { | |
| searchWrapper.classList.add('has-value'); | |
| } | |
| performSearch(); | |
| } | |
| } | |
| } | |
| function initSearchHandlers() { | |
| const searchInput = document.getElementById("search-input"); | |
| const searchWrapper = document.querySelector(".search-wrapper"); | |
| if (!searchInput) return; | |
| let searchTimeout; | |
| function performSearch() { | |
| const term = searchInput.value.trim(); | |
| updateSearchHighlight(term, searchInput); | |
| // Toggle has-value class for clear button visibility | |
| if (searchWrapper) { | |
| searchWrapper.classList.toggle("has-value", term.length > 0); | |
| } | |
| } | |
| searchInput.addEventListener("input", function () { | |
| clearTimeout(searchTimeout); | |
| searchTimeout = setTimeout(performSearch, 150); | |
| }); | |
| window.performSearch = performSearch; | |
| } | |
| function clearSearch() { | |
| const searchInput = document.getElementById("search-input"); | |
| const searchWrapper = document.querySelector(".search-wrapper"); | |
| if (searchInput) { | |
| searchInput.value = ""; | |
| searchInput.classList.remove("has-matches", "no-matches"); | |
| if (searchWrapper) { | |
| searchWrapper.classList.remove("has-value"); | |
| } | |
| // Clear highlights | |
| d3.selectAll("#chart rect") | |
| .classed("search-match", false) | |
| .classed("search-dim", false); | |
| // Clear active hotspot | |
| document.querySelectorAll('.hotspot').forEach(h => h.classList.remove('active')); | |
| } | |
| } | |
| // ============================================================================ | |
| // Resize Handler | |
| // ============================================================================ | |
| function handleResize() { | |
| let resizeTimeout; | |
| window.addEventListener("resize", function () { | |
| clearTimeout(resizeTimeout); | |
| resizeTimeout = setTimeout(resizeChart, 100); | |
| }); | |
| } | |
| function initSidebarResize() { | |
| const sidebar = document.getElementById('sidebar'); | |
| const resizeHandle = document.getElementById('sidebar-resize-handle'); | |
| if (!sidebar || !resizeHandle) return; | |
| let isResizing = false; | |
| let startX = 0; | |
| let startWidth = 0; | |
| const minWidth = 200; | |
| const maxWidth = 600; | |
| resizeHandle.addEventListener('mousedown', function(e) { | |
| isResizing = true; | |
| startX = e.clientX; | |
| startWidth = sidebar.offsetWidth; | |
| resizeHandle.classList.add('resizing'); | |
| document.body.classList.add('resizing-sidebar'); | |
| e.preventDefault(); | |
| }); | |
| document.addEventListener('mousemove', function(e) { | |
| if (!isResizing) return; | |
| const deltaX = e.clientX - startX; | |
| const newWidth = Math.min(Math.max(startWidth + deltaX, minWidth), maxWidth); | |
| sidebar.style.width = newWidth + 'px'; | |
| e.preventDefault(); | |
| }); | |
| document.addEventListener('mouseup', function() { | |
| if (isResizing) { | |
| isResizing = false; | |
| resizeHandle.classList.remove('resizing'); | |
| document.body.classList.remove('resizing-sidebar'); | |
| // Save the new width | |
| const width = sidebar.offsetWidth; | |
| localStorage.setItem('flamegraph-sidebar-width', width); | |
| // Resize chart after sidebar resize | |
| setTimeout(() => { | |
| resizeChart(); | |
| }, 10); | |
| } | |
| }); | |
| } | |
| // ============================================================================ | |
| // Thread Stats | |
| // ============================================================================ | |
| // Mode constants (must match constants.py) | |
| const PROFILING_MODE_WALL = 0; | |
| const PROFILING_MODE_CPU = 1; | |
| const PROFILING_MODE_GIL = 2; | |
| const PROFILING_MODE_ALL = 3; | |
| function populateThreadStats(data, selectedThreadId = null) { | |
| const stats = data?.stats; | |
| if (!stats || !stats.thread_stats) { | |
| return; | |
| } | |
| const mode = stats.mode !== undefined ? stats.mode : PROFILING_MODE_WALL; | |
| let threadStats; | |
| if (selectedThreadId !== null && stats.per_thread_stats && stats.per_thread_stats[selectedThreadId]) { | |
| threadStats = stats.per_thread_stats[selectedThreadId]; | |
| } else { | |
| threadStats = stats.thread_stats; | |
| } | |
| if (!threadStats || typeof threadStats.total !== 'number' || threadStats.total <= 0) { | |
| return; | |
| } | |
| const section = document.getElementById('thread-stats-bar'); | |
| if (!section) { | |
| return; | |
| } | |
| section.style.display = 'block'; | |
| const gilHeldStat = document.getElementById('gil-held-stat'); | |
| const gilReleasedStat = document.getElementById('gil-released-stat'); | |
| const gilWaitingStat = document.getElementById('gil-waiting-stat'); | |
| if (mode === PROFILING_MODE_GIL) { | |
| // In GIL mode, hide GIL-related stats | |
| if (gilHeldStat) gilHeldStat.style.display = 'none'; | |
| if (gilReleasedStat) gilReleasedStat.style.display = 'none'; | |
| if (gilWaitingStat) gilWaitingStat.style.display = 'none'; | |
| } else { | |
| // Show all stats | |
| if (gilHeldStat) gilHeldStat.style.display = 'block'; | |
| if (gilReleasedStat) gilReleasedStat.style.display = 'block'; | |
| if (gilWaitingStat) gilWaitingStat.style.display = 'block'; | |
| const gilHeldPct = threadStats.has_gil_pct || 0; | |
| const gilHeldPctElem = document.getElementById('gil-held-pct'); | |
| if (gilHeldPctElem) gilHeldPctElem.textContent = `${gilHeldPct.toFixed(1)}%`; | |
| const gilHeldFill = document.getElementById('gil-held-fill'); | |
| if (gilHeldFill) gilHeldFill.style.width = `${gilHeldPct}%`; | |
| // GIL Released = not holding GIL and not waiting for it | |
| const gilReleasedPct = Math.max(0, 100 - (threadStats.has_gil_pct || 0) - (threadStats.gil_requested_pct || 0)); | |
| const gilReleasedPctElem = document.getElementById('gil-released-pct'); | |
| if (gilReleasedPctElem) gilReleasedPctElem.textContent = `${gilReleasedPct.toFixed(1)}%`; | |
| const gilReleasedFill = document.getElementById('gil-released-fill'); | |
| if (gilReleasedFill) gilReleasedFill.style.width = `${gilReleasedPct}%`; | |
| const gilWaitingPct = threadStats.gil_requested_pct || 0; | |
| const gilWaitingPctElem = document.getElementById('gil-waiting-pct'); | |
| if (gilWaitingPctElem) gilWaitingPctElem.textContent = `${gilWaitingPct.toFixed(1)}%`; | |
| const gilWaitingFill = document.getElementById('gil-waiting-fill'); | |
| if (gilWaitingFill) gilWaitingFill.style.width = `${gilWaitingPct}%`; | |
| } | |
| const gcPct = threadStats.gc_pct || 0; | |
| const gcPctElem = document.getElementById('gc-pct'); | |
| if (gcPctElem) gcPctElem.textContent = `${gcPct.toFixed(1)}%`; | |
| const gcFill = document.getElementById('gc-fill'); | |
| if (gcFill) gcFill.style.width = `${gcPct}%`; | |
| // Exception stats | |
| const excPct = threadStats.has_exception_pct || 0; | |
| const excPctElem = document.getElementById('exc-pct'); | |
| if (excPctElem) excPctElem.textContent = `${excPct.toFixed(1)}%`; | |
| const excFill = document.getElementById('exc-fill'); | |
| if (excFill) excFill.style.width = `${excPct}%`; | |
| } | |
| // ============================================================================ | |
| // Profile Summary Stats | |
| // ============================================================================ | |
| function formatNumber(num) { | |
| if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; | |
| if (num >= 1000) return (num / 1000).toFixed(1) + 'K'; | |
| return num.toLocaleString(); | |
| } | |
| function formatDuration(seconds) { | |
| if (seconds >= 3600) { | |
| const h = Math.floor(seconds / 3600); | |
| const m = Math.floor((seconds % 3600) / 60); | |
| return `${h}h ${m}m`; | |
| } | |
| if (seconds >= 60) { | |
| const m = Math.floor(seconds / 60); | |
| const s = Math.floor(seconds % 60); | |
| return `${m}m ${s}s`; | |
| } | |
| return seconds.toFixed(2) + 's'; | |
| } | |
| function populateProfileSummary(data) { | |
| const stats = data.stats || {}; | |
| const totalSamples = currentThreadFilter !== 'all' | |
| ? (data.value ?? 0) | |
| : (stats.total_samples ?? data.value ?? 0); | |
| const duration = stats.duration_sec || 0; | |
| const sampleRate = stats.sample_rate || (duration > 0 ? totalSamples / duration : 0); | |
| const errorRate = stats.error_rate; | |
| const missedSamples = stats.missed_samples; | |
| const samplesEl = document.getElementById('stat-total-samples'); | |
| if (samplesEl) samplesEl.textContent = formatNumber(totalSamples); | |
| const durationEl = document.getElementById('stat-duration'); | |
| if (durationEl) durationEl.textContent = duration > 0 ? formatDuration(duration) : '--'; | |
| const rateEl = document.getElementById('stat-sample-rate'); | |
| if (rateEl) rateEl.textContent = sampleRate > 0 ? formatNumber(Math.round(sampleRate)) : '--'; | |
| // Count unique functions | |
| // Use normal (non-inverted) tree structure, but respect thread filtering | |
| const uniqueFunctions = new Set(); | |
| function collectUniqueFunctions(node) { | |
| if (!node) return; | |
| const filename = resolveString(node.filename) || 'unknown'; | |
| const funcname = resolveString(node.funcname) || resolveString(node.name) || 'unknown'; | |
| const lineno = node.lineno || 0; | |
| const key = `${filename}|${lineno}|${funcname}`; | |
| uniqueFunctions.add(key); | |
| if (node.children) node.children.forEach(collectUniqueFunctions); | |
| } | |
| // In inverted mode, use normalData (with thread filter if active) | |
| // In normal mode, use the passed data (already has thread filter applied if any) | |
| let functionCountSource; | |
| if (!normalData) { | |
| functionCountSource = data; | |
| } else if (isInverted) { | |
| if (currentThreadFilter !== 'all') { | |
| functionCountSource = filterDataByThread(normalData, parseInt(currentThreadFilter)); | |
| } else { | |
| functionCountSource = normalData; | |
| } | |
| } else { | |
| functionCountSource = data; | |
| } | |
| collectUniqueFunctions(functionCountSource); | |
| const functionsEl = document.getElementById('stat-functions'); | |
| if (functionsEl) functionsEl.textContent = formatNumber(uniqueFunctions.size); | |
| // Efficiency bar | |
| if (errorRate !== undefined && errorRate !== null) { | |
| const efficiency = Math.max(0, Math.min(100, (100 - errorRate))); | |
| const efficiencySection = document.getElementById('efficiency-section'); | |
| if (efficiencySection) efficiencySection.style.display = 'block'; | |
| const efficiencyValue = document.getElementById('stat-efficiency'); | |
| if (efficiencyValue) efficiencyValue.textContent = efficiency.toFixed(1) + '%'; | |
| const efficiencyFill = document.getElementById('efficiency-fill'); | |
| if (efficiencyFill) efficiencyFill.style.width = efficiency + '%'; | |
| } | |
| // MissedSamples bar | |
| if (missedSamples !== undefined && missedSamples !== null) { | |
| const sampleEfficiency = Math.max(0, missedSamples); | |
| const efficiencySection = document.getElementById('efficiency-section'); | |
| if (efficiencySection) efficiencySection.style.display = 'block'; | |
| const sampleEfficiencyValue = document.getElementById('stat-missed-samples'); | |
| if (sampleEfficiencyValue) sampleEfficiencyValue.textContent = sampleEfficiency.toFixed(1) + '%'; | |
| const sampleEfficiencyFill = document.getElementById('missed-samples-fill'); | |
| if (sampleEfficiencyFill) sampleEfficiencyFill.style.width = sampleEfficiency + '%'; | |
| } | |
| } | |
| // ============================================================================ | |
| // Elided Stacks (Differential) | |
| // ============================================================================ | |
| let elidedFlamegraphData = null; | |
| let invertedElidedData = null; | |
| let isShowingElided = false; | |
| function setupElidedToggle(data) { | |
| const stats = data.stats || {}; | |
| const elidedCount = stats.elided_count || 0; | |
| const elidedFlamegraph = stats.elided_flamegraph; | |
| if (!elidedCount || !elidedFlamegraph) { | |
| return; | |
| } | |
| elidedFlamegraphData = resolveStringIndices(elidedFlamegraph, elidedFlamegraph.strings); | |
| const toggleElided = document.getElementById('toggle-elided'); | |
| if (toggleElided) { | |
| toggleElided.style.display = 'flex'; | |
| toggleElided.onclick = function() { | |
| isShowingElided = !isShowingElided; | |
| updateToggleUI('toggle-elided', isShowingElided); | |
| updateFlamegraphView(); | |
| }; | |
| } | |
| } | |
| // ============================================================================ | |
| // Hotspot Stats | |
| // ============================================================================ | |
| function populateStats(data) { | |
| // Populate profile summary | |
| populateProfileSummary(data); | |
| // Populate thread statistics if available | |
| populateThreadStats(data); | |
| // Setup elided stacks toggle if this is a differential flamegraph | |
| setupElidedToggle(data); | |
| // For hotspots: use normal (non-inverted) tree structure, but respect thread filtering. | |
| // In inverted view, the tree structure changes but the hottest functions remain the same. | |
| // However, if a thread filter is active, we need to show that thread's hotspots. | |
| let hotspotSource; | |
| if (!normalData) { | |
| hotspotSource = data; | |
| } else if (isInverted) { | |
| // In inverted mode, use normalData (with thread filter if active) | |
| if (currentThreadFilter !== 'all') { | |
| hotspotSource = filterDataByThread(normalData, parseInt(currentThreadFilter)); | |
| } else { | |
| hotspotSource = normalData; | |
| } | |
| } else { | |
| // In normal mode, use the passed data (already has thread filter applied if any) | |
| hotspotSource = data; | |
| } | |
| const totalSamples = hotspotSource.value || 0; | |
| const functionMap = new Map(); | |
| function collectFunctions(node) { | |
| if (!node) return; | |
| let filename = resolveString(node.filename); | |
| let funcname = resolveString(node.funcname); | |
| let moduleName = resolveString(node.module); | |
| if (!filename || !funcname) { | |
| const nameStr = resolveString(node.name); | |
| if (nameStr?.includes('(')) { | |
| const match = nameStr.match(/^(.+?)\s*\((.+?):(\d+)\)$/); | |
| if (match) { | |
| funcname = funcname || match[1]; | |
| filename = filename || match[2]; | |
| } | |
| } | |
| } | |
| filename = filename || 'unknown'; | |
| funcname = funcname || 'unknown'; | |
| moduleName = moduleName || 'unknown'; | |
| if (filename !== 'unknown' && funcname !== 'unknown' && node.value > 0) { | |
| const directSamples = node.self || 0; | |
| const funcKey = `${filename}:${node.lineno || '?'}:${funcname}`; | |
| if (functionMap.has(funcKey)) { | |
| const existing = functionMap.get(funcKey); | |
| existing.directSamples += directSamples; | |
| existing.directPercent = (existing.directSamples / totalSamples) * 100; | |
| if (directSamples > existing.maxSingleSamples) { | |
| existing.filename = filename; | |
| existing.module = moduleName; | |
| existing.lineno = node.lineno || '?'; | |
| existing.maxSingleSamples = directSamples; | |
| } | |
| } else { | |
| functionMap.set(funcKey, { | |
| filename: filename, | |
| module: moduleName, | |
| lineno: node.lineno || '?', | |
| funcname: funcname, | |
| directSamples, | |
| directPercent: (directSamples / totalSamples) * 100, | |
| maxSingleSamples: directSamples | |
| }); | |
| } | |
| } | |
| if (node.children) { | |
| node.children.forEach(child => collectFunctions(child)); | |
| } | |
| } | |
| collectFunctions(hotspotSource); | |
| const hotSpots = Array.from(functionMap.values()) | |
| .filter(f => f.directPercent > 0.5) | |
| .sort((a, b) => b.directPercent - a.directPercent) | |
| .slice(0, 3); | |
| // Populate and animate hotspot cards | |
| for (let i = 0; i < 3; i++) { | |
| const num = i + 1; | |
| const card = document.getElementById(`hotspot-${num}`); | |
| const funcEl = document.getElementById(`hotspot-func-${num}`); | |
| const fileEl = document.getElementById(`hotspot-file-${num}`); | |
| const percentEl = document.getElementById(`hotspot-percent-${num}`); | |
| const samplesEl = document.getElementById(`hotspot-samples-${num}`); | |
| if (i < hotSpots.length && hotSpots[i]) { | |
| const h = hotSpots[i]; | |
| const filename = h.filename || 'unknown'; | |
| const lineno = h.lineno ?? '?'; | |
| const moduleName = h.module || 'unknown'; | |
| const isSpecialFrame = filename === '~' && (lineno === 0 || lineno === '?'); | |
| let funcDisplay = h.funcname || 'unknown'; | |
| if (funcDisplay.length > 28) funcDisplay = funcDisplay.substring(0, 25) + '...'; | |
| if (funcEl) funcEl.textContent = funcDisplay; | |
| if (fileEl) { | |
| if (isSpecialFrame) { | |
| fileEl.textContent = '--'; | |
| } else { | |
| const displayName = getDisplayName(moduleName, filename); | |
| fileEl.textContent = `${displayName}:${lineno}`; | |
| } | |
| } | |
| if (percentEl) percentEl.textContent = `${h.directPercent.toFixed(1)}%`; | |
| if (samplesEl) samplesEl.textContent = ` (${h.directSamples.toLocaleString()})`; | |
| } else { | |
| if (funcEl) funcEl.textContent = '--'; | |
| if (fileEl) fileEl.textContent = '--'; | |
| if (percentEl) percentEl.textContent = '--'; | |
| if (samplesEl) samplesEl.textContent = ''; | |
| } | |
| // Add click handler and animate entrance | |
| if (card) { | |
| if (i < hotSpots.length && hotSpots[i]) { | |
| const h = hotSpots[i]; | |
| const moduleName = h.module || 'unknown'; | |
| const filename = h.filename || 'unknown'; | |
| const displayName = getDisplayName(moduleName, filename); | |
| const hasValidLocation = displayName !== 'unknown' && h.lineno !== '?'; | |
| const searchTerm = hasValidLocation ? `${displayName}:${h.lineno}` : h.funcname; | |
| card.dataset.searchterm = searchTerm; | |
| card.onclick = () => searchForHotspot(searchTerm); | |
| card.style.cursor = 'pointer'; | |
| } else { | |
| card.onclick = null; | |
| delete card.dataset.searchterm; | |
| card.style.cursor = 'default'; | |
| } | |
| setTimeout(() => { | |
| card.classList.add('visible'); | |
| }, 100 + i * 80); | |
| } | |
| } | |
| } | |
| // ============================================================================ | |
| // Thread Filter | |
| // ============================================================================ | |
| function initThreadFilter(data) { | |
| const threadFilter = document.getElementById('thread-filter'); | |
| const threadSection = document.getElementById('thread-section'); | |
| if (!threadFilter || !data.threads || data.stats?.is_differential) return; | |
| threadFilter.innerHTML = '<option value="all">All Threads</option>'; | |
| const threads = data.threads || []; | |
| threads.forEach(threadId => { | |
| const option = document.createElement('option'); | |
| option.value = threadId; | |
| option.textContent = `Thread ${threadId}`; | |
| threadFilter.appendChild(option); | |
| }); | |
| if (threads.length > 1 && threadSection) { | |
| threadSection.style.display = 'block'; | |
| } | |
| } | |
| function filterByThread() { | |
| const threadFilter = document.getElementById('thread-filter'); | |
| if (!threadFilter || !normalData) return; | |
| const selectedThread = threadFilter.value; | |
| currentThreadFilter = selectedThread; | |
| updateFlamegraphView(); | |
| } | |
| function filterDataByThread(data, threadId) { | |
| function filterNode(node) { | |
| const threadValues = node.thread_values?.[threadId]; | |
| if (!threadValues) { | |
| return null; | |
| } | |
| const { | |
| thread_values: _threadValues, | |
| thread_opcodes: threadOpcodes, | |
| ...sharedNode | |
| } = node; | |
| const filteredNode = { | |
| ...sharedNode, | |
| value: threadValues[0], | |
| self: threadValues[1], | |
| opcodes: threadOpcodes?.[threadId] ?? {}, | |
| children: [] | |
| }; | |
| if (node.children && Array.isArray(node.children)) { | |
| filteredNode.children = node.children | |
| .map(child => filterNode(child)) | |
| .filter(child => child !== null); | |
| } | |
| return filteredNode; | |
| } | |
| return filterNode(data); | |
| } | |
| // ============================================================================ | |
| // Control Functions | |
| // ============================================================================ | |
| function resetZoom() { | |
| if (window.flamegraphChart) { | |
| zoomedNodeValue = null; | |
| window.flamegraphChart.resetZoom(); | |
| } | |
| } | |
| function exportSVG() { | |
| const svgElement = document.querySelector("#chart svg"); | |
| if (!svgElement) { | |
| console.warn("Cannot export: No flamegraph SVG found"); | |
| return; | |
| } | |
| const serializer = new XMLSerializer(); | |
| const svgString = serializer.serializeToString(svgElement); | |
| const blob = new Blob([svgString], { type: "image/svg+xml" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = "python-performance-flamegraph.svg"; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| // ============================================================================ | |
| // Inverted Flamegraph | |
| // ============================================================================ | |
| // Example: "file.py|10|foo" or "~|0|<GC>" for special frames | |
| function getInvertNodeKey(node) { | |
| return `${node.filename || '~'}|${node.lineno || 0}|${node.funcname || node.name}`; | |
| } | |
| function accumulateInvertedNode(parent, stackFrame, leaf, isDifferential) { | |
| const key = getInvertNodeKey(stackFrame); | |
| if (!parent.children[key]) { | |
| const newNode = { | |
| name: stackFrame.name, | |
| label: stackFrame.label, | |
| value: 0, | |
| self: 0, | |
| children: {}, | |
| filename: stackFrame.filename, | |
| module: stackFrame.module, | |
| lineno: stackFrame.lineno, | |
| funcname: stackFrame.funcname, | |
| source: stackFrame.source, | |
| opcodes: null, | |
| threads: new Set() | |
| }; | |
| if (isDifferential) { | |
| newNode.baseline = 0; | |
| newNode.baseline_total = 0; | |
| newNode.self_time = 0; | |
| newNode.diff = 0; | |
| newNode.diff_pct = 0; | |
| } | |
| parent.children[key] = newNode; | |
| } | |
| const node = parent.children[key]; | |
| node.value += leaf.value; | |
| node.self += stackFrame.self || 0; | |
| if (leaf.threads) { | |
| leaf.threads.forEach(t => node.threads.add(t)); | |
| } | |
| if (stackFrame.opcodes) { | |
| if (!node.opcodes) { | |
| node.opcodes = { ...stackFrame.opcodes }; | |
| } else { | |
| for (const [op, count] of Object.entries(stackFrame.opcodes)) { | |
| node.opcodes[op] = (node.opcodes[op] || 0) + count; | |
| } | |
| } | |
| } | |
| if (isDifferential) { | |
| node.baseline += stackFrame.baseline || 0; | |
| node.baseline_total += stackFrame.baseline_total || 0; | |
| node.self_time += stackFrame.self_time || 0; | |
| node.diff += stackFrame.diff || 0; | |
| if (node.baseline > 0) { | |
| node.diff_pct = (node.diff / node.baseline) * 100.0; | |
| } else if (node.self_time > 0) { | |
| node.diff_pct = 100.0; | |
| } | |
| } | |
| return node; | |
| } | |
| function processLeaf(invertedRoot, path, leafNode, isDifferential) { | |
| if (!path || path.length === 0) { | |
| return; | |
| } | |
| let invertedParent = accumulateInvertedNode(invertedRoot, leafNode, leafNode, isDifferential); | |
| // Walk backwards through the call stack | |
| for (let i = path.length - 2; i >= 0; i--) { | |
| invertedParent = accumulateInvertedNode(invertedParent, path[i], leafNode, isDifferential); | |
| } | |
| } | |
| function traverseInvert(path, currentNode, invertedRoot, isDifferential) { | |
| const selfValue = currentNode.self || 0; | |
| if (selfValue > 0) { | |
| processLeaf(invertedRoot, path, { ...currentNode, value: selfValue }, isDifferential); | |
| } | |
| const children = currentNode.children || []; | |
| children.forEach(child => traverseInvert(path.concat([child]), child, invertedRoot, isDifferential)); | |
| } | |
| function convertInvertDictToArray(node) { | |
| if (node.threads instanceof Set) { | |
| node.threads = Array.from(node.threads).sort((a, b) => a - b); | |
| } | |
| const children = node.children; | |
| if (children && typeof children === 'object' && !Array.isArray(children)) { | |
| node.children = Object.values(children); | |
| node.children.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name)); | |
| node.children.forEach(convertInvertDictToArray); | |
| } | |
| return node; | |
| } | |
| function generateInvertedFlamegraph(data) { | |
| const isDifferential = data && data.stats && data.stats.is_differential; | |
| const invertedRoot = { | |
| name: data.name, | |
| label: data.label, | |
| value: data.value, | |
| children: {}, | |
| stats: data.stats, | |
| threads: data.threads | |
| }; | |
| const children = data.children || []; | |
| if (children.length === 0) { | |
| // Single-frame tree: the root is its own leaf | |
| processLeaf(invertedRoot, [data], data, isDifferential); | |
| } else { | |
| children.forEach(child => traverseInvert([child], child, invertedRoot, isDifferential)); | |
| } | |
| convertInvertDictToArray(invertedRoot); | |
| return invertedRoot; | |
| } | |
| function toggleInvert() { | |
| isInverted = !isInverted; | |
| updateToggleUI('toggle-invert', isInverted); | |
| updateFlamegraphView(); | |
| } | |
| function togglePathDisplay() { | |
| useModuleNames = !useModuleNames; | |
| updateToggleUI('toggle-path-display', useModuleNames); | |
| updateFlamegraphView(); | |
| } | |
| // ============================================================================ | |
| // Initialization | |
| // ============================================================================ | |
| function initFlamegraph() { | |
| ensureLibraryLoaded(); | |
| restoreUIState(); | |
| setupLogos(); | |
| if (EMBEDDED_DATA.strings) { | |
| stringTable = EMBEDDED_DATA.strings; | |
| normalData = resolveStringIndices(EMBEDDED_DATA, EMBEDDED_DATA.strings); | |
| } else { | |
| normalData = EMBEDDED_DATA; | |
| } | |
| // Initialize opcode mapping from embedded data | |
| initOpcodeMapping(EMBEDDED_DATA); | |
| // Inverted data will be built on first toggle | |
| invertedData = null; | |
| initThreadFilter(normalData); | |
| // Toggle legend based on differential mode | |
| const isDifferential = normalData && normalData.stats && normalData.stats.is_differential; | |
| const heatmapLegend = document.getElementById('heatmap-legend-section'); | |
| const diffLegend = document.getElementById('diff-legend-section'); | |
| if (isDifferential) { | |
| if (heatmapLegend) heatmapLegend.style.display = 'none'; | |
| if (diffLegend) diffLegend.style.display = 'block'; | |
| } else { | |
| if (heatmapLegend) heatmapLegend.style.display = 'block'; | |
| if (diffLegend) diffLegend.style.display = 'none'; | |
| } | |
| const tooltip = createPythonTooltip(normalData); | |
| const chart = createFlamegraph(tooltip, normalData.value, normalData); | |
| renderFlamegraph(chart, normalData); | |
| initSearchHandlers(); | |
| initSidebarResize(); | |
| handleResize(); | |
| const toggleInvertBtn = document.getElementById('toggle-invert'); | |
| if (toggleInvertBtn) { | |
| toggleInvertBtn.addEventListener('click', toggleInvert); | |
| } | |
| const togglePathDisplayBtn = document.getElementById('toggle-path-display'); | |
| if (togglePathDisplayBtn) { | |
| togglePathDisplayBtn.addEventListener('click', togglePathDisplay); | |
| } | |
| } | |
| // Keyboard shortcut: Enter/Space activates toggle switches | |
| document.addEventListener('keydown', function(e) { | |
| if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { | |
| return; | |
| } | |
| if ((e.key === 'Enter' || e.key === ' ') && e.target.classList.contains('toggle-switch')) { | |
| e.preventDefault(); | |
| e.target.click(); | |
| } | |
| }); | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", initFlamegraph); | |
| } else { | |
| initFlamegraph(); | |
| } |