// Global string table for resolving string indices
let stringTable = [];
-let originalData = null;
+let normalData = null;
+let invertedData = null;
let currentThreadFilter = 'all';
+let isInverted = false;
// Heat colors are now defined in CSS variables (--heat-1 through --heat-8)
// and automatically switch with theme changes - no JS color arrays needed!
}
// Re-render flamegraph with new theme colors
- if (window.flamegraphData && originalData) {
- const tooltip = createPythonTooltip(originalData);
- const chart = createFlamegraph(tooltip, originalData.value);
+ if (window.flamegraphData && normalData) {
+ const currentData = isInverted ? invertedData : normalData;
+ const tooltip = createPythonTooltip(currentData);
+ const chart = createFlamegraph(tooltip, currentData.value);
renderFlamegraph(chart, window.flamegraphData);
}
}
.tooltip(tooltip)
.inverted(true)
.setColorMapper(function (d) {
+ // Root node should be transparent
+ if (d.depth === 0) return 'transparent';
+
const percentage = d.data.value / rootValue;
const level = getHeatLevel(percentage);
return heatColors[level];
if (rateEl) rateEl.textContent = sampleRate > 0 ? formatNumber(Math.round(sampleRate)) : '--';
// Count unique functions
- let functionCount = 0;
- function countFunctions(node) {
+ // Use normal (non-inverted) tree structure, but respect thread filtering
+ const uniqueFunctions = new Set();
+ function collectUniqueFunctions(node) {
if (!node) return;
- functionCount++;
- if (node.children) node.children.forEach(countFunctions);
+ 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;
}
- countFunctions(data);
+ collectUniqueFunctions(functionCountSource);
const functionsEl = document.getElementById('stat-functions');
- if (functionsEl) functionsEl.textContent = formatNumber(functionCount);
+ if (functionsEl) functionsEl.textContent = formatNumber(uniqueFunctions.size);
// Efficiency bar
if (errorRate !== undefined && errorRate !== null) {
// ============================================================================
function populateStats(data) {
- const totalSamples = data.value || 0;
-
// Populate profile summary
populateProfileSummary(data);
// Populate thread statistics if available
populateThreadStats(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) {
}
}
- collectFunctions(data);
+ collectFunctions(hotspotSource);
const hotSpots = Array.from(functionMap.values())
.filter(f => f.directPercent > 0.5)
function filterByThread() {
const threadFilter = document.getElementById('thread-filter');
- if (!threadFilter || !originalData) return;
+ if (!threadFilter || !normalData) return;
const selectedThread = threadFilter.value;
currentThreadFilter = selectedThread;
+ const baseData = isInverted ? invertedData : normalData;
let filteredData;
let selectedThreadId = null;
if (selectedThread === 'all') {
- filteredData = originalData;
+ filteredData = baseData;
} else {
selectedThreadId = parseInt(selectedThread, 10);
- filteredData = filterDataByThread(originalData, selectedThreadId);
+ filteredData = filterDataByThread(baseData, selectedThreadId);
if (filteredData.strings) {
stringTable = filteredData.strings;
const chart = createFlamegraph(tooltip, filteredData.value);
renderFlamegraph(chart, filteredData);
- populateThreadStats(originalData, selectedThreadId);
+ populateThreadStats(baseData, selectedThreadId);
}
function filterDataByThread(data, threadId) {
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) {
+ const key = getInvertNodeKey(stackFrame);
+
+ if (!parent.children[key]) {
+ parent.children[key] = {
+ name: stackFrame.name,
+ value: 0,
+ children: {},
+ filename: stackFrame.filename,
+ lineno: stackFrame.lineno,
+ funcname: stackFrame.funcname,
+ source: stackFrame.source,
+ threads: new Set()
+ };
+ }
+
+ const node = parent.children[key];
+ node.value += leaf.value;
+ if (leaf.threads) {
+ leaf.threads.forEach(t => node.threads.add(t));
+ }
+
+ return node;
+}
+
+function processLeaf(invertedRoot, path, leafNode) {
+ if (!path || path.length === 0) {
+ return;
+ }
+
+ let invertedParent = accumulateInvertedNode(invertedRoot, leafNode, leafNode);
+
+ // Walk backwards through the call stack
+ for (let i = path.length - 2; i >= 0; i--) {
+ invertedParent = accumulateInvertedNode(invertedParent, path[i], leafNode);
+ }
+}
+
+function traverseInvert(path, currentNode, invertedRoot) {
+ const children = currentNode.children || [];
+ const childThreads = new Set(children.flatMap(c => c.threads || []));
+ const selfThreads = (currentNode.threads || []).filter(t => !childThreads.has(t));
+
+ if (selfThreads.length > 0) {
+ processLeaf(invertedRoot, path, { ...currentNode, threads: selfThreads });
+ }
+
+ children.forEach(child => traverseInvert(path.concat([child]), child, invertedRoot));
+}
+
+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 invertedRoot = {
+ name: data.name,
+ 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);
+ } else {
+ children.forEach(child => traverseInvert([child], child, invertedRoot));
+ }
+
+ convertInvertDictToArray(invertedRoot);
+ return invertedRoot;
+}
+
+function updateToggleUI(toggleId, isOn) {
+ const toggle = document.getElementById(toggleId);
+ if (toggle) {
+ const track = toggle.querySelector('.toggle-track');
+ const labels = toggle.querySelectorAll('.toggle-label');
+ if (isOn) {
+ track.classList.add('on');
+ labels[0].classList.remove('active');
+ labels[1].classList.add('active');
+ } else {
+ track.classList.remove('on');
+ labels[0].classList.add('active');
+ labels[1].classList.remove('active');
+ }
+ }
+}
+
+function toggleInvert() {
+ isInverted = !isInverted;
+ updateToggleUI('toggle-invert', isInverted);
+
+ // Build inverted data on first use
+ if (isInverted && !invertedData) {
+ invertedData = generateInvertedFlamegraph(normalData);
+ }
+
+ let dataToRender = isInverted ? invertedData : normalData;
+
+ if (currentThreadFilter !== 'all') {
+ dataToRender = filterDataByThread(dataToRender, parseInt(currentThreadFilter));
+ }
+
+ const tooltip = createPythonTooltip(dataToRender);
+ const chart = createFlamegraph(tooltip, dataToRender.value);
+ renderFlamegraph(chart, dataToRender);
+}
+
// ============================================================================
// Initialization
// ============================================================================
restoreUIState();
setupLogos();
- let processedData = EMBEDDED_DATA;
if (EMBEDDED_DATA.strings) {
stringTable = EMBEDDED_DATA.strings;
- processedData = resolveStringIndices(EMBEDDED_DATA);
+ normalData = resolveStringIndices(EMBEDDED_DATA);
+ } else {
+ normalData = EMBEDDED_DATA;
}
// Initialize opcode mapping from embedded data
initOpcodeMapping(EMBEDDED_DATA);
- originalData = processedData;
- initThreadFilter(processedData);
+ // Inverted data will be built on first toggle
+ invertedData = null;
+
+ initThreadFilter(normalData);
- const tooltip = createPythonTooltip(processedData);
- const chart = createFlamegraph(tooltip, processedData.value);
- renderFlamegraph(chart, processedData);
+ const tooltip = createPythonTooltip(normalData);
+ const chart = createFlamegraph(tooltip, normalData.value);
+ renderFlamegraph(chart, normalData);
initSearchHandlers();
initSidebarResize();
handleResize();
+
+ const toggleInvertBtn = document.getElementById('toggle-invert');
+ if (toggleInvertBtn) {
+ toggleInvertBtn.addEventListener('click', toggleInvert);
+ }
}
if (document.readyState === "loading") {