1// stackViewer displays a flame-graph like view (extended to show callers). 2// stacks - report.StackSet 3// nodes - List of names for each source in report.StackSet 4function stackViewer(stacks, nodes) { 5 'use strict'; 6 7 // Constants used in rendering. 8 const ROW = 20; 9 const PADDING = 2; 10 const MIN_WIDTH = 4; 11 const MIN_TEXT_WIDTH = 16; 12 const TEXT_MARGIN = 2; 13 const FONT_SIZE = 12; 14 const MIN_FONT_SIZE = 8; 15 16 // Fields 17 let pivots = []; // Indices of currently selected data.Sources entries. 18 let matches = new Set(); // Indices of sources that match search 19 let elems = new Map(); // Mapping from source index to display elements 20 let displayList = []; // List of boxes to display. 21 let actionMenuOn = false; // Is action menu visible? 22 let actionTarget = null; // Box on which action menu is operating. 23 let diff = false; // Are we displaying a diff? 24 25 for (const stack of stacks.Stacks) { 26 if (stack.Value < 0) { 27 diff = true; 28 break; 29 } 30 } 31 32 // Setup to allow measuring text width. 33 const textSizer = document.createElement('canvas'); 34 textSizer.id = 'textsizer'; 35 const textContext = textSizer.getContext('2d'); 36 37 // Get DOM elements. 38 const chart = find('stack-chart'); 39 const search = find('search'); 40 const actions = find('action-menu'); 41 const actionTitle = find('action-title'); 42 const detailBox = find('current-details'); 43 44 window.addEventListener('resize', render); 45 window.addEventListener('popstate', render); 46 search.addEventListener('keydown', handleSearchKey); 47 48 // Withdraw action menu when clicking outside, or when item selected. 49 document.addEventListener('mousedown', (e) => { 50 if (!actions.contains(e.target)) { 51 hideActionMenu(); 52 } 53 }); 54 actions.addEventListener('click', hideActionMenu); 55 56 // Initialize menus and other general UI elements. 57 viewer(new URL(window.location.href), nodes, { 58 hiliter: (n, on) => { return hilite(n, on); }, 59 current: () => { 60 let r = new Map(); 61 if (pivots.length == 1 && pivots[0] == 0) { 62 // Not pivoting 63 } else { 64 for (let p of pivots) { 65 r.set(p, true); 66 } 67 } 68 return r; 69 }}); 70 71 render(); 72 73 // Helper functions follow: 74 75 // hilite changes the highlighting of elements corresponding to specified src. 76 function hilite(src, on) { 77 if (on) { 78 matches.add(src); 79 } else { 80 matches.delete(src); 81 } 82 toggleClass(src, 'hilite', on); 83 return true; 84 } 85 86 // Display action menu (triggered by right-click on a frame) 87 function showActionMenu(e, box) { 88 if (box.src == 0) return; // No action menu for root 89 e.preventDefault(); // Disable browser context menu 90 const src = stacks.Sources[box.src]; 91 actionTitle.innerText = src.Display[src.Display.length-1]; 92 const menu = actions; 93 menu.style.display = 'block'; 94 // Compute position so menu stays visible and near the mouse. 95 const x = Math.min(e.clientX - 10, document.body.clientWidth - menu.clientWidth); 96 const y = Math.min(e.clientY - 10, document.body.clientHeight - menu.clientHeight); 97 menu.style.left = x + 'px'; 98 menu.style.top = y + 'px'; 99 // Set menu links to operate on clicked box. 100 setHrefParam('action-source', 'f', box.src); 101 setHrefParam('action-source-tab', 'f', box.src); 102 setHrefParam('action-focus', 'f', box.src); 103 setHrefParam('action-ignore', 'i', box.src); 104 setHrefParam('action-hide', 'h', box.src); 105 setHrefParam('action-showfrom', 'sf', box.src); 106 toggleClass(box.src, 'hilite2', true); 107 actionTarget = box; 108 actionMenuOn = true; 109 } 110 111 function hideActionMenu() { 112 actions.style.display = 'none'; 113 actionMenuOn = false; 114 if (actionTarget != null) { 115 toggleClass(actionTarget.src, 'hilite2', false); 116 } 117 } 118 119 // setHrefParam updates the specified parameter in the href of an <a> 120 // element to make it operate on the specified src. 121 function setHrefParam(id, param, src) { 122 const elem = document.getElementById(id); 123 if (!elem) return; 124 125 let url = new URL(elem.href); 126 url.hash = ''; 127 128 // Copy params from this page's URL. 129 const params = url.searchParams; 130 for (const p of new URLSearchParams(window.location.search)) { 131 params.set(p[0], p[1]); 132 } 133 134 // Update params to include src. 135 let v = pprofQuoteMeta(stacks.Sources[src].FullName); 136 if (param != 'f' && param != 'sf') { // old f,sf values are overwritten 137 // Add new source to current parameter value. 138 const old = params.get(param); 139 if (old && old != '') { 140 v += '|' + old; 141 } 142 } 143 params.set(param, v); 144 145 elem.href = url.toString(); 146 } 147 148 // Capture Enter key in the search box to make it pivot instead of focus. 149 function handleSearchKey(e) { 150 if (e.key != 'Enter') return; 151 e.stopImmediatePropagation(); // Disable normal enter key handling 152 const val = search.value; 153 try { 154 new RegExp(search.value); 155 } catch (error) { 156 return; // TODO: Display error state in search box 157 } 158 switchPivots(val); 159 } 160 161 function switchPivots(regexp) { 162 // Switch URL without hitting the server. 163 const url = new URL(document.URL); 164 if (regexp === '' || regexp === '^$') { 165 url.searchParams.delete('p'); // Not pivoting 166 } else { 167 url.searchParams.set('p', regexp); 168 } 169 history.pushState('', '', url.toString()); // Makes back-button work 170 matches = new Set(); 171 search.value = ''; 172 render(); 173 } 174 175 function handleEnter(box, div) { 176 if (actionMenuOn) return; 177 const src = stacks.Sources[box.src]; 178 div.title = details(box) + ' │ ' + src.FullName + (src.Inlined ? "\n(inlined)" : ""); 179 detailBox.innerText = summary(box.sumpos, box.sumneg); 180 // Highlight all boxes that have the same source as box. 181 toggleClass(box.src, 'hilite2', true); 182 } 183 184 function handleLeave(box) { 185 if (actionMenuOn) return; 186 detailBox.innerText = ''; 187 toggleClass(box.src, 'hilite2', false); 188 } 189 190 // Return list of sources that match the regexp given by the 'p' URL parameter. 191 function urlPivots() { 192 const pivots = []; 193 const params = (new URL(document.URL)).searchParams; 194 const val = params.get('p'); 195 if (val !== null && val != '') { 196 try { 197 const re = new RegExp(val); 198 for (let i = 0; i < stacks.Sources.length; i++) { 199 const src = stacks.Sources[i]; 200 if (re.test(src.UniqueName) || re.test(src.FileName)) { 201 pivots.push(i); 202 } 203 } 204 } catch (error) {} 205 } 206 if (pivots.length == 0) { 207 pivots.push(0); 208 } 209 return pivots; 210 } 211 212 // render re-generates the stack display. 213 function render() { 214 pivots = urlPivots(); 215 216 // Get places where pivots occur. 217 let places = []; 218 for (let pivot of pivots) { 219 const src = stacks.Sources[pivot]; 220 for (let p of src.Places) { 221 places.push(p); 222 } 223 } 224 225 const width = chart.clientWidth; 226 elems.clear(); 227 actionTarget = null; 228 const [pos, neg] = totalValue(places); 229 const total = pos + neg; 230 const xscale = (width-2*PADDING) / total; // Converts from profile value to X pixels 231 const x = PADDING; 232 const y = 0; 233 234 displayList.length = 0; 235 renderStacks(0, xscale, x, y, places, +1); // Callees 236 renderStacks(0, xscale, x, y-ROW, places, -1); // Callers (ROW left for separator) 237 display(xscale, pos, neg, displayList); 238 } 239 240 // renderStacks creates boxes with top-left at x,y with children drawn as 241 // nested stacks (below or above based on the sign of direction). 242 // Returns the largest y coordinate filled. 243 function renderStacks(depth, xscale, x, y, places, direction) { 244 // Example: suppose we are drawing the following stacks: 245 // a->b->c 246 // a->b->d 247 // a->e->f 248 // After rendering a, we will call renderStacks, with places pointing to 249 // the preceding stacks. 250 // 251 // We first group all places with the same leading entry. In this example 252 // we get [b->c, b->d] and [e->f]. We render the two groups side-by-side. 253 const groups = partitionPlaces(places); 254 for (const g of groups) { 255 renderGroup(depth, xscale, x, y, g, direction); 256 x += groupWidth(xscale, g); 257 } 258 } 259 260 // Some of the types used below: 261 // 262 // // Group represents a displayed (sub)tree. 263 // interface Group { 264 // name: string; // Full name of source 265 // src: number; // Index in stacks.Sources 266 // self: number; // Contribution as leaf (may be < 0 for diffs) 267 // sumpos: number; // Sum of |self| of positive nodes in tree (>= 0) 268 // sumneg: number; // Sum of |self| of negative nodes in tree (>= 0) 269 // places: Place[]; // Stack slots that contributed to this group 270 // } 271 // 272 // // Box is a rendered item. 273 // interface Box { 274 // x: number; // X coordinate of top-left 275 // y: number; // Y coordinate of top-left 276 // width: number; // Width of box to display 277 // src: number; // Index in stacks.Sources 278 // sumpos: number; // From corresponding Group 279 // sumneg: number; // From corresponding Group 280 // self: number; // From corresponding Group 281 // }; 282 283 function groupWidth(xscale, g) { 284 return xscale * (g.sumpos + g.sumneg); 285 } 286 287 function renderGroup(depth, xscale, x, y, g, direction) { 288 // Skip if not wide enough. 289 const width = groupWidth(xscale, g); 290 if (width < MIN_WIDTH) return; 291 292 // Draw the box for g.src (except for selected element in upwards direction 293 // since that duplicates the box we added in downwards direction). 294 if (depth != 0 || direction > 0) { 295 const box = { 296 x: x, 297 y: y, 298 width: width, 299 src: g.src, 300 sumpos: g.sumpos, 301 sumneg: g.sumneg, 302 self: g.self, 303 }; 304 displayList.push(box); 305 if (direction > 0) { 306 // Leave gap on left hand side to indicate self contribution. 307 x += xscale*Math.abs(g.self); 308 } 309 } 310 y += direction * ROW; 311 312 // Find child or parent stacks. 313 const next = []; 314 for (const place of g.places) { 315 const stack = stacks.Stacks[place.Stack]; 316 const nextSlot = place.Pos + direction; 317 if (nextSlot >= 0 && nextSlot < stack.Sources.length) { 318 next.push({Stack: place.Stack, Pos: nextSlot}); 319 } 320 } 321 renderStacks(depth+1, xscale, x, y, next, direction); 322 } 323 324 // partitionPlaces partitions a set of places into groups where each group 325 // contains places with the same source. If a stack occurs multiple times 326 // in places, only the outer-most occurrence is kept. 327 function partitionPlaces(places) { 328 // Find outer-most slot per stack (used later to elide duplicate stacks). 329 const stackMap = new Map(); // Map from stack index to outer-most slot# 330 for (const place of places) { 331 const prevSlot = stackMap.get(place.Stack); 332 if (prevSlot && prevSlot <= place.Pos) { 333 // We already have a higher slot in this stack. 334 } else { 335 stackMap.set(place.Stack, place.Pos); 336 } 337 } 338 339 // Now partition the stacks. 340 const groups = []; // Array of Group {name, src, sum, self, places} 341 const groupMap = new Map(); // Map from Source to Group 342 for (const place of places) { 343 if (stackMap.get(place.Stack) != place.Pos) { 344 continue; 345 } 346 347 const stack = stacks.Stacks[place.Stack]; 348 const src = stack.Sources[place.Pos]; 349 let group = groupMap.get(src); 350 if (!group) { 351 const name = stacks.Sources[src].FullName; 352 group = {name: name, src: src, sumpos: 0, sumneg: 0, self: 0, places: []}; 353 groupMap.set(src, group); 354 groups.push(group); 355 } 356 if (stack.Value < 0) { 357 group.sumneg += -stack.Value; 358 } else { 359 group.sumpos += stack.Value; 360 } 361 group.self += (place.Pos == stack.Sources.length-1) ? stack.Value : 0; 362 group.places.push(place); 363 } 364 365 // Order by decreasing cost (makes it easier to spot heavy functions). 366 // Though alphabetical ordering is a potential alternative that will make 367 // profile comparisons easier. 368 groups.sort(function(a, b) { 369 return (b.sumpos + b.sumneg) - (a.sumpos + a.sumneg); 370 }); 371 372 return groups; 373 } 374 375 function display(xscale, posTotal, negTotal, list) { 376 // Sort boxes so that text selection follows a predictable order. 377 list.sort(function(a, b) { 378 if (a.y != b.y) return a.y - b.y; 379 return a.x - b.x; 380 }); 381 382 // Adjust Y coordinates so that zero is at top. 383 let adjust = (list.length > 0) ? list[0].y : 0; 384 adjust -= ROW + 2*PADDING; // Room for details 385 386 const divs = []; 387 for (const box of list) { 388 box.y -= adjust; 389 divs.push(drawBox(xscale, box)); 390 } 391 divs.push(drawSep(-adjust, posTotal, negTotal)); 392 393 const h = (list.length > 0 ? list[list.length-1].y : 0) + 4*ROW; 394 chart.style.height = h+'px'; 395 chart.replaceChildren(...divs); 396 } 397 398 function drawBox(xscale, box) { 399 const srcIndex = box.src; 400 const src = stacks.Sources[srcIndex]; 401 402 function makeRect(cl, x, y, w, h) { 403 const r = document.createElement('div'); 404 r.style.left = x+'px'; 405 r.style.top = y+'px'; 406 r.style.width = w+'px'; 407 r.style.height = h+'px'; 408 r.classList.add(cl); 409 return r; 410 } 411 412 // Background 413 const w = box.width - 1; // Leave 1px gap 414 const r = makeRect('boxbg', box.x, box.y, w, ROW); 415 if (!diff) r.style.background = makeColor(src.Color); 416 addElem(srcIndex, r); 417 if (!src.Inlined) { 418 r.classList.add('not-inlined'); 419 } 420 421 // Positive/negative indicator for diff mode. 422 if (diff) { 423 const delta = box.sumpos - box.sumneg; 424 const partWidth = xscale * Math.abs(delta); 425 if (partWidth >= MIN_WIDTH) { 426 r.appendChild(makeRect((delta < 0 ? 'negative' : 'positive'), 427 0, 0, partWidth, ROW-1)); 428 } 429 } 430 431 // Label 432 if (box.width >= MIN_TEXT_WIDTH) { 433 const t = document.createElement('div'); 434 t.classList.add('boxtext'); 435 fitText(t, box.width-2*TEXT_MARGIN, src.Display); 436 r.appendChild(t); 437 } 438 439 r.addEventListener('click', () => { switchPivots(pprofQuoteMeta(src.UniqueName)); }); 440 r.addEventListener('mouseenter', () => { handleEnter(box, r); }); 441 r.addEventListener('mouseleave', () => { handleLeave(box); }); 442 r.addEventListener('contextmenu', (e) => { showActionMenu(e, box); }); 443 return r; 444 } 445 446 function drawSep(y, posTotal, negTotal) { 447 const m = document.createElement('div'); 448 m.innerText = summary(posTotal, negTotal); 449 m.style.top = (y-ROW) + 'px'; 450 m.style.left = PADDING + 'px'; 451 m.style.width = (chart.clientWidth - PADDING*2) + 'px'; 452 m.classList.add('separator'); 453 return m; 454 } 455 456 // addElem registers an element that belongs to the specified src. 457 function addElem(src, elem) { 458 let list = elems.get(src); 459 if (!list) { 460 list = []; 461 elems.set(src, list); 462 } 463 list.push(elem); 464 elem.classList.toggle('hilite', matches.has(src)); 465 } 466 467 // Adds or removes cl from classList of all elements for the specified source. 468 function toggleClass(src, cl, value) { 469 const list = elems.get(src); 470 if (list) { 471 for (const elem of list) { 472 elem.classList.toggle(cl, value); 473 } 474 } 475 } 476 477 // fitText sets text and font-size clipped to the specified width w. 478 function fitText(t, avail, textList) { 479 // Find first entry in textList that fits. 480 let width = avail; 481 textContext.font = FONT_SIZE + 'pt Arial'; 482 for (let i = 0; i < textList.length; i++) { 483 let text = textList[i]; 484 width = textContext.measureText(text).width; 485 if (width <= avail) { 486 t.innerText = text; 487 return; 488 } 489 } 490 491 // Try to fit by dropping font size. 492 let text = textList[textList.length-1]; 493 const fs = Math.max(MIN_FONT_SIZE, FONT_SIZE * (avail / width)); 494 t.style.fontSize = fs + 'pt'; 495 t.innerText = text; 496 } 497 498 // totalValue returns the positive and negative sums of the Values of stacks 499 // listed in places. 500 function totalValue(places) { 501 const seen = new Set(); 502 let pos = 0; 503 let neg = 0; 504 for (const place of places) { 505 if (seen.has(place.Stack)) continue; // Do not double-count stacks 506 seen.add(place.Stack); 507 const stack = stacks.Stacks[place.Stack]; 508 if (stack.Value < 0) { 509 neg += -stack.Value; 510 } else { 511 pos += stack.Value; 512 } 513 } 514 return [pos, neg]; 515 } 516 517 function summary(pos, neg) { 518 // Examples: 519 // 6s (10%) 520 // 12s (20%) 18s (30%) 521 return diff ? diffText(neg, pos) : percentText(pos); 522 } 523 524 function details(box) { 525 // Examples: 526 // 6s (10%) 527 // 6s (10%) │ self 3s (5%) 528 // 6s (10%) │ 12s (20%) 18s (30%) 529 let result = percentText(box.sumpos - box.sumneg); 530 if (box.self != 0) { 531 result += " │ self " + unitText(box.self); 532 } 533 if (diff && box.sumpos > 0 && box.sumneg > 0) { 534 result += " │ " + diffText(box.sumneg, box.sumpos); 535 } 536 return result; 537 } 538 539 // diffText returns text that displays from and to alongside their percentages. 540 // E.g., 9s (45%) 10s (50%) 541 function diffText(from, to) { 542 return percentText(from) + " " + percentText(to); 543 } 544 545 // percentText returns text that displays v in appropriate units alongside its 546 // percentange. 547 function percentText(v) { 548 function percent(v, total) { 549 return Number(((100.0 * v) / total).toFixed(1)) + '%'; 550 } 551 return unitText(v) + " (" + percent(v, stacks.Total) + ")"; 552 } 553 554 // unitText returns a formatted string to display for value. 555 function unitText(value) { 556 return pprofUnitText(value*stacks.Scale, stacks.Unit); 557 } 558 559 function find(name) { 560 const elem = document.getElementById(name); 561 if (!elem) { 562 throw 'element not found: ' + name 563 } 564 return elem; 565 } 566 567 function makeColor(index) { 568 // Rotate hue around a circle. Multiple by phi to spread things 569 // out better. Use 50% saturation to make subdued colors, and 570 // 80% lightness to have good contrast with black foreground text. 571 const PHI = 1.618033988; 572 const hue = (index+1) * PHI * 2 * Math.PI; // +1 to avoid 0 573 const hsl = `hsl(${hue}rad 50% 80%)`; 574 return hsl; 575 } 576} 577 578// pprofUnitText returns a formatted string to display for value in the specified unit. 579function pprofUnitText(value, unit) { 580 const sign = (value < 0) ? "-" : ""; 581 let v = Math.abs(value); 582 // Rescale to appropriate display unit. 583 let list = null; 584 for (const def of pprofUnitDefs) { 585 if (def.DefaultUnit.CanonicalName == unit) { 586 list = def.Units; 587 v *= def.DefaultUnit.Factor; 588 break; 589 } 590 } 591 if (list) { 592 // Stop just before entry that is too large. 593 for (let i = 0; i < list.length; i++) { 594 if (i == list.length-1 || list[i+1].Factor > v) { 595 v /= list[i].Factor; 596 unit = list[i].CanonicalName; 597 break; 598 } 599 } 600 } 601 return sign + Number(v.toFixed(2)) + unit; 602} 603