blob: f0cb7122bba0a12ce4a0b2704d7e6eefadb9b797 [file] [log] [blame]
// stackViewer displays a flame-graph like view (extended to show callers).
// stacks - report.StackSet
// nodes - List of names for each source in report.StackSet
function stackViewer(stacks, nodes) {
'use strict';
// Constants used in rendering.
const ROW = 20;
const PADDING = 2;
const MIN_WIDTH = 4;
const MIN_TEXT_WIDTH = 16;
const TEXT_MARGIN = 2;
const FONT_SIZE = 12;
const MIN_FONT_SIZE = 8;
// Mapping from unit to a list of display scales/labels.
// List should be ordered by increasing unit size.
const UNITS = new Map([
['B', [
['B', 1],
['kB', Math.pow(2, 10)],
['MB', Math.pow(2, 20)],
['GB', Math.pow(2, 30)],
['TB', Math.pow(2, 40)],
['PB', Math.pow(2, 50)]]],
['s', [
['ns', 1e-9],
['µs', 1e-6],
['ms', 1e-3],
['s', 1],
['hrs', 60*60]]]]);
// Fields
let shownTotal = 0; // Total value of all stacks
let pivots = []; // Indices of currently selected data.Sources entries.
let matches = new Set(); // Indices of sources that match search
let elems = new Map(); // Mapping from source index to display elements
let displayList = []; // List of boxes to display.
let actionMenuOn = false; // Is action menu visible?
let actionTarget = null; // Box on which action menu is operating.
// Setup to allow measuring text width.
const textSizer = document.createElement('canvas');
textSizer.id = 'textsizer';
const textContext = textSizer.getContext('2d');
// Get DOM elements.
const chart = find('stack-chart');
const search = find('search');
const actions = find('action-menu');
const actionTitle = find('action-title');
const detailBox = find('current-details');
window.addEventListener('resize', render);
window.addEventListener('popstate', render);
search.addEventListener('keydown', handleSearchKey);
// Withdraw action menu when clicking outside, or when item selected.
document.addEventListener('mousedown', (e) => {
if (!actions.contains(e.target)) {
hideActionMenu();
}
});
actions.addEventListener('click', hideActionMenu);
// Initialize menus and other general UI elements.
viewer(new URL(window.location.href), nodes, {
hiliter: (n, on) => { return hilite(n, on); },
current: () => {
let r = new Map();
for (let p of pivots) {
r.set(p, true);
}
return r;
}});
render();
// Helper functions follow:
// hilite changes the highlighting of elements corresponding to specified src.
function hilite(src, on) {
if (on) {
matches.add(src);
} else {
matches.delete(src);
}
toggleClass(src, 'hilite', on);
return true;
}
// Display action menu (triggered by right-click on a frame)
function showActionMenu(e, box) {
if (box.src == 0) return; // No action menu for root
e.preventDefault(); // Disable browser context menu
const src = stacks.Sources[box.src];
actionTitle.innerText = src.Display[src.Display.length-1];
const menu = actions;
menu.style.display = 'block';
// Compute position so menu stays visible and near the mouse.
const x = Math.min(e.clientX - 10, document.body.clientWidth - menu.clientWidth);
const y = Math.min(e.clientY - 10, document.body.clientHeight - menu.clientHeight);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
// Set menu links to operate on clicked box.
setHrefParam('action-source', 'f', box.src);
setHrefParam('action-source-tab', 'f', box.src);
setHrefParam('action-focus', 'f', box.src);
setHrefParam('action-ignore', 'i', box.src);
setHrefParam('action-hide', 'h', box.src);
setHrefParam('action-showfrom', 'sf', box.src);
toggleClass(box.src, 'hilite2', true);
actionTarget = box;
actionMenuOn = true;
}
function hideActionMenu() {
actions.style.display = 'none';
actionMenuOn = false;
if (actionTarget != null) {
toggleClass(actionTarget.src, 'hilite2', false);
}
}
// setHrefParam updates the specified parameter in the href of an <a>
// element to make it operate on the specified src.
function setHrefParam(id, param, src) {
const elem = document.getElementById(id);
if (!elem) return;
let url = new URL(elem.href);
url.hash = '';
// Copy params from this page's URL.
const params = url.searchParams;
for (const p of new URLSearchParams(window.location.search)) {
params.set(p[0], p[1]);
}
// Update params to include src.
let v = stacks.Sources[src].RE;
if (param != 'f' && param != 'sf') { // old f,sf values are overwritten
// Add new source to current parameter value.
const old = params.get(param);
if (old && old != '') {
v += '|' + old;
}
}
params.set(param, v);
elem.href = url.toString();
}
// Capture Enter key in the search box to make it pivot instead of focus.
function handleSearchKey(e) {
if (e.key != 'Enter') return;
e.stopImmediatePropagation(); // Disable normal enter key handling
const val = search.value;
try {
new RegExp(search.value);
} catch (error) {
return; // TODO: Display error state in search box
}
switchPivots(val);
}
function switchPivots(regexp) {
// Switch URL without hitting the server.
const url = new URL(document.URL);
url.searchParams.set('p', regexp);
history.pushState('', '', url.toString()); // Makes back-button work
matches = new Set();
search.value = '';
render();
}
function handleEnter(box, div) {
if (actionMenuOn) return;
const d = details(box);
div.title = d + ' ' + stacks.Sources[box.src].FullName;
detailBox.innerText = d;
// Highlight all boxes that have the same source as box.
toggleClass(box.src, 'hilite2', true);
}
function handleLeave(box) {
if (actionMenuOn) return;
detailBox.innerText = '';
toggleClass(box.src, 'hilite2', false);
}
// Return list of sources that match the regexp given by the 'p' URL parameter.
function urlPivots() {
const pivots = [];
const params = (new URL(document.URL)).searchParams;
const val = params.get('p');
if (val !== null && val != '') {
try {
const re = new RegExp(val);
for (let i = 0; i < stacks.Sources.length; i++) {
const src = stacks.Sources[i];
if (re.test(src.UniqueName) || re.test(src.FileName)) {
pivots.push(i);
}
}
} catch (error) {}
}
if (pivots.length == 0) {
pivots.push(0);
}
return pivots;
}
// render re-generates the stack display.
function render() {
pivots = urlPivots();
// Get places where pivots occur.
let places = [];
for (let pivot of pivots) {
const src = stacks.Sources[pivot];
for (let p of src.Places) {
places.push(p);
}
}
const width = chart.clientWidth;
elems.clear();
actionTarget = null;
const total = totalValue(places);
const xscale = (width-2*PADDING) / total; // Converts from profile value to X pixels
const x = PADDING;
const y = 0;
shownTotal = total;
displayList.length = 0;
renderStacks(0, xscale, x, y, places, +1); // Callees
renderStacks(0, xscale, x, y-ROW, places, -1); // Callers (ROW left for separator)
display(displayList);
}
// renderStacks creates boxes with top-left at x,y with children drawn as
// nested stacks (below or above based on the sign of direction).
// Returns the largest y coordinate filled.
function renderStacks(depth, xscale, x, y, places, direction) {
// Example: suppose we are drawing the following stacks:
// a->b->c
// a->b->d
// a->e->f
// After rendering a, we will call renderStacks, with places pointing to
// the preceding stacks.
//
// We first group all places with the same leading entry. In this example
// we get [b->c, b->d] and [e->f]. We render the two groups side-by-side.
const groups = partitionPlaces(places);
for (const g of groups) {
renderGroup(depth, xscale, x, y, g, direction);
x += xscale*g.sum;
}
}
function renderGroup(depth, xscale, x, y, g, direction) {
// Skip if not wide enough.
const width = xscale * g.sum;
if (width < MIN_WIDTH) return;
// Draw the box for g.src (except for selected element in upwards direction
// since that duplicates the box we added in downwards direction).
if (depth != 0 || direction > 0) {
const box = {
x: x,
y: y,
src: g.src,
sum: g.sum,
selfValue: g.self,
width: xscale*g.sum,
selfWidth: (direction > 0) ? xscale*g.self : 0,
};
displayList.push(box);
x += box.selfWidth;
}
y += direction * ROW;
// Find child or parent stacks.
const next = [];
for (const place of g.places) {
const stack = stacks.Stacks[place.Stack];
const nextSlot = place.Pos + direction;
if (nextSlot >= 0 && nextSlot < stack.Sources.length) {
next.push({Stack: place.Stack, Pos: nextSlot});
}
}
renderStacks(depth+1, xscale, x, y, next, direction);
}
// partitionPlaces partitions a set of places into groups where each group
// contains places with the same source. If a stack occurs multiple times
// in places, only the outer-most occurrence is kept.
function partitionPlaces(places) {
// Find outer-most slot per stack (used later to elide duplicate stacks).
const stackMap = new Map(); // Map from stack index to outer-most slot#
for (const place of places) {
const prevSlot = stackMap.get(place.Stack);
if (prevSlot && prevSlot <= place.Pos) {
// We already have a higher slot in this stack.
} else {
stackMap.set(place.Stack, place.Pos);
}
}
// Now partition the stacks.
const groups = []; // Array of Group {name, src, sum, self, places}
const groupMap = new Map(); // Map from Source to Group
for (const place of places) {
if (stackMap.get(place.Stack) != place.Pos) {
continue;
}
const stack = stacks.Stacks[place.Stack];
const src = stack.Sources[place.Pos];
let group = groupMap.get(src);
if (!group) {
const name = stacks.Sources[src].FullName;
group = {name: name, src: src, sum: 0, self: 0, places: []};
groupMap.set(src, group);
groups.push(group);
}
group.sum += stack.Value;
group.self += (place.Pos == stack.Sources.length-1) ? stack.Value : 0;
group.places.push(place);
}
// Order by decreasing cost (makes it easier to spot heavy functions).
// Though alphabetical ordering is a potential alternative that will make
// profile comparisons easier.
groups.sort(function(a, b) { return b.sum - a.sum; });
return groups;
}
function display(list) {
// Sort boxes so that text selection follows a predictable order.
list.sort(function(a, b) {
if (a.y != b.y) return a.y - b.y;
return a.x - b.x;
});
// Adjust Y coordinates so that zero is at top.
let adjust = (list.length > 0) ? list[0].y : 0;
adjust -= ROW + 2*PADDING; // Room for details
const divs = [];
for (const box of list) {
box.y -= adjust;
divs.push(drawBox(box));
}
divs.push(drawSep(-adjust));
const h = (list.length > 0 ? list[list.length-1].y : 0) + 4*ROW;
chart.style.height = h+'px';
chart.replaceChildren(...divs);
}
function drawBox(box) {
const srcIndex = box.src;
const src = stacks.Sources[srcIndex];
// Background
const w = box.width - 1; // Leave 1px gap
const r = document.createElement('div');
r.style.left = box.x + 'px';
r.style.top = box.y + 'px';
r.style.width = w + 'px';
r.style.height = (ROW-1) + 'px'; // Leave 1px gap
r.classList.add('boxbg');
r.style.background = makeColor(src.Color);
addElem(srcIndex, r);
// Box that shows time spent in self
if (box.selfWidth >= MIN_WIDTH) {
const s = document.createElement('div');
s.style.width = Math.min(box.selfWidth, w)+'px';
s.style.height = (ROW-1)+'px';
s.classList.add('self');
r.appendChild(s);
}
// Label
if (box.width >= MIN_TEXT_WIDTH) {
const t = document.createElement('div');
t.classList.add('boxtext');
fitText(t, box.width-2*TEXT_MARGIN, src.Display);
r.appendChild(t);
}
r.addEventListener('click', () => { switchPivots(src.RE); });
r.addEventListener('mouseenter', () => { handleEnter(box, r); });
r.addEventListener('mouseleave', () => { handleLeave(box); });
r.addEventListener('contextmenu', (e) => { showActionMenu(e, box); });
return r;
}
function drawSep(y) {
const m = document.createElement('div');
m.innerText = percent(shownTotal, stacks.Total) +
'\xa0\xa0\xa0\xa0' + // Some non-breaking spaces
valueString(shownTotal);
m.style.top = (y-ROW) + 'px';
m.style.left = PADDING + 'px';
m.style.width = (chart.clientWidth - PADDING*2) + 'px';
m.classList.add('separator');
return m;
}
// addElem registers an element that belongs to the specified src.
function addElem(src, elem) {
let list = elems.get(src);
if (!list) {
list = [];
elems.set(src, list);
}
list.push(elem);
elem.classList.toggle('hilite', matches.has(src));
}
// Adds or removes cl from classList of all elements for the specified source.
function toggleClass(src, cl, value) {
const list = elems.get(src);
if (list) {
for (const elem of list) {
elem.classList.toggle(cl, value);
}
}
}
// fitText sets text and font-size clipped to the specified width w.
function fitText(t, avail, textList) {
// Find first entry in textList that fits.
let width = avail;
textContext.font = FONT_SIZE + 'pt Arial';
for (let i = 0; i < textList.length; i++) {
let text = textList[i];
width = textContext.measureText(text).width;
if (width <= avail) {
t.innerText = text;
return;
}
}
// Try to fit by dropping font size.
let text = textList[textList.length-1];
const fs = Math.max(MIN_FONT_SIZE, FONT_SIZE * (avail / width));
t.style.fontSize = fs + 'pt';
t.innerText = text;
}
// totalValue returns the combined sum of the stacks listed in places.
function totalValue(places) {
const seen = new Set();
let result = 0;
for (const place of places) {
if (seen.has(place.Stack)) continue; // Do not double-count stacks
seen.add(place.Stack);
const stack = stacks.Stacks[place.Stack];
result += stack.Value;
}
return result;
}
function details(box) {
// E.g., 10% 7s
// or 10% 7s (3s self
let result = percent(box.sum, stacks.Total) + ' ' + valueString(box.sum);
if (box.selfValue > 0) {
result += ` (${valueString(box.selfValue)} self)`;
}
return result;
}
function percent(v, total) {
return Number(((100.0 * v) / total).toFixed(1)) + '%';
}
// valueString returns a formatted string to display for value.
function valueString(value) {
let v = value * stacks.Scale;
// Rescale to appropriate display unit.
let unit = stacks.Unit;
const list = UNITS.get(unit);
if (list) {
// Find first entry in list that is not too small.
for (const [name, scale] of list) {
if (v <= 100*scale) {
v /= scale;
unit = name;
break;
}
}
}
return Number(v.toFixed(2)) + unit;
}
function find(name) {
const elem = document.getElementById(name);
if (!elem) {
throw 'element not found: ' + name
}
return elem;
}
function makeColor(index) {
// Rotate hue around a circle. Multiple by phi to spread things
// out better. Use 50% saturation to make subdued colors, and
// 80% lightness to have good contrast with black foreground text.
const PHI = 1.618033988;
const hue = (index+1) * PHI * 2 * Math.PI; // +1 to avoid 0
const hsl = `hsl(${hue}rad 50% 80%)`;
return hsl;
}
}