cpython/Lib/profiling/sampling/_heatmap_assets/heatmap.js
László Kiss Kollár 9587726a3e
gh-149430: Fix edge-cases in profiling.sampling outputs (#149431)
The line highlights on the heatmap are driven by the URL hash and the
`:target` selector. When clicking a caller/callee link for the line that
was already selected, the hash doesn't change, so the browser keeps the
existing target state and doesn't restart the animation. Due to this the
highlight only works the first time.

With this fix, line navigation goes through JavaScript. If the target
URL already points to the current location, the highlight is replayed by
clearing the animation, forcing style recalculation, and restoring it.

The `baseline_self` variable isn't initialized for structural elided
roots. This variable is accessed later unconditionally and leads to a
crash.

The child process ends up being invoked with `--diff_flamegraph` instead
of the correct argument.
2026-05-09 14:05:46 +01:00

738 lines
25 KiB
JavaScript

// Tachyon Profiler - Heatmap JavaScript
// Interactive features for the heatmap visualization
// Aligned with Flamegraph viewer design patterns
// ============================================================================
// State Management
// ============================================================================
let currentMenu = null;
let colorMode = 'self'; // 'self' or 'cumulative' - default to self
let coldCodeHidden = false;
// ============================================================================
// Theme Support
// ============================================================================
function toggleTheme() {
toggleAndSaveTheme();
applyLineColors();
// Rebuild scroll marker with new theme colors
buildScrollMarker();
}
// ============================================================================
// Utility Functions
// ============================================================================
function createElement(tag, className, textContent = '') {
const el = document.createElement(tag);
if (className) el.className = className;
if (textContent) el.textContent = textContent;
return el;
}
function calculateMenuPosition(buttonRect, menuWidth, menuHeight) {
const viewport = { width: window.innerWidth, height: window.innerHeight };
const scroll = {
x: window.pageXOffset || document.documentElement.scrollLeft,
y: window.pageYOffset || document.documentElement.scrollTop
};
const left = buttonRect.right + menuWidth + 10 < viewport.width
? buttonRect.right + scroll.x + 10
: Math.max(scroll.x + 10, buttonRect.left + scroll.x - menuWidth - 10);
const top = buttonRect.bottom + menuHeight + 10 < viewport.height
? buttonRect.bottom + scroll.y + 5
: Math.max(scroll.y + 10, buttonRect.top + scroll.y - menuHeight - 10);
return { left, top };
}
// ============================================================================
// Menu Management
// ============================================================================
function closeMenu() {
if (currentMenu) {
currentMenu.remove();
currentMenu = null;
}
}
function showNavigationMenu(button, items, title) {
closeMenu();
const menu = createElement('div', 'callee-menu');
menu.appendChild(createElement('div', 'callee-menu-header', title));
items.forEach(linkData => {
const item = createElement('div', 'callee-menu-item');
const funcDiv = createElement('div', 'callee-menu-func');
funcDiv.textContent = linkData.func;
if (linkData.count !== undefined && linkData.count > 0) {
const countBadge = createElement('span', 'count-badge');
countBadge.textContent = linkData.count.toLocaleString();
countBadge.title = `${linkData.count.toLocaleString()} samples`;
funcDiv.appendChild(document.createTextNode(' '));
funcDiv.appendChild(countBadge);
}
item.appendChild(funcDiv);
item.appendChild(createElement('div', 'callee-menu-file', linkData.file));
item.addEventListener('click', () => navigateToLine(linkData.link));
menu.appendChild(item);
});
const pos = calculateMenuPosition(button.getBoundingClientRect(), 350, 300);
menu.style.left = `${pos.left}px`;
menu.style.top = `${pos.top}px`;
document.body.appendChild(menu);
currentMenu = menu;
}
// ============================================================================
// Navigation
// ============================================================================
function handleNavigationClick(button, e) {
e.stopPropagation();
const navData = button.getAttribute('data-nav');
if (navData) {
navigateToLine(JSON.parse(navData).link);
return;
}
const navMulti = button.getAttribute('data-nav-multi');
if (navMulti) {
const items = JSON.parse(navMulti);
const title = button.classList.contains('caller') ? 'Choose a caller:' : 'Choose a callee:';
showNavigationMenu(button, items, title);
}
}
function restartLineHighlight(target) {
target.style.animation = 'none';
// Force style recalculation so restoring the animation restarts it.
void target.offsetWidth;
target.style.animation = '';
}
function navigateToLine(link) {
const url = new URL(link, window.location.href);
if (url.href === window.location.href) {
scrollToTargetLine();
} else {
window.location.href = link;
}
}
function scrollToTargetLine() {
if (!window.location.hash) return;
const target = document.querySelector(window.location.hash);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
restartLineHighlight(target);
}
}
// ============================================================================
// Sample Count & Intensity
// ============================================================================
function getSampleCount(line) {
let text;
if (colorMode === 'self') {
text = line.querySelector('.line-samples-self')?.textContent.trim().replace(/,/g, '');
} else {
text = line.querySelector('.line-samples-cumulative')?.textContent.trim().replace(/,/g, '');
}
return parseInt(text) || 0;
}
// ============================================================================
// Scroll Minimap
// ============================================================================
function buildScrollMarker() {
const existing = document.getElementById('scroll_marker');
if (existing) existing.remove();
if (document.body.scrollHeight <= window.innerHeight) return;
const allLines = document.querySelectorAll('.code-line');
const lines = Array.from(allLines).filter(line => line.style.display !== 'none');
const markerScale = window.innerHeight / document.body.scrollHeight;
const lineHeight = Math.min(Math.max(3, window.innerHeight / lines.length), 10);
const maxSamples = Math.max(...Array.from(lines, getSampleCount));
const scrollMarker = createElement('div', '');
scrollMarker.id = 'scroll_marker';
let prevLine = -99, lastMark, lastTop;
lines.forEach((line, index) => {
const samples = getSampleCount(line);
if (samples === 0) return;
const lineTop = Math.floor(line.offsetTop * markerScale);
const lineNumber = index + 1;
const intensityClass = maxSamples > 0 ? (intensityToClass(samples / maxSamples) || 'cold') : 'cold';
if (lineNumber === prevLine + 1 && lastMark?.classList.contains(intensityClass)) {
lastMark.style.height = `${lineTop + lineHeight - lastTop}px`;
} else {
lastMark = createElement('div', `marker ${intensityClass}`);
lastMark.style.height = `${lineHeight}px`;
lastMark.style.top = `${lineTop}px`;
scrollMarker.appendChild(lastMark);
lastTop = lineTop;
}
prevLine = lineNumber;
});
document.body.appendChild(scrollMarker);
}
function applyLineColors() {
const lines = document.querySelectorAll('.code-line');
lines.forEach(line => {
let intensity;
if (colorMode === 'self') {
intensity = parseFloat(line.getAttribute('data-self-intensity')) || 0;
} else {
intensity = parseFloat(line.getAttribute('data-cumulative-intensity')) || 0;
}
const color = intensityToColor(intensity);
line.style.background = color;
});
}
// ============================================================================
// Toggle Controls
// ============================================================================
function toggleColdCode() {
coldCodeHidden = !coldCodeHidden;
applyHotFilter();
updateToggleUI('toggle-cold', coldCodeHidden);
buildScrollMarker();
}
function applyHotFilter() {
const lines = document.querySelectorAll('.code-line');
lines.forEach(line => {
const selfSamples = line.querySelector('.line-samples-self')?.textContent.trim();
const cumulativeSamples = line.querySelector('.line-samples-cumulative')?.textContent.trim();
let isCold;
if (colorMode === 'self') {
isCold = !selfSamples || selfSamples === '';
} else {
isCold = !cumulativeSamples || cumulativeSamples === '';
}
if (isCold) {
line.style.display = coldCodeHidden ? 'none' : 'flex';
} else {
line.style.display = 'flex';
}
});
}
function toggleColorMode() {
colorMode = colorMode === 'self' ? 'cumulative' : 'self';
applyLineColors();
updateToggleUI('toggle-color-mode', colorMode === 'cumulative');
if (coldCodeHidden) {
applyHotFilter();
}
buildScrollMarker();
}
// ============================================================================
// Initialization
// ============================================================================
document.addEventListener('DOMContentLoaded', function() {
restoreUIState();
applyLineColors();
// Initialize navigation buttons
document.querySelectorAll('.nav-btn').forEach(button => {
button.addEventListener('click', e => handleNavigationClick(button, e));
});
// Initialize line number permalink handlers
document.querySelectorAll('.line-number').forEach(lineNum => {
lineNum.style.cursor = 'pointer';
lineNum.addEventListener('click', e => {
window.location.hash = `line-${e.target.textContent.trim()}`;
});
});
// Initialize toggle buttons
const toggleColdBtn = document.getElementById('toggle-cold');
if (toggleColdBtn) toggleColdBtn.addEventListener('click', toggleColdCode);
const colorModeBtn = document.getElementById('toggle-color-mode');
if (colorModeBtn) colorModeBtn.addEventListener('click', toggleColorMode);
// Initialize specialization view toggle (hide if no bytecode data)
const hasBytecode = document.querySelectorAll('.bytecode-toggle').length > 0;
const specViewBtn = document.getElementById('toggle-spec-view');
if (specViewBtn) {
if (hasBytecode) {
specViewBtn.addEventListener('click', toggleSpecView);
} else {
specViewBtn.style.display = 'none';
}
}
// Initialize expand-all bytecode button
const expandAllBtn = document.getElementById('toggle-all-bytecode');
if (expandAllBtn) {
if (hasBytecode) {
expandAllBtn.addEventListener('click', toggleAllBytecode);
} else {
expandAllBtn.style.display = 'none';
}
}
// Initialize span tooltips
initSpanTooltips();
// Build scroll marker and scroll to target
setTimeout(buildScrollMarker, 200);
setTimeout(scrollToTargetLine, 100);
});
// Close menu when clicking outside
document.addEventListener('click', e => {
if (currentMenu && !currentMenu.contains(e.target) && !e.target.classList.contains('nav-btn')) {
closeMenu();
}
});
// ========================================
// SPECIALIZATION VIEW TOGGLE
// ========================================
let specViewEnabled = false;
/**
* Calculate heat color for given intensity (0-1)
* Hot spans (>30%) get warm orange, cold spans get dimmed gray
* @param {number} intensity - Value between 0 and 1
* @returns {string} rgba color string
*/
function calculateHeatColor(intensity) {
// Hot threshold: only spans with >30% of max samples get color
if (intensity > 0.3) {
// Normalize intensity above threshold to 0-1
const normalizedIntensity = (intensity - 0.3) / 0.7;
// Warm orange-red with increasing opacity for hotter spans
const alpha = 0.25 + normalizedIntensity * 0.35; // 0.25 to 0.6
const hotColor = getComputedStyle(document.documentElement).getPropertyValue('--span-hot-base').trim();
return `rgba(${hotColor}, ${alpha})`;
} else if (intensity > 0) {
// Cold spans: very subtle gray, almost invisible
const coldColor = getComputedStyle(document.documentElement).getPropertyValue('--span-cold-base').trim();
return `rgba(${coldColor}, 0.1)`;
}
return 'transparent';
}
/**
* Apply intensity-based heat colors to source spans
* Hot spans get orange highlight, cold spans get dimmed
* @param {boolean} enable - Whether to enable or disable span coloring
*/
function applySpanHeatColors(enable) {
document.querySelectorAll('.instr-span').forEach(span => {
const samples = enable ? (parseInt(span.dataset.samples) || 0) : 0;
if (samples > 0) {
const intensity = samples / (parseInt(span.dataset.maxSamples) || 1);
span.style.backgroundColor = calculateHeatColor(intensity);
span.style.borderRadius = '2px';
span.style.padding = '0 1px';
span.style.cursor = 'pointer';
} else {
span.style.cssText = '';
}
});
}
// ========================================
// SPAN TOOLTIPS
// ========================================
let activeTooltip = null;
/**
* Create and show tooltip for a span
*/
function showSpanTooltip(span) {
hideSpanTooltip();
const samples = parseInt(span.dataset.samples) || 0;
const maxSamples = parseInt(span.dataset.maxSamples) || 1;
const pct = span.dataset.pct || '0';
const opcodes = span.dataset.opcodes || '';
if (samples === 0) return;
const intensity = samples / maxSamples;
const isHot = intensity > 0.7;
const isWarm = intensity > 0.3;
const hotnessText = isHot ? 'Hot' : isWarm ? 'Warm' : 'Cold';
const hotnessClass = isHot ? 'hot' : isWarm ? 'warm' : 'cold';
// Build opcodes rows - each opcode on its own row
let opcodesHtml = '';
if (opcodes) {
const opcodeList = opcodes.split(',').map(op => op.trim()).filter(op => op);
if (opcodeList.length > 0) {
opcodesHtml = `
<div class="span-tooltip-section">Opcodes:</div>
${opcodeList.map(op => `<div class="span-tooltip-opcode">${op}</div>`).join('')}
`;
}
}
const tooltip = document.createElement('div');
tooltip.className = 'span-tooltip';
tooltip.innerHTML = `
<div class="span-tooltip-header ${hotnessClass}">${hotnessText}</div>
<div class="span-tooltip-row">
<span class="span-tooltip-label">Samples:</span>
<span class="span-tooltip-value${isHot ? ' highlight' : ''}">${samples.toLocaleString()}</span>
</div>
<div class="span-tooltip-row">
<span class="span-tooltip-label">% of line:</span>
<span class="span-tooltip-value">${pct}%</span>
</div>
${opcodesHtml}
`;
document.body.appendChild(tooltip);
activeTooltip = tooltip;
// Position tooltip above the span
const rect = span.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
let top = rect.top - tooltipRect.height - 8;
// Keep tooltip in viewport
if (left < 5) left = 5;
if (left + tooltipRect.width > window.innerWidth - 5) {
left = window.innerWidth - tooltipRect.width - 5;
}
if (top < 5) {
top = rect.bottom + 8; // Show below if no room above
}
tooltip.style.left = `${left + window.scrollX}px`;
tooltip.style.top = `${top + window.scrollY}px`;
}
/**
* Hide active tooltip
*/
function hideSpanTooltip() {
if (activeTooltip) {
activeTooltip.remove();
activeTooltip = null;
}
}
/**
* Initialize span tooltip handlers
*/
function initSpanTooltips() {
document.addEventListener('mouseover', (e) => {
const span = e.target.closest('.instr-span');
if (span && specViewEnabled) {
showSpanTooltip(span);
}
});
document.addEventListener('mouseout', (e) => {
const span = e.target.closest('.instr-span');
if (span) {
hideSpanTooltip();
}
});
}
function toggleSpecView() {
specViewEnabled = !specViewEnabled;
const lines = document.querySelectorAll('.code-line');
if (specViewEnabled) {
lines.forEach(line => {
const specColor = line.getAttribute('data-spec-color');
line.style.background = specColor || 'transparent';
});
} else {
applyLineColors();
}
applySpanHeatColors(specViewEnabled);
updateToggleUI('toggle-spec-view', specViewEnabled);
// Disable/enable color mode toggle based on spec view state
const colorModeToggle = document.getElementById('toggle-color-mode');
if (colorModeToggle) {
colorModeToggle.classList.toggle('disabled', specViewEnabled);
}
buildScrollMarker();
}
// ========================================
// BYTECODE PANEL TOGGLE
// ========================================
/**
* Toggle bytecode panel visibility for a source line
* @param {HTMLElement} button - The toggle button that was clicked
*/
function toggleBytecode(button) {
const lineDiv = button.closest('.code-line');
const lineId = lineDiv.id;
const lineNum = lineId.replace('line-', '');
const panel = document.getElementById(`bytecode-${lineNum}`);
const wrapper = document.getElementById(`bytecode-wrapper-${lineNum}`);
if (!panel || !wrapper) return;
const isExpanded = panel.classList.contains('expanded');
if (isExpanded) {
panel.classList.remove('expanded');
wrapper.classList.remove('expanded');
button.classList.remove('expanded');
button.innerHTML = '&#9654;'; // Right arrow
} else {
if (!panel.dataset.populated) {
populateBytecodePanel(panel, button);
}
panel.classList.add('expanded');
wrapper.classList.add('expanded');
button.classList.add('expanded');
button.innerHTML = '&#9660;'; // Down arrow
}
}
/**
* Populate bytecode panel with instruction data
* @param {HTMLElement} panel - The panel element to populate
* @param {HTMLElement} button - The button containing the bytecode data
*/
function populateBytecodePanel(panel, button) {
const bytecodeJson = button.getAttribute('data-bytecode');
if (!bytecodeJson) return;
// Get line number from parent
const lineDiv = button.closest('.code-line');
const lineNum = lineDiv ? lineDiv.id.replace('line-', '') : null;
try {
const instructions = JSON.parse(bytecodeJson);
if (!instructions.length) {
panel.innerHTML = '<div class="bytecode-empty">No bytecode data</div>';
panel.dataset.populated = 'true';
return;
}
const maxSamples = Math.max(...instructions.map(i => i.samples), 1);
// Calculate specialization stats
const totalSamples = instructions.reduce((sum, i) => sum + i.samples, 0);
const specializedSamples = instructions
.filter(i => i.is_specialized)
.reduce((sum, i) => sum + i.samples, 0);
const specPct = totalSamples > 0 ? Math.round(100 * specializedSamples / totalSamples) : 0;
const specializedCount = instructions.filter(i => i.is_specialized).length;
// Determine specialization level class
let specClass = 'low';
if (specPct >= 67) specClass = 'high';
else if (specPct >= 33) specClass = 'medium';
// Build specialization summary
const instruction_word = instructions.length === 1 ? 'instruction' : 'instructions';
const sample_word = totalSamples === 1 ? 'sample' : 'samples';
let html = `<div class="bytecode-spec-summary ${specClass}">
<span class="spec-pct">${specPct}%</span>
<span class="spec-label">specialized</span>
<span class="spec-detail">(${specializedCount}/${instructions.length} ${instruction_word}, ${specializedSamples.toLocaleString()}/${totalSamples.toLocaleString()} ${sample_word})</span>
</div>`;
html += '<div class="bytecode-header">' +
'<span class="bytecode-opname">Instruction</span>' +
'<span class="bytecode-samples">Samples</span>' +
'<span>Heat</span></div>';
for (const instr of instructions) {
const heatPct = (instr.samples / maxSamples) * 100;
const isHot = heatPct > 50;
const specializedClass = instr.is_specialized ? ' specialized' : '';
const baseOpHtml = instr.is_specialized
? `<span class="base-op">(${escapeHtml(instr.base_opname)})</span>` : '';
const badge = instr.is_specialized
? '<span class="specialization-badge">SPECIALIZED</span>' : '';
// Build location data attributes for cross-referencing with source spans
const hasLocations = instr.locations && instr.locations.length > 0;
const locationData = hasLocations
? `data-locations='${JSON.stringify(instr.locations)}' data-line="${lineNum}" data-opcode="${instr.opcode}"`
: '';
html += `<div class="bytecode-instruction" ${locationData}>
<span class="bytecode-opname${specializedClass}">${escapeHtml(instr.opname)}${baseOpHtml}${badge}</span>
<span class="bytecode-samples${isHot ? ' hot' : ''}">${instr.samples.toLocaleString()}</span>
<div class="bytecode-heatbar"><div class="bytecode-heatbar-fill" style="width:${heatPct}%"></div></div>
</div>`;
}
panel.innerHTML = html;
panel.dataset.populated = 'true';
// Add hover handlers for bytecode instructions to highlight source spans
panel.querySelectorAll('.bytecode-instruction[data-locations]').forEach(instrEl => {
instrEl.addEventListener('mouseenter', highlightSourceFromBytecode);
instrEl.addEventListener('mouseleave', unhighlightSourceFromBytecode);
});
} catch (e) {
panel.innerHTML = '<div class="bytecode-error">Error loading bytecode</div>';
console.error('Error parsing bytecode data:', e);
}
}
/**
* Highlight source spans when hovering over bytecode instruction
*/
function highlightSourceFromBytecode(e) {
const instrEl = e.currentTarget;
const lineNum = instrEl.dataset.line;
const locationsStr = instrEl.dataset.locations;
if (!lineNum) return;
const lineDiv = document.getElementById(`line-${lineNum}`);
if (!lineDiv) return;
// Parse locations and highlight matching spans by column range
try {
const locations = JSON.parse(locationsStr || '[]');
const spans = lineDiv.querySelectorAll('.instr-span');
spans.forEach(span => {
const spanStart = parseInt(span.dataset.colStart);
const spanEnd = parseInt(span.dataset.colEnd);
for (const loc of locations) {
// Match if span's range matches instruction's location
if (spanStart === loc.col_offset && spanEnd === loc.end_col_offset) {
span.classList.add('highlight-from-bytecode');
break;
}
}
});
} catch (err) {
console.error('Error parsing locations:', err);
}
// Also highlight the instruction row itself
instrEl.classList.add('highlight');
}
/**
* Remove highlighting from source spans
*/
function unhighlightSourceFromBytecode(e) {
const instrEl = e.currentTarget;
const lineNum = instrEl.dataset.line;
if (!lineNum) return;
const lineDiv = document.getElementById(`line-${lineNum}`);
if (!lineDiv) return;
const spans = lineDiv.querySelectorAll('.instr-span.highlight-from-bytecode');
spans.forEach(span => {
span.classList.remove('highlight-from-bytecode');
});
instrEl.classList.remove('highlight');
}
/**
* Escape HTML special characters
* @param {string} text - Text to escape
* @returns {string} Escaped HTML
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Toggle all bytecode panels at once
*/
function toggleAllBytecode() {
const buttons = document.querySelectorAll('.bytecode-toggle');
if (buttons.length === 0) return;
const someExpanded = Array.from(buttons).some(b => b.classList.contains('expanded'));
const expandAllBtn = document.getElementById('toggle-all-bytecode');
buttons.forEach(button => {
const isExpanded = button.classList.contains('expanded');
if (someExpanded ? isExpanded : !isExpanded) {
toggleBytecode(button);
}
});
// Update the expand-all button state
if (expandAllBtn) {
expandAllBtn.classList.toggle('expanded', !someExpanded);
}
}
// Keyboard shortcut: 'b' toggles all bytecode panels, Enter/Space activates toggle switches
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
if (e.key === 'b' && !e.ctrlKey && !e.altKey && !e.metaKey) {
toggleAllBytecode();
}
if ((e.key === 'Enter' || e.key === ' ') && e.target.classList.contains('toggle-switch')) {
e.preventDefault();
e.target.click();
}
});
// Handle hash changes
window.addEventListener('hashchange', () => setTimeout(scrollToTargetLine, 50));
// Rebuild scroll marker on resize
window.addEventListener('resize', buildScrollMarker);