Added alternative flamegraph implementation that can show callers. (#716)

Add an experimental flame-graph implementation. It can be selected in
pprof's web interface using the new "Flame (experimental)" menu entry.
At some point this new implementation may become the default.

The new view is similar to flame-graph view. But it can show caller
information as well. This should allow it to satisfy many users of
Graph and Peek views as well.

Let's illustrate with an example. Suppose we have profile data that
consists of the following stacks:

```
1000	main -> foo -> malloc
2000	main -> bar -> malloc
```

When main is selected, both the old and new views show:

```
[-------------------3000 main---------------------]
[---1000 foo-----] [----------2000 bar------------]
[---1000 malloc--] [----------2000 malloc---------]
```

But suppose now the user selects the right-most malloc slot.
The old view will show just the path leading to that malloc:

```
[----------2000 main-----------]
[----------2000 bar------------]
[----------2000 malloc---------]
```

The new view will however show a flame-graph view that grows
upwards that displays the call stacks leading to malloc:

```
[---1000 main----] [----------2000 main-----------]
[---1000 foo-----] [----------2000 bar------------]
[-------------------3000 malloc-------------------]
```

This caller display is useful when trying to determine expensive
callers of function.

A list of important differences between the new view and flame graphs:

New view pros:

1.  Callers are shown, e.g., all paths leading to malloc.
2.  Shows self-cost clearly with a different saturation.
3.  Font size is adjusted to fit more text into boxes.
4.  Highlighting on hover shows other occurrences of a function.
5.  Search works more like other views.
6.  Pivot changes are reflected in browser history (so back and forward
    buttons can be used to navigate between different selections).
7.  Allows eventual removal of the D3 dependency, which may make
    integrations into various environments easier.
8.  Colors provide higher contrast between foreground and background.

New view cons:

1.  There are small differences in how things look and feel.
2.  Color-scheme is very different.
3.  Change triggered by selecting a new entry is not animated.
diff --git a/doc/README.md b/doc/README.md
index 9144b00..8f90e21 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -399,6 +399,40 @@
 
 The top of the display is a header that contains some buttons and menus.
 
+## View
+
+The `View` menu allows the user to switch between different visualizations of
+the profile.
+
+Top
+:   Displays a tabular view of profile entries. The table can be sorted
+    interactively.
+
+Graph
+:   Displays a scrollable/zoomable graph view; each function (or profile entry)
+    is represented by a node and edges connect callers to callees.
+
+Flame Graph
+:   Displays a [flame graph](https://www.brendangregg.com/flamegraphs.html).
+
+Flame (experimental)
+:   Displays a view similar to a flame graph that can show the selected node's
+    callers and callees simultaneously.
+
+NOTE: This view is currently experimental and may eventually replace the normal
+Flame Graph view.
+
+Peek
+:   Shows callers / callees per function in a simple textual forma.
+
+Source
+:   Displays source code annotated with profile information. Clicking on a
+    source line can show the disassembled machine instructions for that line.
+
+Disassemble
+:   Displays disassembled machine instructions annotated with profile
+    information.
+
 ## Config
 
 The `Config` menu allows the user to save the current refinement
diff --git a/internal/driver/html/common.css b/internal/driver/html/common.css
index 03755ab..e0de53c 100644
--- a/internal/driver/html/common.css
+++ b/internal/driver/html/common.css
@@ -116,6 +116,7 @@
   box-shadow: 0 1px 5px rgba(0,0,0,.3);
   font-size: 100%;
   text-transform: none;
+  white-space: nowrap;
 }
 .menu-item, .submenu {
   user-select: none;
diff --git a/internal/driver/html/common.js b/internal/driver/html/common.js
index 4fe3caa..5282c1b 100644
--- a/internal/driver/html/common.js
+++ b/internal/driver/html/common.js
@@ -388,7 +388,12 @@
   }
 }
 
-function viewer(baseUrl, nodes) {
+// options if present can contain:
+//   hiliter: function(Number, Boolean): Boolean
+//     Overridable mechanism for highlighting/unhighlighting specified node.
+//   current: function() Map[Number,Boolean]
+//     Overridable mechanism for fetching set of currently selected nodes.
+function viewer(baseUrl, nodes, options) {
   'use strict';
 
   // Elements
@@ -403,6 +408,16 @@
   let searchAlarm = null;
   let buttonsEnabled = true;
 
+  // Return current selection.
+  function getSelection() {
+    if (selected.size > 0) {
+      return selected;
+    } else if (options && options.current) {
+      return options.current();
+    }
+    return new Map();
+  }
+
   function handleDetails(e) {
     e.preventDefault();
     const detailsText = document.getElementById('detailsbox');
@@ -453,7 +468,7 @@
     // drop currently selected items that do not match re.
     selected.forEach(function(v, n) {
       if (!match(nodes[n])) {
-        unselect(n, document.getElementById('node' + n));
+        unselect(n);
       }
     })
 
@@ -461,7 +476,7 @@
     if (nodes) {
       for (let n = 0; n < nodes.length; n++) {
         if (!selected.has(n) && match(nodes[n])) {
-          select(n, document.getElementById('node' + n));
+          select(n);
         }
       }
     }
@@ -482,23 +497,19 @@
     const n = nodeId(elem);
     if (n < 0) return;
     if (selected.has(n)) {
-      unselect(n, elem);
+      unselect(n);
     } else {
-      select(n, elem);
+      select(n);
     }
     updateButtons();
   }
 
-  function unselect(n, elem) {
-    if (elem == null) return;
-    selected.delete(n);
-    setBackground(elem, false);
+  function unselect(n) {
+    if (setNodeHighlight(n, false)) selected.delete(n);
   }
 
   function select(n, elem) {
-    if (elem == null) return;
-    selected.set(n, true);
-    setBackground(elem, true);
+    if (setNodeHighlight(n, true)) selected.set(n, true);
   }
 
   function nodeId(elem) {
@@ -511,11 +522,17 @@
     return n;
   }
 
-  function setBackground(elem, set) {
+  // Change highlighting of node (returns true if node was found).
+  function setNodeHighlight(n, set) {
+    if (options && options.hiliter) return options.hiliter(n, set);
+
+    const elem = document.getElementById('node' + n);
+    if (!elem) return false;
+
     // Handle table row highlighting.
     if (elem.nodeName == 'TR') {
       elem.classList.toggle('hilite', set);
-      return;
+      return true;
     }
 
     // Handle svg element highlighting.
@@ -528,6 +545,8 @@
         p.style.fill = origFill.get(p);
       }
     }
+
+    return true;
   }
 
   function findPolygon(elem) {
@@ -575,8 +594,8 @@
       // The selection can be in one of two modes: regexp-based or
       // list-based.  Construct regular expression depending on mode.
       let re = regexpActive
-        ? search.value
-        : Array.from(selected.keys()).map(key => quotemeta(nodes[key])).join('|');
+          ? search.value
+          : Array.from(getSelection().keys()).map(key => quotemeta(nodes[key])).join('|');
 
       setHrefParams(elem, function (params) {
         if (re != '') {
@@ -639,7 +658,7 @@
   }
 
   function updateButtons() {
-    const enable = (search.value != '' || selected.size != 0);
+    const enable = (search.value != '' || getSelection().size != 0);
     if (buttonsEnabled == enable) return;
     buttonsEnabled = enable;
     for (const id of ['focus', 'ignore', 'hide', 'show', 'show-from']) {
@@ -663,8 +682,8 @@
     toptable.addEventListener('touchstart', handleTopClick);
   }
 
-  const ids = ['topbtn', 'graphbtn', 'flamegraph', 'peek', 'list', 'disasm',
-               'focus', 'ignore', 'hide', 'show', 'show-from'];
+  const ids = ['topbtn', 'graphbtn', 'flamegraph', 'flamegraph2', 'peek', 'list',
+	       'disasm', 'focus', 'ignore', 'hide', 'show', 'show-from'];
   ids.forEach(makeSearchLinkDynamic);
 
   const sampleIDs = [{{range .SampleTypes}}'{{.}}', {{end}}];
diff --git a/internal/driver/html/header.html b/internal/driver/html/header.html
index 66cabbb..39cb55a 100644
--- a/internal/driver/html/header.html
+++ b/internal/driver/html/header.html
@@ -12,6 +12,7 @@
       <a title="{{.Help.top}}"  href="./top" id="topbtn">Top</a>
       <a title="{{.Help.graph}}" href="./" id="graphbtn">Graph</a>
       <a title="{{.Help.flamegraph}}" href="./flamegraph" id="flamegraph">Flame Graph</a>
+      <a title="{{.Help.flamegraph2}}" href="./flamegraph2" id="flamegraph2">Flame Graph (new)</a>
       <a title="{{.Help.peek}}" href="./peek" id="peek">Peek</a>
       <a title="{{.Help.list}}" href="./source" id="list">Source</a>
       <a title="{{.Help.disasm}}" href="./disasm" id="disasm">Disassemble</a>
diff --git a/internal/driver/html/stacks.css b/internal/driver/html/stacks.css
new file mode 100644
index 0000000..d3701e4
--- /dev/null
+++ b/internal/driver/html/stacks.css
@@ -0,0 +1,75 @@
+body {
+  overflow: hidden; /* Want scrollbar not here, but in #stack-holder */
+}
+/* Scrollable container for flame graph */
+#stack-holder {
+  width: 100%;
+  flex-grow: 1;
+  overflow-y: auto;
+  background: #eee; /* Light grey gives better contrast with boxes */
+  position: relative; /* Allows absolute positioning of child boxes */
+}
+/* Flame graph */
+#stack-chart {
+  width: 100%;
+  position: relative; /* Allows absolute positioning of child boxes */
+}
+/* Shows details of frame that is under the mouse */
+#current-details {
+  position: absolute;
+  top: 5px;
+  right: 5px;
+  z-index: 2;
+  font-size: 12pt;
+}
+/* Background of a single flame-graph frame */
+.boxbg {
+  border-width: 0px;
+  position: absolute;
+  overflow: hidden;
+}
+/* Function name */
+.boxtext {
+  position: absolute;
+  width: 100%;
+  padding-left: 2px;
+  line-height: 18px;
+  cursor: default;
+  font-family: "Google Sans", Arial, sans-serif;
+  font-size: 12pt;
+  z-index: 2;
+}
+/* Box highlighting via shadows to avoid size changes */
+.hilite { box-shadow: 0px 0px 0px 2px #000; z-index: 1; }
+.hilite2 { box-shadow: 0px 0px 0px 2px #000; z-index: 1; }
+/* Self-cost region inside a box */
+.self {
+  position: absolute;
+  background: rgba(0,0,0,0.25); /* Darker hue */
+}
+/* Gap left between callers and callees */
+.separator {
+  position: absolute;
+  text-align: center;
+  font-size: 12pt;
+  font-weight: bold;
+}
+/* Ensure that pprof menu is above boxes */
+.submenu { z-index: 3; }
+/* Right-click menu */
+#action-menu {
+  max-width: 15em;
+}
+/* Right-click menu title */
+#action-title {
+  display: block;
+  padding: 0.5em 1em;
+  background: #888;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+/* Internal canvas used to measure text size when picking fonts */
+#textsizer {
+  position: absolute;
+  bottom: -100px;
+}
diff --git a/internal/driver/html/stacks.html b/internal/driver/html/stacks.html
new file mode 100644
index 0000000..1ddb7a3
--- /dev/null
+++ b/internal/driver/html/stacks.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>{{.Title}}</title>
+  {{template "css" .}}
+  {{template "stacks_css"}}
+</head>
+<body>
+  {{template "header" .}}
+  <div id="stack-holder">
+    <div id="stack-chart"></div>
+    <div id="current-details"></div>
+  </div>
+  <div id="action-menu" class="submenu">
+    <span id="action-title"></span>
+    <hr>
+    <a title="{{.Help.list}}" id="action-source" href="./source">Show source code</a>
+    <a title="{{.Help.list}}" id="action-source-tab" href="./source" target="_blank">Show source in new tab</a>
+    <hr>
+    <a title="{{.Help.focus}}" id="action-focus" href="?">Focus</a>
+    <a title="{{.Help.ignore}}" id="action-ignore" href="?">Ignore</a>
+    <a title="{{.Help.hide}}" id="action-hide" href="?">Hide</a>
+    <a title="{{.Help.show_from}}" id="action-showfrom" href="?">Show from</a>
+  </div>
+  {{template "script" .}}
+  {{template "stacks_js"}}
+  <script>
+    stackViewer({{.Stacks}}, {{.Nodes}});
+  </script>
+</body>
+</html>
diff --git a/internal/driver/html/stacks.js b/internal/driver/html/stacks.js
new file mode 100644
index 0000000..f0cb712
--- /dev/null
+++ b/internal/driver/html/stacks.js
@@ -0,0 +1,520 @@
+// 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;
+  }
+}
diff --git a/internal/driver/stacks.go b/internal/driver/stacks.go
new file mode 100644
index 0000000..249dfe0
--- /dev/null
+++ b/internal/driver/stacks.go
@@ -0,0 +1,58 @@
+// Copyright 2022 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package driver
+
+import (
+	"encoding/json"
+	"html/template"
+	"net/http"
+
+	"github.com/google/pprof/internal/report"
+)
+
+// stackView generates the new flamegraph view.
+func (ui *webInterface) stackView(w http.ResponseWriter, req *http.Request) {
+	// Get all data in a report.
+	rpt, errList := ui.makeReport(w, req, []string{"svg"}, func(cfg *config) {
+		cfg.CallTree = true
+		cfg.Trim = false
+		cfg.Granularity = "filefunctions"
+	})
+	if rpt == nil {
+		return // error already reported
+	}
+
+	// Make stack data and generate corresponding JSON.
+	stacks := rpt.Stacks()
+	b, err := json.Marshal(stacks)
+	if err != nil {
+		http.Error(w, "error serializing stacks for flame graph",
+			http.StatusInternalServerError)
+		ui.options.UI.PrintErr(err)
+		return
+	}
+
+	nodes := make([]string, len(stacks.Sources))
+	for i, src := range stacks.Sources {
+		nodes[i] = src.FullName
+	}
+	nodes[0] = "" // root is not a real node
+
+	_, legend := report.TextItems(rpt)
+	ui.render(w, req, "stacks", rpt, errList, legend, webArgs{
+		Stacks: template.JS(b),
+		Nodes:  nodes,
+	})
+}
diff --git a/internal/driver/webhtml.go b/internal/driver/webhtml.go
index 94f32e3..55973ff 100644
--- a/internal/driver/webhtml.go
+++ b/internal/driver/webhtml.go
@@ -65,4 +65,7 @@
 	def("sourcelisting", loadFile("html/source.html"))
 	def("plaintext", loadFile("html/plaintext.html"))
 	def("flamegraph", loadFile("html/flamegraph.html"))
+	def("stacks", loadFile("html/stacks.html"))
+	def("stacks_css", loadCSS("html/stacks.css"))
+	def("stacks_js", loadJS("html/stacks.js"))
 }
diff --git a/internal/driver/webui.go b/internal/driver/webui.go
index 0f3e8bf..1fbcdf8 100644
--- a/internal/driver/webui.go
+++ b/internal/driver/webui.go
@@ -86,6 +86,7 @@
 	TextBody    string
 	Top         []report.TextItem
 	FlameGraph  template.JS
+	Stacks      template.JS
 	Configs     []configMenuEntry
 }
 
@@ -107,6 +108,8 @@
 	}
 	ui.help["details"] = "Show information about the profile and this view"
 	ui.help["graph"] = "Display profile as a directed graph"
+	ui.help["flamegraph"] = "Display profile as a flame graph"
+	ui.help["flamegraph2"] = "Display profile as a flame graph (experimental version that can display caller info on selection)"
 	ui.help["reset"] = "Show the entire profile"
 	ui.help["save_config"] = "Save current settings"
 
@@ -125,6 +128,7 @@
 			"/source":       http.HandlerFunc(ui.source),
 			"/peek":         http.HandlerFunc(ui.peek),
 			"/flamegraph":   http.HandlerFunc(ui.flamegraph),
+			"/flamegraph2":  http.HandlerFunc(ui.stackView), // Experimental
 			"/saveconfig":   http.HandlerFunc(ui.saveConfig),
 			"/deleteconfig": http.HandlerFunc(ui.deleteConfig),
 			"/download": http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
diff --git a/internal/driver/webui_test.go b/internal/driver/webui_test.go
index 2010533..2f9b89b 100644
--- a/internal/driver/webui_test.go
+++ b/internal/driver/webui_test.go
@@ -95,13 +95,22 @@
 			// Check d3-flame-graph CSS is included.
 			".d3-flame-graph rect {",
 		}, false},
+		{"/flamegraph2", []string{
+			"File: testbin",
+			// Check that interesting frames are included.
+			`\bF1\b`,
+			`\bF2\b`,
+			// Check new view JS is included.
+			`function stackViewer`,
+			// Check new view CSS is included.
+			"#stack-chart {",
+		}, false},
 	}
 	for _, c := range testcases {
 		if c.needDot && !haveDot {
 			t.Log("skipping", c.path, "since dot (graphviz) does not seem to be installed")
 			continue
 		}
-
 		res, err := http.Get(server.URL + c.path)
 		if err != nil {
 			t.Error("could not fetch", c.path, err)
diff --git a/internal/report/package.go b/internal/report/package.go
new file mode 100644
index 0000000..6d53859
--- /dev/null
+++ b/internal/report/package.go
@@ -0,0 +1,17 @@
+package report
+
+import "regexp"
+
+// pkgRE extracts package name, It looks for the first "." or "::" that occurs
+// after the last "/". (Searching after the last / allows us to correctly handle
+// names that look like "some.url.com/foo.bar".
+var pkgRE = regexp.MustCompile(`^((.*/)?[\w\d_]+)(\.|::)([^/]*)$`)
+
+// packageName returns the package name of the named symbol, or "" if not found.
+func packageName(name string) string {
+	m := pkgRE.FindStringSubmatch(name)
+	if m == nil {
+		return ""
+	}
+	return m[1]
+}
diff --git a/internal/report/package_test.go b/internal/report/package_test.go
new file mode 100644
index 0000000..62e5196
--- /dev/null
+++ b/internal/report/package_test.go
@@ -0,0 +1,52 @@
+package report
+
+import (
+	"testing"
+)
+
+func TestPackageName(t *testing.T) {
+	type testCase struct {
+		name   string
+		expect string
+	}
+
+	for _, c := range []testCase{
+		// Unrecognized packages:
+		{``, ``},
+		{`name`, ``},
+		{`[libjvm.so]`, ``},
+		{`prefix/name/suffix`, ``},
+		{`prefix(a.b.c,x.y.z)`, ``},
+		{`<undefined>.a.b`, ``},
+		{`(a.b)`, ``},
+
+		// C++ symbols:
+		{`Math.number`, `Math`},
+		{`std::vector`, `std`},
+		{`std::internal::vector`, `std`},
+
+		// Java symbols:
+		{`pkg.Class.name`, `pkg`},
+		{`pkg.pkg.Class.name`, `pkg`},
+		{`pkg.Class.name(a.b.c, x.y.z)`, `pkg`},
+		{`pkg.pkg.Class.<init>`, `pkg`},
+		{`pkg.pkg.Class.<init>(a.b.c, x.y.z)`, `pkg`},
+
+		// Go symbols:
+		{`pkg.name`, `pkg`},
+		{`pkg.(*type).name`, `pkg`},
+		{`path/pkg.name`, `path/pkg`},
+		{`path/pkg.(*type).name`, `path/pkg`},
+		{`path/path/pkg.name`, `path/path/pkg`},
+		{`path/path/pkg.(*type).name`, `path/path/pkg`},
+		{`some.url.com/path/pkg.fnID`, `some.url.com/path/pkg`},
+		{`parent-dir/dir/google.golang.org/grpc/transport.NewFramer`, `parent-dir/dir/google.golang.org/grpc/transport`},
+		{`parent-dir/dir/google.golang.org/grpc.(*Server).handleRawConn`, `parent-dir/dir/google.golang.org/grpc`},
+	} {
+		t.Run(c.name, func(t *testing.T) {
+			if got := packageName(c.name); got != c.expect {
+				t.Errorf("packageName(%q) = %#v, expecting %#v", c.name, got, c.expect)
+			}
+		})
+	}
+}
diff --git a/internal/report/report_test.go b/internal/report/report_test.go
index 99e4bc7..9b394fb 100644
--- a/internal/report/report_test.go
+++ b/internal/report/report_test.go
@@ -146,6 +146,7 @@
 	}
 }
 
+// testM contains mappings for fake profiles used in tests.
 var testM = []*profile.Mapping{
 	{
 		ID:              1,
@@ -156,6 +157,7 @@
 	},
 }
 
+// testF contains functions for fake profiles used in tests.
 var testF = []*profile.Function{
 	{
 		ID:       1,
@@ -179,6 +181,7 @@
 	},
 }
 
+// testL contains locations for fake profiles used in tests.
 var testL = []*profile.Location{
 	{
 		ID:      1,
@@ -232,6 +235,29 @@
 	},
 }
 
+// testSample returns a profile sample with specified value and stack.
+// Note: callees come first in sample stacks.
+func testSample(value int64, locs ...*profile.Location) *profile.Sample {
+	return &profile.Sample{
+		Value:    []int64{value},
+		Location: locs,
+	}
+}
+
+// makeTestProfile returns a profile with specified samples that uses testL/testF/testM
+// (defined in report_test.go).
+func makeTestProfile(samples ...*profile.Sample) *profile.Profile {
+	return &profile.Profile{
+		SampleType: []*profile.ValueType{{Type: "samples", Unit: "count"}},
+		Sample:     samples,
+		Location:   testL,
+		Function:   testF,
+		Mapping:    testM,
+	}
+}
+
+// testProfile contains a fake profile used in tests.
+// Various report methods modify profiles so tests should operate on testProfile.Copy().
 var testProfile = &profile.Profile{
 	PeriodType:    &profile.ValueType{Type: "cpu", Unit: "millisecond"},
 	Period:        10,
diff --git a/internal/report/shortnames.go b/internal/report/shortnames.go
new file mode 100644
index 0000000..3d9f3f4
--- /dev/null
+++ b/internal/report/shortnames.go
@@ -0,0 +1,39 @@
+// Copyright 2022 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package report
+
+import (
+	"regexp"
+
+	"github.com/google/pprof/internal/graph"
+)
+
+var sepRE = regexp.MustCompile(`::|\.`)
+
+// shortNameList returns a non-empty sequence of shortened names
+// (in decreasing preference) that can be used to represent name.
+func shortNameList(name string) []string {
+	name = graph.ShortenFunctionName(name)
+	seps := sepRE.FindAllStringIndex(name, -1)
+	result := make([]string, 0, len(seps)+1)
+	result = append(result, name)
+	for _, sep := range seps {
+		// Suffix starting just after sep
+		if sep[1] < len(name) {
+			result = append(result, name[sep[1]:])
+		}
+	}
+	return result
+}
diff --git a/internal/report/shortnames_test.go b/internal/report/shortnames_test.go
new file mode 100644
index 0000000..2119063
--- /dev/null
+++ b/internal/report/shortnames_test.go
@@ -0,0 +1,34 @@
+package report
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestShortNames(t *testing.T) {
+	type testCase struct {
+		name string
+		in   string
+		out  []string
+	}
+	test := func(name, in string, out ...string) testCase {
+		return testCase{name, in, out}
+	}
+
+	for _, c := range []testCase{
+		test("empty", "", ""),
+		test("simple", "foo", "foo"),
+		test("trailingsep", "foo.bar.", "foo.bar.", "bar."),
+		test("cplusplus", "a::b::c", "a::b::c", "b::c", "c"),
+		test("dotted", "a.b.c", "a.b.c", "b.c", "c"),
+		test("mixed_separators", "a::b.c::d", "a::b.c::d", "b.c::d", "c::d", "d"),
+		test("call_operator", "foo::operator()", "foo::operator()", "operator()"),
+	} {
+		t.Run(c.name, func(t *testing.T) {
+			got := shortNameList(c.in)
+			if !reflect.DeepEqual(c.out, got) {
+				t.Errorf("shortNameList(%q) = %#v, expecting %#v", c.in, got, c.out)
+			}
+		})
+	}
+}
diff --git a/internal/report/stacks.go b/internal/report/stacks.go
new file mode 100644
index 0000000..4a5f5c1
--- /dev/null
+++ b/internal/report/stacks.go
@@ -0,0 +1,186 @@
+// Copyright 2022 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package report
+
+import (
+	"crypto/sha256"
+	"encoding/binary"
+	"fmt"
+	"regexp"
+
+	"github.com/google/pprof/internal/measurement"
+	"github.com/google/pprof/profile"
+)
+
+// StackSet holds a set of stacks corresponding to a profile.
+//
+// Slices in StackSet and the types it contains are always non-nil,
+// which makes Javascript code that uses the JSON encoding less error-prone.
+type StackSet struct {
+	Total   int64         // Total value of the profile.
+	Scale   float64       // Multiplier to generate displayed value
+	Type    string        // Profile type. E.g., "cpu".
+	Unit    string        // One of "B", "s", "GCU", or "" (if unknown)
+	Stacks  []Stack       // List of stored stacks
+	Sources []StackSource // Mapping from source index to info
+}
+
+// Stack holds a single stack instance.
+type Stack struct {
+	Value   int64 // Total value for all samples of this stack.
+	Sources []int // Indices in StackSet.Sources (callers before callees).
+}
+
+// StackSource holds function/location info for a stack entry.
+type StackSource struct {
+	FullName   string
+	FileName   string
+	UniqueName string // Disambiguates functions with same names
+
+	// Alternative names to display (with decreasing lengths) to make text fit.
+	// Guaranteed to be non-empty.
+	Display []string
+
+	// Regular expression (anchored) that matches exactly FullName.
+	RE string
+
+	// Places holds the list of stack slots where this source occurs.
+	// In particular, if [a,b] is an element in Places,
+	// StackSet.Stacks[a].Sources[b] points to this source.
+	//
+	// No stack will be referenced twice in the Places slice for a given
+	// StackSource. In case of recursion, Places will contain the outer-most
+	// entry in the recursive stack. E.g., if stack S has source X at positions
+	// 4,6,9,10, the Places entry for X will contain [S,4].
+	Places []StackSlot
+
+	// Combined count of stacks where this source is the leaf.
+	Self int64
+
+	// Color number to use for this source.
+	// Colors with high numbers than supported may be treated as zero.
+	Color int
+}
+
+// StackSlot identifies a particular StackSlot.
+type StackSlot struct {
+	Stack int // Index in StackSet.Stacks
+	Pos   int // Index in Stack.Sources
+}
+
+// Stacks returns a StackSet for the profile in rpt.
+func (rpt *Report) Stacks() StackSet {
+	// Get scale for converting to default unit of the right type.
+	scale, unit := measurement.Scale(1, rpt.options.SampleUnit, "default")
+	if unit == "default" {
+		unit = ""
+	}
+	if rpt.options.Ratio > 0 {
+		scale *= rpt.options.Ratio
+	}
+	s := &StackSet{
+		Total:   rpt.total,
+		Scale:   scale,
+		Type:    rpt.options.SampleType,
+		Unit:    unit,
+		Stacks:  []Stack{},       // Ensure non-nil
+		Sources: []StackSource{}, // Ensure non-nil
+	}
+	s.makeInitialStacks(rpt)
+	s.fillPlaces()
+	s.assignColors()
+	return *s
+}
+
+func (s *StackSet) makeInitialStacks(rpt *Report) {
+	srcs := map[profile.Line]int{} // Sources identified so far.
+	seenFunctions := map[string]bool{}
+	unknownIndex := 1
+	getSrc := func(line profile.Line) int {
+		if i, ok := srcs[line]; ok {
+			return i
+		}
+		x := StackSource{Places: []StackSlot{}} // Ensure Places is non-nil
+		if fn := line.Function; fn != nil {
+			x.FullName = fn.Name
+			x.FileName = fn.Filename
+			if !seenFunctions[fn.Name] {
+				x.UniqueName = fn.Name
+				seenFunctions[fn.Name] = true
+			} else {
+				// Assign a different name so pivoting picks this function.
+				x.UniqueName = fmt.Sprint(fn.Name, "#", fn.ID)
+			}
+		} else {
+			x.FullName = fmt.Sprintf("?%d?", unknownIndex)
+			x.UniqueName = x.FullName
+			unknownIndex++
+		}
+		x.RE = "^" + regexp.QuoteMeta(x.UniqueName) + "$"
+		x.Display = shortNameList(x.FullName)
+		s.Sources = append(s.Sources, x)
+		srcs[line] = len(s.Sources) - 1
+		return len(s.Sources) - 1
+	}
+
+	// Synthesized root location that will be placed at the beginning of each stack.
+	s.Sources = []StackSource{{
+		FullName: "root",
+		Display:  []string{"root"},
+		Places:   []StackSlot{},
+	}}
+
+	for _, sample := range rpt.prof.Sample {
+		value := rpt.options.SampleValue(sample.Value)
+		stack := Stack{Value: value, Sources: []int{0}} // Start with the root
+
+		// Note: we need to reverse the order in the produced stack.
+		for i := len(sample.Location) - 1; i >= 0; i-- {
+			loc := sample.Location[i]
+			for j := len(loc.Line) - 1; j >= 0; j-- {
+				line := loc.Line[j]
+				stack.Sources = append(stack.Sources, getSrc(line))
+			}
+		}
+
+		leaf := stack.Sources[len(stack.Sources)-1]
+		s.Sources[leaf].Self += value
+		s.Stacks = append(s.Stacks, stack)
+	}
+}
+
+func (s *StackSet) fillPlaces() {
+	for i, stack := range s.Stacks {
+		seenSrcs := map[int]bool{}
+		for j, src := range stack.Sources {
+			if seenSrcs[src] {
+				continue
+			}
+			seenSrcs[src] = true
+			s.Sources[src].Places = append(s.Sources[src].Places, StackSlot{i, j})
+		}
+	}
+}
+
+func (s *StackSet) assignColors() {
+	// Assign different color indices to different packages.
+	const numColors = 1048576
+	for i, src := range s.Sources {
+		pkg := packageName(src.FullName)
+		h := sha256.Sum256([]byte(pkg))
+		index := binary.LittleEndian.Uint32(h[:])
+		s.Sources[i].Color = int(index % numColors)
+	}
+}
diff --git a/internal/report/stacks_test.go b/internal/report/stacks_test.go
new file mode 100644
index 0000000..ecccd88
--- /dev/null
+++ b/internal/report/stacks_test.go
@@ -0,0 +1,162 @@
+package report
+
+import (
+	"fmt"
+	"reflect"
+	"testing"
+
+	"github.com/google/pprof/profile"
+)
+
+// makeTestStacks generates a StackSet from a supplied list of samples.
+func makeTestStacks(samples ...*profile.Sample) StackSet {
+	prof := makeTestProfile(samples...)
+	rpt := NewDefault(prof, Options{OutputFormat: Tree, CallTree: true})
+	return rpt.Stacks()
+}
+
+func TestStacks(t *testing.T) {
+	// See report_test.go for the functions available to use in tests.
+	main, foo, bar, tee := testL[0], testL[1], testL[2], testL[3]
+
+	// stack holds an expected stack value found in StackSet.
+	type stack struct {
+		value int64
+		names []string
+	}
+	makeStack := func(value int64, names ...string) stack {
+		return stack{value, names}
+	}
+
+	for _, c := range []struct {
+		name   string
+		stacks StackSet
+		expect []stack
+	}{
+		{
+			"simple",
+			makeTestStacks(
+				testSample(100, bar, foo, main),
+				testSample(200, tee, foo, main),
+			),
+			[]stack{
+				makeStack(100, "0:root", "1:main", "2:foo", "3:bar"),
+				makeStack(200, "0:root", "1:main", "2:foo", "4:tee"),
+			},
+		},
+		{
+			"recursion",
+			makeTestStacks(
+				testSample(100, bar, foo, foo, foo, main),
+				testSample(200, bar, foo, foo, main),
+			),
+			[]stack{
+				// Note: Recursive calls to foo have different source indices.
+				makeStack(100, "0:root", "1:main", "2:foo", "2:foo", "2:foo", "3:bar"),
+				makeStack(200, "0:root", "1:main", "2:foo", "2:foo", "3:bar"),
+			},
+		},
+	} {
+		t.Run(c.name, func(t *testing.T) {
+			var got []stack
+			for _, s := range c.stacks.Stacks {
+				stk := stack{
+					value: s.Value,
+					names: make([]string, len(s.Sources)),
+				}
+				for i, src := range s.Sources {
+					stk.names[i] = fmt.Sprint(src, ":", c.stacks.Sources[src].FullName)
+				}
+				got = append(got, stk)
+			}
+			if !reflect.DeepEqual(c.expect, got) {
+				t.Errorf("expecting source %+v, got %+v", c.expect, got)
+			}
+		})
+	}
+}
+
+func TestStackSources(t *testing.T) {
+	// See report_test.go for the functions available to use in tests.
+	main, foo, bar, tee := testL[0], testL[1], testL[2], testL[3]
+
+	type srcInfo struct {
+		name string
+		self int64
+	}
+
+	source := func(stacks StackSet, name string) srcInfo {
+		src := findSource(stacks, name)
+		return srcInfo{src.FullName, src.Self}
+	}
+
+	for _, c := range []struct {
+		name   string
+		stacks StackSet
+		srcs   []srcInfo
+	}{
+		{
+			"empty",
+			makeTestStacks(),
+			[]srcInfo{},
+		},
+		{
+			"two-leaves",
+			makeTestStacks(
+				testSample(100, bar, foo, main),
+				testSample(200, tee, bar, foo, main),
+				testSample(1000, tee, main),
+			),
+			[]srcInfo{
+				{"main", 0},
+				{"bar", 100},
+				{"foo", 0},
+				{"tee", 1200},
+			},
+		},
+		{
+			"recursion",
+			makeTestStacks(
+				testSample(100, foo, foo, foo, main),
+				testSample(100, foo, foo, main),
+			),
+			[]srcInfo{
+				{"main", 0},
+				{"foo", 200},
+			},
+		},
+		{
+			"flat",
+			makeTestStacks(
+				testSample(100, main),
+				testSample(100, foo),
+				testSample(100, bar),
+				testSample(100, tee),
+			),
+			[]srcInfo{
+				{"main", 100},
+				{"bar", 100},
+				{"foo", 100},
+				{"tee", 100},
+			},
+		},
+	} {
+		t.Run(c.name, func(t *testing.T) {
+			for _, expect := range c.srcs {
+				got := source(c.stacks, expect.name)
+				if !reflect.DeepEqual(expect, got) {
+					t.Errorf("expecting source %+v, got %+v", expect, got)
+				}
+			}
+		})
+	}
+}
+
+func findSource(stacks StackSet, name string) StackSource {
+	for _, src := range stacks.Sources {
+		if src.FullName == name {
+			return src
+		}
+	}
+	return StackSource{}
+}