cpython/Lib/profiling/sampling/_heatmap_assets/heatmap.js

737 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', () => window.location.href = 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) {
window.location.href = 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 scrollToTargetLine() {
if (!window.location.hash) return;
const target = document.querySelector(window.location.hash);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// ============================================================================
// 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 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 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);