mirror of
https://github.com/python/cpython.git
synced 2026-01-03 14:02:21 +00:00
737 lines
25 KiB
JavaScript
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 = '▶'; // Right arrow
|
|
} else {
|
|
if (!panel.dataset.populated) {
|
|
populateBytecodePanel(panel, button);
|
|
}
|
|
panel.classList.add('expanded');
|
|
wrapper.classList.add('expanded');
|
|
button.classList.add('expanded');
|
|
button.innerHTML = '▼'; // 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);
|