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