// 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; // Fields 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. let diff = false; // Are we displaying a diff? let shown = 0; // How many profile values are being displayed? for (const stack of stacks.Stacks) { if (stack.Value < 0) { diff = true; break; } } // 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 leftDetailBox = find('current-details-left'); const rightDetailBox = find('current-details-right'); 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(); if (pivots.length == 1 && pivots[0] == 0) { // Not pivoting } else { for (let p of pivots) { r.set(p, true); } } return r; }}); render(); clearDetails(); // 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 // 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. // When `pprof` is invoked with `-lines`, FullName will be suffixed with `:`, // which we need to remove. let v = pprofQuoteMeta(stacks.Sources[src].FullName.replace(/:[0-9]+$/, '')); 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); if (regexp === '' || regexp === '^$') { url.searchParams.delete('p'); // Not pivoting } else { 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 src = stacks.Sources[box.src]; div.title = details(box) + ' │ ' + src.FullName + (src.Inlined ? "\n(inlined)" : ""); leftDetailBox.innerText = src.FullName + (src.Inlined ? " (inlined)" : ""); let timing = summary(box.sumpos, box.sumneg); if (box.self != 0) { timing = "self " + unitText(box.self) + " │ " + timing; } rightDetailBox.innerText = timing; // Highlight all boxes that have the same source as box. toggleClass(box.src, 'hilite2', true); } function handleLeave(box) { if (actionMenuOn) return; clearDetails(); toggleClass(box.src, 'hilite2', false); } function clearDetails() { leftDetailBox.innerText = ''; rightDetailBox.innerText = percentText(shown); } // 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 [pos, neg] = totalValue(places); const total = pos + neg; const xscale = (width-2*PADDING) / total; // Converts from profile value to X pixels const x = PADDING; const y = 0; // Show summary for pivots if we are actually pivoting. const showPivotSummary = !(pivots.length == 1 && pivots[0] == 0); shown = pos + neg; 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(xscale, pos, neg, displayList, showPivotSummary); } // 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 += groupWidth(xscale, g); } } // Some of the types used below: // // // Group represents a displayed (sub)tree. // interface Group { // name: string; // Full name of source // src: number; // Index in stacks.Sources // self: number; // Contribution as leaf (may be < 0 for diffs) // sumpos: number; // Sum of |self| of positive nodes in tree (>= 0) // sumneg: number; // Sum of |self| of negative nodes in tree (>= 0) // places: Place[]; // Stack slots that contributed to this group // } // // // Box is a rendered item. // interface Box { // x: number; // X coordinate of top-left // y: number; // Y coordinate of top-left // width: number; // Width of box to display // src: number; // Index in stacks.Sources // sumpos: number; // From corresponding Group // sumneg: number; // From corresponding Group // self: number; // From corresponding Group // }; function groupWidth(xscale, g) { return xscale * (g.sumpos + g.sumneg); } function renderGroup(depth, xscale, x, y, g, direction) { // Skip if not wide enough. const width = groupWidth(xscale, g); 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, width: width, src: g.src, sumpos: g.sumpos, sumneg: g.sumneg, self: g.self, }; displayList.push(box); if (direction > 0) { // Leave gap on left hand side to indicate self contribution. x += xscale*Math.abs(g.self); } } 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, sumpos: 0, sumneg: 0, self: 0, places: []}; groupMap.set(src, group); groups.push(group); } if (stack.Value < 0) { group.sumneg += -stack.Value; } else { group.sumpos += 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.sumpos + b.sumneg) - (a.sumpos + a.sumneg); }); return groups; } function display(xscale, posTotal, negTotal, list, showPivotSummary) { // 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; const divs = []; for (const box of list) { box.y -= adjust; divs.push(drawBox(xscale, box)); } if (showPivotSummary) { divs.push(drawSep(-adjust, posTotal, negTotal)); } const h = (list.length > 0 ? list[list.length-1].y : 0) + 4*ROW; chart.style.height = h+'px'; chart.replaceChildren(...divs); } function drawBox(xscale, box) { const srcIndex = box.src; const src = stacks.Sources[srcIndex]; function makeRect(cl, x, y, w, h) { const r = document.createElement('div'); r.style.left = x+'px'; r.style.top = y+'px'; r.style.width = w+'px'; r.style.height = h+'px'; r.classList.add(cl); return r; } // Background const w = box.width - 1; // Leave 1px gap const r = makeRect('boxbg', box.x, box.y, w, ROW); if (!diff) r.style.background = makeColor(src.Color); addElem(srcIndex, r); if (!src.Inlined) { r.classList.add('not-inlined'); } // Positive/negative indicator for diff mode. if (diff) { const delta = box.sumpos - box.sumneg; const partWidth = xscale * Math.abs(delta); if (partWidth >= MIN_WIDTH) { r.appendChild(makeRect((delta < 0 ? 'negative' : 'positive'), 0, 0, partWidth, ROW-1)); } } // 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); } onClick(r, () => { switchPivots(pprofQuoteMeta(src.UniqueName)); }); r.addEventListener('mouseenter', () => { handleEnter(box, r); }); r.addEventListener('mouseleave', () => { handleLeave(box); }); r.addEventListener('contextmenu', (e) => { showActionMenu(e, box); }); return r; } // Handle clicks, but only if the mouse did not move during the click. function onClick(target, handler) { // Disable click if mouse moves more than threshold pixels since mousedown. const threshold = 3; let [x, y] = [-1, -1]; target.addEventListener('mousedown', (e) => { [x, y] = [e.clientX, e.clientY]; }); target.addEventListener('click', (e) => { if (Math.abs(e.clientX - x) <= threshold && Math.abs(e.clientY - y) <= threshold) { handler(); } }); } function drawSep(y, posTotal, negTotal) { const m = document.createElement('div'); m.innerText = summary(posTotal, negTotal); 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 positive and negative sums of the Values of stacks // listed in places. function totalValue(places) { const seen = new Set(); let pos = 0; let neg = 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]; if (stack.Value < 0) { neg += -stack.Value; } else { pos += stack.Value; } } return [pos, neg]; } function summary(pos, neg) { // Examples: // 6s (10%) // 12s (20%) 🠆 18s (30%) return diff ? diffText(neg, pos) : percentText(pos); } function details(box) { // Examples: // 6s (10%) // 6s (10%) │ self 3s (5%) // 6s (10%) │ 12s (20%) 🠆 18s (30%) let result = percentText(box.sumpos - box.sumneg); if (box.self != 0) { result += " │ self " + unitText(box.self); } if (diff && box.sumpos > 0 && box.sumneg > 0) { result += " │ " + diffText(box.sumneg, box.sumpos); } return result; } // diffText returns text that displays from and to alongside their percentages. // E.g., 9s (45%) 🠆 10s (50%) function diffText(from, to) { return percentText(from) + " 🠆 " + percentText(to); } // percentText returns text that displays v in appropriate units alongside its // percentange. function percentText(v) { function percent(v, total) { return Number(((100.0 * v) / total).toFixed(1)) + '%'; } return unitText(v) + " (" + percent(v, stacks.Total) + ")"; } // unitText returns a formatted string to display for value. function unitText(value) { return pprofUnitText(value*stacks.Scale, stacks.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; } } // pprofUnitText returns a formatted string to display for value in the specified unit. function pprofUnitText(value, unit) { const sign = (value < 0) ? "-" : ""; let v = Math.abs(value); // Rescale to appropriate display unit. let list = null; for (const def of pprofUnitDefs) { if (def.DefaultUnit.CanonicalName == unit) { list = def.Units; v *= def.DefaultUnit.Factor; break; } } if (list) { // Stop just before entry that is too large. for (let i = 0; i < list.length; i++) { if (i == list.length-1 || list[i+1].Factor > v) { v /= list[i].Factor; unit = list[i].CanonicalName; break; } } } return sign + Number(v.toFixed(2)) + unit; }