Repository: jlfwong/speedscope
Pull request #73 (2018-06-25 07:54:19)

Upgrade the "Table view" to a "Sandwich" view
![image](https://user-images.githubusercontent.com/150329/41837387-25417bae-7812-11e8-83cb-d3e6782b734e.png)

This provides information about the caller & callees of individual functions selected in the table view.

coveralls: ## Pull Request Test Coverage Report for [Build 149](https://coveralls.io/builds/17669677)

* **39** of **156**   **(25.0%)**  changed or added relevant lines in **8** files are covered.
* **4** unchanged lines in **4** files lost coverage.
* Overall coverage decreased (**-0.7%**) to **35.97%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [flamechart-minimap-view.tsx](https://coveralls.io/builds/17669677/source?filename=flamechart-minimap-view.tsx#L8) | 0 | 1 | 0.0%
| [style.ts](https://coveralls.io/builds/17669677/source?filename=style.ts#L1) | 0 | 2 | 0.0%
| [profile-table-view.tsx](https://coveralls.io/builds/17669677/source?filename=profile-table-view.tsx#L6) | 0 | 5 | 0.0%
| [flamechart-renderer.ts](https://coveralls.io/builds/17669677/source?filename=flamechart-renderer.ts#L137) | 0 | 8 | 0.0%
| [application.tsx](https://coveralls.io/builds/17669677/source?filename=application.tsx#L24) | 0 | 13 | 0.0%
| [flamechart-view.tsx](https://coveralls.io/builds/17669677/source?filename=flamechart-view.tsx#L13) | 0 | 18 | 0.0%
| [sandwich-view.tsx](https://coveralls.io/builds/17669677/source?filename=sandwich-view.tsx#L1) | 0 | 70 | 0.0%
<!-- | **Total:** | **39** | **156** | **25.0%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [profile-table-view.tsx](https://coveralls.io/builds/17669677/source?filename=profile-table-view.tsx#L75) | 1 | 0.0% |
| [application.tsx](https://coveralls.io/builds/17669677/source?filename=application.tsx#L703) | 1 | 0.0% |
| [flamechart-renderer.ts](https://coveralls.io/builds/17669677/source?filename=flamechart-renderer.ts#L266) | 1 | 0.0% |
| [flamechart-view.tsx](https://coveralls.io/builds/17669677/source?filename=flamechart-view.tsx#L408) | 1 | 0.0% |
<!-- | **Total:** | **4** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/17669677/badge)](https://coveralls.io/builds/17669677) |
| :-- | --: |
| Change from base [Build 141](https://coveralls.io/builds/17629485): |  -0.7% |
| Covered Lines: | 1044 |
| Relevant Lines: | 2753 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/README.md b/README.md
index 5491753..8f5b51c 100644
--- a/README.md
+++ b/README.md
@@ -49,21 +49,27 @@ To load a specific profile by URL, you can append a hash fragment like `#profile
 
 ## Views
 
-Both of the main views of the applications display flame graphs.
-
-In this view, the horizontal axis represents the "weight" of each stack (most commonly CPU time), and the vertical axis shows you the stack active at the time of the sample.
-
 ### 🕰Time Order
 
 In the "Time Order" view (the default), the stacks are ordered left-to-right in the same order as the occurred in the input file, which is usually going to be the chronological order they were recorded in. This view is most helpful for understand the behavior of an application over time, e.g. "first the data is fetched from the database, then the data is prepared for serialization, then the data is serialized to JSON". This is the only flame graph order supported by Chrome developer tools.
 
+In all flamegraph views, the horizontal axis represents the "weight" of each stack (most commonly CPU time), and the vertical axis shows you the stack active at the time of the sample.
+
+If you click on one of the frames, you'll be able to see summary statistics about it.
+
+![Detail View](https://user-images.githubusercontent.com/150329/42108613-e6ef6d3a-7b8f-11e8-93d4-541b2cb93fe5.png)
+
 ### ⬅️Left Heavy
 
 In the "Left Heavy" view, identical stacks are grouped together, regardless of whether they were recorded sequentially. Then, the stacks are sorted so that the heaviest stack for each parent is on the left -- hence "left heavy". This view is useful for understanding where all the time is going in situations where there are hundreds or thousands of function calls interleaved between other call stacks.
 
-### 📒Table View
+### 🥪 Sandwich
+
+The Sandwich view is a table view in which you can find a list of all functions an their associated times. You can sort by self time or total time.
+It's called "Sandwich" view because if you select one of the rows in the table, you can see flamegraphs for all the callers and callees of the selected
+row.
 
-In this "Table View", you can find a list of all functions an their associated times. You can sort by self time or total time.
+![Sandwich View](https://user-images.githubusercontent.com/150329/42108467-76a57baa-7b8f-11e8-815f-1df7b6ac3ede.png)
 
 ## Navigation
 
@@ -80,6 +86,7 @@ Once a profile has loaded, the main view is split into two: the top area is the
 * Pinch to zoom
 * Hold Cmd+Scroll to zoom
 * Double click on a frame to fit the viewport to it
+* Click on a frame to view summary statistics about it
 
 ### Keyboard Navigation
 
@@ -89,5 +96,5 @@ Once a profile has loaded, the main view is split into two: the top area is the
 * `w`/`a`/`s`/`d` or arrow keys: pan around the profile
 * `1`: Switch to the "Time Order" view
 * `2`: Switch to the "Left Heavy" view
-* `3`: Switch to the table view
+* `3`: Switch to the "Sandwich" view
 * `r`: Collapse recursion in the flamegraphs
diff --git a/application.tsx b/application.tsx
index 992a6eb..52d7cc3 100644
--- a/application.tsx
+++ b/application.tsx
@@ -21,11 +21,12 @@ import {Flamechart} from './flamechart'
 import {FlamechartView} from './flamechart-view'
 import {FontFamily, FontSize, Colors, Sizes} from './style'
 import {getHashParams, HashParams} from './hash-params'
-import {ProfileTableView, SortMethod, SortField, SortDirection} from './profile-table-view'
+import {SortMethod, SortField, SortDirection} from './profile-table-view'
 import {triangle} from './utils'
 import {Color} from './color'
 import {RowAtlas} from './row-atlas'
 import {importAsmJsSymbolMap} from './asm-js'
+import {SandwichView} from './sandwich-view'
 
 declare function require(x: string): any
 const exampleProfileURL = require('./sample/profiles/stackcollapse/perf-vertx-stacks-01-collapsed-all.txt')
@@ -33,7 +34,7 @@ const exampleProfileURL = require('./sample/profiles/stackcollapse/perf-vertx-st
 const enum ViewMode {
   CHRONO_FLAME_CHART,
   LEFT_HEAVY_FLAME_GRAPH,
-  TABLE_VIEW,
+  SANDWICH_VIEW,
 }
 
 interface ApplicationState {
@@ -130,8 +131,8 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
     this.props.setViewMode(ViewMode.LEFT_HEAVY_FLAME_GRAPH)
   }
 
-  setTableView = () => {
-    this.props.setViewMode(ViewMode.TABLE_VIEW)
+  setSandwichView = () => {
+    this.props.setViewMode(ViewMode.SANDWICH_VIEW)
   }
 
   render() {
@@ -179,11 +180,11 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
           <div
             className={css(
               style.toolbarTab,
-              this.props.viewMode === ViewMode.TABLE_VIEW && style.toolbarTabActive,
+              this.props.viewMode === ViewMode.SANDWICH_VIEW && style.toolbarTabActive,
             )}
-            onClick={this.setTableView}
+            onClick={this.setSandwichView}
           >
-            <span className={css(style.emoji)}>📒</span>Table View
+            <span className={css(style.emoji)}>🥪</span>Sandwich
           </div>
           {help}
         </div>
@@ -480,7 +481,7 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
     ev.preventDefault()
   }
 
-  onWindowKeyPress = (ev: KeyboardEvent) => {
+  onWindowKeyPress = async (ev: KeyboardEvent) => {
     if (ev.key === '1') {
       this.setState({
         viewMode: ViewMode.CHRONO_FLAME_CHART,
@@ -491,16 +492,16 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
       })
     } else if (ev.key === '3') {
       this.setState({
-        viewMode: ViewMode.TABLE_VIEW,
+        viewMode: ViewMode.SANDWICH_VIEW,
       })
     } else if (ev.key === 'r') {
       const {flattenRecursion, profile} = this.state
       if (!profile) return
       if (flattenRecursion) {
-        this.setActiveProfile(profile)
+        await this.setActiveProfile(profile)
         this.setState({flattenRecursion: false})
       } else {
-        this.setActiveProfile(profile.flattenRecursion())
+        await this.setActiveProfile(profile.getProfileWithRecursionFlattened())
         this.setState({flattenRecursion: true})
       }
     }
@@ -649,6 +650,12 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
     }
   }
 
+  getColorBucketForFrame = (frame: Frame): number => {
+    const {chronoFlamechart} = this.state
+    if (!chronoFlamechart) return 0
+    return chronoFlamechart.getColorBucketForFrame(frame)
+  }
+
   getCSSColorForFrame = (frame: Frame): string => {
     const {chronoFlamechart} = this.state
     if (!chronoFlamechart) return '#FFFFFF'
@@ -710,13 +717,18 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
           />
         )
       }
-      case ViewMode.TABLE_VIEW: {
+      case ViewMode.SANDWICH_VIEW: {
+        if (!this.rowAtlas || !this.state.profile) return null
         return (
-          <ProfileTableView
-            profile={this.state.activeProfile}
+          <SandwichView
+            profile={this.state.profile}
+            flattenRecursion={this.state.flattenRecursion}
+            getColorBucketForFrame={this.getColorBucketForFrame}
             getCSSColorForFrame={this.getCSSColorForFrame}
             sortMethod={this.state.tableSortMethod}
             setSortMethod={this.setTableSortMethod}
+            canvasContext={this.canvasContext}
+            rowAtlas={this.rowAtlas}
           />
         )
       }
diff --git a/flamechart-detail-view.tsx b/flamechart-detail-view.tsx
new file mode 100644
index 0000000..01aac64
--- /dev/null
+++ b/flamechart-detail-view.tsx
@@ -0,0 +1,123 @@
+import {StyleDeclarationValue, css} from 'aphrodite'
+import {ReloadableComponent} from './reloadable'
+import {h} from 'preact'
+import {style} from './flamechart-style'
+import {formatPercent} from './utils'
+import {Frame, CallTreeNode} from './profile'
+import {ColorChit} from './color-chit'
+import {Flamechart} from './flamechart'
+
+interface StatisticsTableProps {
+  title: string
+  grandTotal: number
+  selectedTotal: number
+  selectedSelf: number
+  cellStyle: StyleDeclarationValue
+  formatter: (v: number) => string
+}
+
+class StatisticsTable extends ReloadableComponent<StatisticsTableProps, {}> {
+  render() {
+    const total = this.props.formatter(this.props.selectedTotal)
+    const self = this.props.formatter(this.props.selectedSelf)
+    const totalPerc = 100.0 * this.props.selectedTotal / this.props.grandTotal
+    const selfPerc = 100.0 * this.props.selectedSelf / this.props.grandTotal
+
+    return (
+      <div className={css(style.statsTable)}>
+        <div className={css(this.props.cellStyle, style.statsTableCell, style.statsTableHeader)}>
+          {this.props.title}
+        </div>
+
+        <div className={css(this.props.cellStyle, style.statsTableCell)}>Total</div>
+        <div className={css(this.props.cellStyle, style.statsTableCell)}>Self</div>
+
+        <div className={css(this.props.cellStyle, style.statsTableCell)}>{total}</div>
+        <div className={css(this.props.cellStyle, style.statsTableCell)}>{self}</div>
+
+        <div className={css(this.props.cellStyle, style.statsTableCell)}>
+          {formatPercent(totalPerc)}
+          <div className={css(style.barDisplay)} style={{height: `${totalPerc}%`}} />
+        </div>
+        <div className={css(this.props.cellStyle, style.statsTableCell)}>
+          {formatPercent(selfPerc)}
+          <div className={css(style.barDisplay)} style={{height: `${selfPerc}%`}} />
+        </div>
+      </div>
+    )
+  }
+}
+
+interface StackTraceViewProps {
+  getFrameColor: (frame: Frame) => string
+  node: CallTreeNode
+}
+class StackTraceView extends ReloadableComponent<StackTraceViewProps, {}> {
+  render() {
+    const rows: JSX.Element[] = []
+    let node: CallTreeNode | null = this.props.node
+    for (; node && !node.isRoot(); node = node.parent) {
+      const row: (JSX.Element | string)[] = []
+      const {frame} = node
+
+      row.push(<ColorChit color={this.props.getFrameColor(frame)} />)
+
+      if (rows.length) {
+        row.push(<span className={css(style.stackFileLine)}>> </span>)
+      }
+      row.push(frame.name)
+
+      if (frame.file) {
+        let pos = frame.file
+        if (frame.line) {
+          pos += `:${frame.line}`
+          if (frame.col) {
+            pos += `:${frame.col}`
+          }
+        }
+        row.push(<span className={css(style.stackFileLine)}> ({pos})</span>)
+      }
+      rows.push(<div className={css(style.stackLine)}>{row}</div>)
+    }
+    return (
+      <div className={css(style.stackTraceView)}>
+        <div className={css(style.stackTraceViewPadding)}>{rows}</div>
+      </div>
+    )
+  }
+}
+
+interface FlamechartDetailViewProps {
+  flamechart: Flamechart
+  getCSSColorForFrame: (frame: Frame) => string
+  selectedNode: CallTreeNode
+}
+
+export class FlamechartDetailView extends ReloadableComponent<FlamechartDetailViewProps, {}> {
+  render() {
+    const {flamechart, selectedNode} = this.props
+    const {frame} = selectedNode
+
+    return (
+      <div className={css(style.detailView)}>
+        <StatisticsTable
+          title={'This Instance'}
+          cellStyle={style.thisInstanceCell}
+          grandTotal={flamechart.getTotalWeight()}
+          selectedTotal={selectedNode.getTotalWeight()}
+          selectedSelf={selectedNode.getSelfWeight()}
+          formatter={flamechart.formatValue.bind(flamechart)}
+        />
+        <StatisticsTable
+          title={'All Instances'}
+          cellStyle={style.allInstancesCell}
+          grandTotal={flamechart.getTotalWeight()}
+          selectedTotal={frame.getTotalWeight()}
+          selectedSelf={frame.getSelfWeight()}
+          formatter={flamechart.formatValue.bind(flamechart)}
+        />
+        <StackTraceView node={selectedNode} getFrameColor={this.props.getCSSColorForFrame} />
+      </div>
+    )
+  }
+}
diff --git a/flamechart-minimap-view.tsx b/flamechart-minimap-view.tsx
index ec9e4bb..6e6050b 100644
--- a/flamechart-minimap-view.tsx
+++ b/flamechart-minimap-view.tsx
@@ -3,11 +3,11 @@ import {css} from 'aphrodite'
 import {Flamechart} from './flamechart'
 import {Rect, Vec2, AffineTransform, clamp} from './math'
 import {FlamechartRenderer} from './flamechart-renderer'
-import {cachedMeasureTextWidth} from './utils'
 import {style} from './flamechart-style'
-import {FontFamily, FontSize, Colors, Sizes} from './style'
+import {FontFamily, FontSize, Colors, Sizes, commonStyle} from './style'
 import {CanvasContext} from './canvas-context'
 import {TextureCachedRenderer} from './texture-cached-renderer'
+import {cachedMeasureTextWidth} from './text-utils'
 
 interface FlamechartMinimapViewProps {
   flamechart: Flamechart
@@ -444,7 +444,7 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
         onWheel={this.onWheel}
         onMouseDown={this.onMouseDown}
         onMouseMove={this.onMouseMove}
-        className={css(style.minimap, style.vbox)}
+        className={css(style.minimap, commonStyle.vbox)}
       >
         <canvas width={1} height={1} ref={this.overlayCanvasRef} className={css(style.fill)} />
       </div>
diff --git a/flamechart-pan-zoom-view.tsx b/flamechart-pan-zoom-view.tsx
new file mode 100644
index 0000000..ac9ab96
--- /dev/null
+++ b/flamechart-pan-zoom-view.tsx
@@ -0,0 +1,697 @@
+import {Rect, AffineTransform, Vec2, clamp} from './math'
+import {CallTreeNode} from './profile'
+import {Flamechart, FlamechartFrame} from './flamechart'
+import {CanvasContext} from './canvas-context'
+import {FlamechartRenderer} from './flamechart-renderer'
+import {ReloadableComponent} from './reloadable'
+import {Sizes, FontSize, Colors, FontFamily, commonStyle} from './style'
+import {cachedMeasureTextWidth, ELLIPSIS, trimTextMid} from './text-utils'
+import {style} from './flamechart-style'
+import {h} from 'preact'
+import {css} from 'aphrodite'
+
+interface FlamechartFrameLabel {
+  configSpaceBounds: Rect
+  node: CallTreeNode
+}
+
+/**
+ * Component to visualize a Flamechart and interact with it via hovering,
+ * zooming, and panning.
+ *
+ * There are 3 vector spaces involved:
+ * - Configuration Space: In this space, the horizontal unit is ms, and the
+ *   vertical unit is stack depth. Each stack frame is one unit high.
+ * - Logical view space: Origin is top-left, with +y downwards. This represents
+ *   the coordinate space of the view as specified in CSS: horizontal and vertical
+ *   units are both "logical" pixels.
+ * - Physical view space: Origin is top-left, with +y downwards. This represents
+ *   the coordinate space of the view as specified in hardware pixels: horizontal
+ *   and vertical units are both "physical" pixels.
+ *
+ * We use two canvases to draw the flamechart itself: one for the rectangles,
+ * which we render via WebGL, and one for the labels, which we render via 2D
+ * canvas primitives.
+ */
+export interface FlamechartPanZoomViewProps {
+  flamechart: Flamechart
+  canvasContext: CanvasContext
+  flamechartRenderer: FlamechartRenderer
+  renderInverted: boolean
+  selectedNode: CallTreeNode | null
+
+  onNodeHover: (hover: {node: CallTreeNode; event: MouseEvent} | null) => void
+  onNodeSelect: (node: CallTreeNode | null) => void
+  configSpaceViewportRect: Rect
+  transformViewport: (transform: AffineTransform) => void
+  setConfigSpaceViewportRect: (rect: Rect) => void
+}
+
+export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoomViewProps, {}> {
+  private container: Element | null = null
+  private containerRef = (element?: Element) => {
+    this.container = element || null
+  }
+
+  private overlayCanvas: HTMLCanvasElement | null = null
+  private overlayCtx: CanvasRenderingContext2D | null = null
+
+  private hoveredLabel: FlamechartFrameLabel | null = null
+
+  private setConfigSpaceViewportRect(r: Rect) {
+    this.props.setConfigSpaceViewportRect(r)
+  }
+
+  private overlayCanvasRef = (element?: Element) => {
+    if (element) {
+      this.overlayCanvas = element as HTMLCanvasElement
+      this.overlayCtx = this.overlayCanvas.getContext('2d')
+      this.renderCanvas()
+    } else {
+      this.overlayCanvas = null
+      this.overlayCtx = null
+    }
+  }
+
+  private configSpaceSize() {
+    return new Vec2(
+      this.props.flamechart.getTotalWeight(),
+      this.props.flamechart.getLayers().length,
+    )
+  }
+
+  private physicalViewSize() {
+    return new Vec2(
+      this.overlayCanvas ? this.overlayCanvas.width : 0,
+      this.overlayCanvas ? this.overlayCanvas.height : 0,
+    )
+  }
+
+  private physicalBounds(): Rect {
+    if (this.props.renderInverted) {
+      // If we're rendering inverted and the flamegraph won't fill the viewport,
+      // we want to stick the flamegraph to the bottom of the viewport, not the top.
+
+      const physicalViewportHeight = this.physicalViewSize().y
+      const physicalFlamegraphHeight =
+        (this.configSpaceSize().y + 1) *
+        this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT *
+        window.devicePixelRatio
+
+      if (physicalFlamegraphHeight < physicalViewportHeight) {
+        return new Rect(
+          new Vec2(0, physicalViewportHeight - physicalFlamegraphHeight),
+          this.physicalViewSize(),
+        )
+      }
+    }
+
+    return new Rect(new Vec2(0, 0), this.physicalViewSize())
+  }
+
+  private LOGICAL_VIEW_SPACE_FRAME_HEIGHT = Sizes.FRAME_HEIGHT
+
+  private configSpaceToPhysicalViewSpace() {
+    return AffineTransform.betweenRects(this.props.configSpaceViewportRect, this.physicalBounds())
+  }
+
+  private logicalToPhysicalViewSpace() {
+    return AffineTransform.withScale(new Vec2(window.devicePixelRatio, window.devicePixelRatio))
+  }
+
+  private resizeOverlayCanvasIfNeeded() {
+    if (!this.overlayCanvas) return
+    let {width, height} = this.overlayCanvas.getBoundingClientRect()
+    {
+      /*
+      We render text at a higher resolution then scale down to
+      ensure we're rendering at 1:1 device pixel ratio.
+      This ensures our text is rendered crisply.
+    */
+    }
+    width = Math.floor(width)
+    height = Math.floor(height)
+
+    // Still initializing: don't resize yet
+    if (width === 0 || height === 0) return
+
+    const scaledWidth = width * window.devicePixelRatio
+    const scaledHeight = height * window.devicePixelRatio
+
+    if (scaledWidth === this.overlayCanvas.width && scaledHeight === this.overlayCanvas.height)
+      return
+
+    this.overlayCanvas.width = scaledWidth
+    this.overlayCanvas.height = scaledHeight
+  }
+
+  private renderOverlays() {
+    const ctx = this.overlayCtx
+    if (!ctx) return
+    this.resizeOverlayCanvasIfNeeded()
+    if (this.props.configSpaceViewportRect.isEmpty()) return
+
+    const configToPhysical = this.configSpaceToPhysicalViewSpace()
+
+    const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
+    const physicalViewSpaceFrameHeight =
+      this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT * window.devicePixelRatio
+
+    const physicalViewSize = this.physicalViewSize()
+
+    ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
+
+    if (this.hoveredLabel) {
+      let color = Colors.DARK_GRAY
+      if (this.props.selectedNode === this.hoveredLabel.node) {
+        color = Colors.DARK_BLUE
+      }
+
+      ctx.lineWidth = 2 * devicePixelRatio
+      ctx.strokeStyle = color
+
+      const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
+      ctx.strokeRect(
+        Math.round(physicalViewBounds.left()),
+        Math.round(physicalViewBounds.top()),
+        Math.round(Math.max(0, physicalViewBounds.width())),
+        Math.round(Math.max(0, physicalViewBounds.height())),
+      )
+    }
+
+    ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${
+      FontFamily.MONOSPACE
+    }`
+    ctx.textBaseline = 'alphabetic'
+    ctx.fillStyle = Colors.DARK_GRAY
+
+    const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M')
+    const minConfigSpaceWidthToRender = (
+      configToPhysical.inverseTransformVector(new Vec2(minWidthToRender, 0)) || new Vec2(0, 0)
+    ).x
+
+    const LABEL_PADDING_PX = 5 * window.devicePixelRatio
+
+    const renderFrameLabelAndChildren = (frame: FlamechartFrame, depth = 0) => {
+      const width = frame.end - frame.start
+      const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
+      const configSpaceBounds = new Rect(new Vec2(frame.start, y), new Vec2(width, 1))
+
+      if (width < minConfigSpaceWidthToRender) return
+      if (configSpaceBounds.left() > this.props.configSpaceViewportRect.right()) return
+      if (configSpaceBounds.right() < this.props.configSpaceViewportRect.left()) return
+
+      if (this.props.renderInverted) {
+        if (configSpaceBounds.bottom() < this.props.configSpaceViewportRect.top()) return
+      } else {
+        if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return
+      }
+
+      if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) {
+        let physicalLabelBounds = configToPhysical.transformRect(configSpaceBounds)
+
+        if (physicalLabelBounds.left() < 0) {
+          physicalLabelBounds = physicalLabelBounds
+            .withOrigin(physicalLabelBounds.origin.withX(0))
+            .withSize(
+              physicalLabelBounds.size.withX(
+                physicalLabelBounds.size.x + physicalLabelBounds.left(),
+              ),
+            )
+        }
+        if (physicalLabelBounds.right() > physicalViewSize.x) {
+          physicalLabelBounds = physicalLabelBounds.withSize(
+            physicalLabelBounds.size.withX(physicalViewSize.x - physicalLabelBounds.left()),
+          )
+        }
+
+        const trimmedText = trimTextMid(
+          ctx,
+          frame.node.frame.name,
+          physicalLabelBounds.width() - 2 * LABEL_PADDING_PX,
+        )
+
+        // Note that this is specifying the position of the starting text
+        // baseline.
+        ctx.fillText(
+          trimmedText,
+          physicalLabelBounds.left() + LABEL_PADDING_PX,
+          Math.round(
+            physicalLabelBounds.bottom() -
+              (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2,
+          ),
+        )
+      }
+      for (let child of frame.children) {
+        renderFrameLabelAndChildren(child, depth + 1)
+      }
+    }
+
+    for (let frame of this.props.flamechart.getLayers()[0] || []) {
+      renderFrameLabelAndChildren(frame)
+    }
+
+    const frameOutlineWidth = 2 * window.devicePixelRatio
+    ctx.strokeStyle = Colors.PALE_DARK_BLUE
+    ctx.lineWidth = frameOutlineWidth
+    const minConfigSpaceWidthToRenderOutline = (
+      configToPhysical.inverseTransformVector(new Vec2(1, 0)) || new Vec2(0, 0)
+    ).x
+    const renderIndirectlySelectedFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
+      if (!this.props.selectedNode) return
+      const width = frame.end - frame.start
+      const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
+      const configSpaceBounds = new Rect(new Vec2(frame.start, y), new Vec2(width, 1))
+
+      if (width < minConfigSpaceWidthToRenderOutline) return
+      if (configSpaceBounds.left() > this.props.configSpaceViewportRect.right()) return
+      if (configSpaceBounds.right() < this.props.configSpaceViewportRect.left()) return
+      if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return
+
+      if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) {
+        const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
+
+        if (frame.node.frame === this.props.selectedNode.frame) {
+          if (frame.node === this.props.selectedNode) {
+            if (ctx.strokeStyle !== Colors.DARK_BLUE) {
+              ctx.stroke()
+              ctx.beginPath()
+              ctx.strokeStyle = Colors.DARK_BLUE
+            }
+          } else {
+            if (ctx.strokeStyle !== Colors.PALE_DARK_BLUE) {
+              ctx.stroke()
+              ctx.beginPath()
+              ctx.strokeStyle = Colors.PALE_DARK_BLUE
+            }
+          }
+
+          // Identify the flamechart frames with a function that matches the
+          // selected flamechart frame.
+          ctx.rect(
+            Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
+            Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
+            Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
+            Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)),
+          )
+        }
+      }
+
+      for (let child of frame.children) {
+        renderIndirectlySelectedFrameOutlines(child, depth + 1)
+      }
+    }
+
+    ctx.beginPath()
+    for (let frame of this.props.flamechart.getLayers()[0] || []) {
+      renderIndirectlySelectedFrameOutlines(frame)
+    }
+    ctx.stroke()
+
+    this.renderTimeIndicators()
+  }
+
+  private renderTimeIndicators() {
+    const ctx = this.overlayCtx
+    if (!ctx) return
+
+    const physicalViewSpaceFrameHeight =
+      this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT * window.devicePixelRatio
+    const physicalViewSize = this.physicalViewSize()
+    const configToPhysical = this.configSpaceToPhysicalViewSpace()
+    const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
+    const labelPaddingPx = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
+
+    const left = this.props.configSpaceViewportRect.left()
+    const right = this.props.configSpaceViewportRect.right()
+    // We want about 10 gridlines to be visible, and want the unit to be
+    // 1eN, 2eN, or 5eN for some N
+    // Ideally, we want an interval every 100 logical screen pixels
+    const logicalToConfig = (
+      this.configSpaceToPhysicalViewSpace().inverted() || new AffineTransform()
+    ).times(this.logicalToPhysicalViewSpace())
+    const targetInterval = logicalToConfig.transformVector(new Vec2(200, 1)).x
+    const minInterval = Math.pow(10, Math.floor(Math.log10(targetInterval)))
+    let interval = minInterval
+    if (targetInterval / interval > 5) {
+      interval *= 5
+    } else if (targetInterval / interval > 2) {
+      interval *= 2
+    }
+
+    {
+      const y = this.props.renderInverted ? physicalViewSize.y - physicalViewSpaceFrameHeight : 0
+
+      ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
+      ctx.fillRect(0, y, physicalViewSize.x, physicalViewSpaceFrameHeight)
+      ctx.fillStyle = Colors.DARK_GRAY
+      ctx.textBaseline = 'top'
+      for (let x = Math.ceil(left / interval) * interval; x < right; x += interval) {
+        // TODO(jlfwong): Ensure that labels do not overlap
+        const pos = Math.round(configToPhysical.transformPosition(new Vec2(x, 0)).x)
+        const labelText = this.props.flamechart.formatValue(x)
+        const textWidth = cachedMeasureTextWidth(ctx, labelText)
+        ctx.fillText(labelText, pos - textWidth - labelPaddingPx, y + labelPaddingPx)
+        ctx.fillRect(pos, 0, 1, physicalViewSize.y)
+      }
+    }
+  }
+
+  private lastBounds: ClientRect | null = null
+  private updateConfigSpaceViewport() {
+    if (!this.container) return
+    const bounds = this.container.getBoundingClientRect()
+    const {width, height} = bounds
+
+    // Still initializing: don't resize yet
+    if (width < 2 || height < 2) return
+
+    if (this.lastBounds == null) {
+      const configSpaceViewportHeight = height / this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT
+      if (this.props.renderInverted) {
+        this.setConfigSpaceViewportRect(
+          new Rect(
+            new Vec2(0, this.configSpaceSize().y - configSpaceViewportHeight + 1),
+            new Vec2(this.configSpaceSize().x, configSpaceViewportHeight),
+          ),
+        )
+      } else {
+        this.setConfigSpaceViewportRect(
+          new Rect(new Vec2(0, -1), new Vec2(this.configSpaceSize().x, configSpaceViewportHeight)),
+        )
+      }
+    } else if (this.lastBounds.width !== width || this.lastBounds.height !== height) {
+      // Resize the viewport rectangle to match the window size aspect
+      // ratio.
+      this.setConfigSpaceViewportRect(
+        this.props.configSpaceViewportRect.withSize(
+          this.props.configSpaceViewportRect.size.timesPointwise(
+            new Vec2(width / this.lastBounds.width, height / this.lastBounds.height),
+          ),
+        ),
+      )
+    }
+    this.lastBounds = bounds
+  }
+
+  onWindowResize = () => {
+    this.updateConfigSpaceViewport()
+    this.onBeforeFrame()
+  }
+
+  private renderRects() {
+    if (!this.container) return
+    this.updateConfigSpaceViewport()
+
+    if (this.props.configSpaceViewportRect.isEmpty()) return
+
+    this.props.canvasContext.renderInto(this.container, context => {
+      this.props.flamechartRenderer.render({
+        physicalSpaceDstRect: this.physicalBounds(),
+        configSpaceSrcRect: this.props.configSpaceViewportRect,
+        renderOutlines: true,
+      })
+    })
+  }
+
+  // Inertial scrolling introduces tricky interaction problems.
+  // Namely, if you start panning, and hit the edge of the scrollable
+  // area, the browser continues to receive WheelEvents from inertial
+  // scrolling. If we start zooming by holding Cmd + scrolling, then
+  // release the Cmd key, this can cause us to interpret the incoming
+  // inertial scrolling events as panning. To prevent this, we introduce
+  // a concept of an "Interaction Lock". Once a certain interaction has
+  // begun, we don't allow the other type of interaction to begin until
+  // we've received two frames with no inertial wheel events. This
+  // prevents us from accidentally switching between panning & zooming.
+  private frameHadWheelEvent = false
+  private framesWithoutWheelEvents = 0
+  private interactionLock: 'pan' | 'zoom' | null = null
+  private maybeClearInteractionLock = () => {
+    if (this.interactionLock) {
+      if (!this.frameHadWheelEvent) {
+        this.framesWithoutWheelEvents++
+        if (this.framesWithoutWheelEvents >= 2) {
+          this.interactionLock = null
+          this.framesWithoutWheelEvents = 0
+        }
+      }
+      this.props.canvasContext.requestFrame()
+    }
+    this.frameHadWheelEvent = false
+  }
+
+  private onBeforeFrame = () => {
+    this.renderRects()
+    this.renderOverlays()
+    this.maybeClearInteractionLock()
+  }
+
+  private renderCanvas = () => {
+    this.props.canvasContext.requestFrame()
+  }
+
+  private pan(logicalViewSpaceDelta: Vec2) {
+    this.interactionLock = 'pan'
+
+    const physicalDelta = this.logicalToPhysicalViewSpace().transformVector(logicalViewSpaceDelta)
+    const configDelta = this.configSpaceToPhysicalViewSpace().inverseTransformVector(physicalDelta)
+
+    if (this.hoveredLabel) {
+      this.props.onNodeHover(null)
+    }
+
+    if (!configDelta) return
+    this.props.transformViewport(AffineTransform.withTranslation(configDelta))
+  }
+
+  private zoom(logicalViewSpaceCenter: Vec2, multiplier: number) {
+    this.interactionLock = 'zoom'
+
+    const physicalCenter = this.logicalToPhysicalViewSpace().transformPosition(
+      logicalViewSpaceCenter,
+    )
+    const configSpaceCenter = this.configSpaceToPhysicalViewSpace().inverseTransformPosition(
+      physicalCenter,
+    )
+    if (!configSpaceCenter) return
+
+    const zoomTransform = AffineTransform.withTranslation(configSpaceCenter.times(-1))
+      .scaledBy(new Vec2(multiplier, 1))
+      .translatedBy(configSpaceCenter)
+
+    this.props.transformViewport(zoomTransform)
+  }
+
+  private lastDragPos: Vec2 | null = null
+  private onMouseDown = (ev: MouseEvent) => {
+    this.lastDragPos = new Vec2(ev.offsetX, ev.offsetY)
+    this.updateCursor()
+    window.addEventListener('mouseup', this.onWindowMouseUp)
+  }
+
+  private onMouseDrag = (ev: MouseEvent) => {
+    if (!this.lastDragPos) return
+    const logicalMousePos = new Vec2(ev.offsetX, ev.offsetY)
+    this.pan(this.lastDragPos.minus(logicalMousePos))
+    this.lastDragPos = logicalMousePos
+
+    // When panning by scrolling, the element under
+    // the cursor will change, so clear the hovered label.
+    if (this.hoveredLabel) {
+      this.props.onNodeHover(null)
+    }
+  }
+
+  private onDblClick = (ev: MouseEvent) => {
+    if (this.hoveredLabel) {
+      const hoveredBounds = this.hoveredLabel.configSpaceBounds
+      const viewportRect = new Rect(
+        hoveredBounds.origin.minus(new Vec2(0, 1)),
+        hoveredBounds.size.withY(this.props.configSpaceViewportRect.height()),
+      )
+      this.props.setConfigSpaceViewportRect(viewportRect)
+    }
+  }
+
+  private onClick = (ev: MouseEvent) => {
+    if (this.hoveredLabel) {
+      this.props.onNodeSelect(this.hoveredLabel.node)
+      this.renderCanvas()
+    } else {
+      this.props.onNodeSelect(null)
+    }
+  }
+
+  private updateCursor() {
+    if (this.lastDragPos) {
+      document.body.style.cursor = 'grabbing'
+      document.body.style.cursor = '-webkit-grabbing'
+    } else {
+      document.body.style.cursor = 'default'
+    }
+  }
+
+  private onWindowMouseUp = (ev: MouseEvent) => {
+    this.lastDragPos = null
+    this.updateCursor()
+    window.removeEventListener('mouseup', this.onWindowMouseUp)
+  }
+
+  private onMouseMove = (ev: MouseEvent) => {
+    this.updateCursor()
+    if (this.lastDragPos) {
+      ev.preventDefault()
+      this.onMouseDrag(ev)
+      return
+    }
+    this.hoveredLabel = null
+    const logicalViewSpaceMouse = new Vec2(ev.offsetX, ev.offsetY)
+    const physicalViewSpaceMouse = this.logicalToPhysicalViewSpace().transformPosition(
+      logicalViewSpaceMouse,
+    )
+    const configSpaceMouse = this.configSpaceToPhysicalViewSpace().inverseTransformPosition(
+      physicalViewSpaceMouse,
+    )
+
+    if (!configSpaceMouse) return
+
+    const setHoveredLabel = (frame: FlamechartFrame, depth = 0) => {
+      const width = frame.end - frame.start
+      const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
+      const configSpaceBounds = new Rect(new Vec2(frame.start, y), new Vec2(width, 1))
+      if (configSpaceMouse.x < configSpaceBounds.left()) return null
+      if (configSpaceMouse.x > configSpaceBounds.right()) return null
+
+      if (configSpaceBounds.contains(configSpaceMouse)) {
+        this.hoveredLabel = {
+          configSpaceBounds,
+          node: frame.node,
+        }
+      }
+
+      for (let child of frame.children) {
+        setHoveredLabel(child, depth + 1)
+      }
+    }
+
+    for (let frame of this.props.flamechart.getLayers()[0] || []) {
+      setHoveredLabel(frame)
+    }
+
+    if (this.hoveredLabel) {
+      this.props.onNodeHover({node: this.hoveredLabel!.node, event: ev})
+    } else {
+      this.props.onNodeHover(null)
+    }
+
+    this.renderCanvas()
+  }
+
+  private onMouseLeave = (ev: MouseEvent) => {
+    this.hoveredLabel = null
+    this.props.onNodeHover(null)
+    this.renderCanvas()
+  }
+
+  private onWheel = (ev: WheelEvent) => {
+    ev.preventDefault()
+    this.frameHadWheelEvent = true
+
+    const isZoom = ev.metaKey || ev.ctrlKey
+
+    let deltaY = ev.deltaY
+    let deltaX = ev.deltaX
+    if (ev.deltaMode === ev.DOM_DELTA_LINE) {
+      deltaY *= this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT
+      deltaX *= this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT
+    }
+
+    if (isZoom && this.interactionLock !== 'pan') {
+      let multiplier = 1 + deltaY / 100
+
+      // On Chrome & Firefox, pinch-to-zoom maps to
+      // WheelEvent + Ctrl Key. We'll accelerate it in
+      // this case, since it feels a bit sluggish otherwise.
+      if (ev.ctrlKey) {
+        multiplier = 1 + deltaY / 40
+      }
+
+      multiplier = clamp(multiplier, 0.1, 10.0)
+
+      this.zoom(new Vec2(ev.offsetX, ev.offsetY), multiplier)
+    } else if (this.interactionLock !== 'zoom') {
+      this.pan(new Vec2(deltaX, deltaY))
+    }
+
+    this.renderCanvas()
+  }
+
+  onWindowKeyPress = (ev: KeyboardEvent) => {
+    if (!this.container) return
+    const {width, height} = this.container.getBoundingClientRect()
+
+    if (ev.key === '=' || ev.key === '+') {
+      this.zoom(new Vec2(width / 2, height / 2), 0.5)
+      ev.preventDefault()
+    } else if (ev.key === '-' || ev.key === '_') {
+      this.zoom(new Vec2(width / 2, height / 2), 2)
+      ev.preventDefault()
+    } else if (ev.key === '0') {
+      this.zoom(new Vec2(width / 2, height / 2), 1e9)
+    } else if (ev.key === 'ArrowRight' || ev.key === 'd') {
+      this.pan(new Vec2(100, 0))
+    } else if (ev.key === 'ArrowLeft' || ev.key === 'a') {
+      this.pan(new Vec2(-100, 0))
+    } else if (ev.key === 'ArrowUp' || ev.key === 'w') {
+      this.pan(new Vec2(0, -100))
+    } else if (ev.key === 'ArrowDown' || ev.key === 's') {
+      this.pan(new Vec2(0, 100))
+    } else if (ev.key === 'Escape') {
+      this.props.onNodeSelect(null)
+      this.renderCanvas()
+    }
+  }
+
+  shouldComponentUpdate() {
+    return false
+  }
+  componentWillReceiveProps(nextProps: FlamechartPanZoomViewProps) {
+    if (this.props.flamechart !== nextProps.flamechart) {
+      this.hoveredLabel = null
+      this.lastBounds = null
+      this.renderCanvas()
+    } else if (this.props.selectedNode !== nextProps.selectedNode) {
+      this.renderCanvas()
+    } else if (this.props.configSpaceViewportRect !== nextProps.configSpaceViewportRect) {
+      this.renderCanvas()
+    }
+  }
+  componentDidMount() {
+    this.props.canvasContext.addBeforeFrameHandler(this.onBeforeFrame)
+    window.addEventListener('resize', this.onWindowResize)
+    window.addEventListener('keydown', this.onWindowKeyPress)
+  }
+  componentWillUnmount() {
+    this.props.canvasContext.removeBeforeFrameHandler(this.onBeforeFrame)
+    window.removeEventListener('resize', this.onWindowResize)
+    window.removeEventListener('keydown', this.onWindowKeyPress)
+  }
+
+  render() {
+    return (
+      <div
+        className={css(style.panZoomView, commonStyle.vbox)}
+        onMouseDown={this.onMouseDown}
+        onMouseMove={this.onMouseMove}
+        onMouseLeave={this.onMouseLeave}
+        onClick={this.onClick}
+        onDblClick={this.onDblClick}
+        onWheel={this.onWheel}
+        ref={this.containerRef}
+      >
+        <canvas width={1} height={1} ref={this.overlayCanvasRef} className={css(style.fill)} />
+      </div>
+    )
+  }
+}
diff --git a/flamechart-renderer.ts b/flamechart-renderer.ts
index c1f721f..956e451 100644
--- a/flamechart-renderer.ts
+++ b/flamechart-renderer.ts
@@ -121,6 +121,10 @@ export class FlamechartRowAtlasKey {
   }
 }
 
+interface RendererOptions {
+  inverted: boolean
+}
+
 export class FlamechartRenderer {
   private layers: RangeTreeNode[] = []
   private rectInfoTexture: regl.Texture
@@ -130,11 +134,12 @@ export class FlamechartRenderer {
     private canvasContext: CanvasContext,
     private rowAtlas: RowAtlas<FlamechartRowAtlasKey>,
     private flamechart: Flamechart,
+    private options: RendererOptions = {inverted: false},
   ) {
     const nLayers = flamechart.getLayers().length
     for (let stackDepth = 0; stackDepth < nLayers; stackDepth++) {
       const leafNodes: RangeTreeLeafNode[] = []
-      const y = stackDepth
+      const y = options.inverted ? nLayers - 1 - stackDepth : stackDepth
 
       let minLeft = Infinity
       let maxRight = -Infinity
@@ -150,7 +155,7 @@ export class FlamechartRenderer {
           leafNodes.push(
             new RangeTreeLeafNode(
               batch,
-              new Rect(new Vec2(minLeft, stackDepth), new Vec2(maxRight - minLeft, 1)),
+              new Rect(new Vec2(minLeft, y), new Vec2(maxRight - minLeft, 1)),
               rectCount,
             ),
           )
@@ -183,7 +188,7 @@ export class FlamechartRenderer {
         leafNodes.push(
           new RangeTreeLeafNode(
             batch,
-            new Rect(new Vec2(minLeft, stackDepth), new Vec2(maxRight - minLeft, 1)),
+            new Rect(new Vec2(minLeft, y), new Vec2(maxRight - minLeft, 1)),
             rectCount,
           ),
         )
@@ -207,7 +212,9 @@ export class FlamechartRenderer {
 
     const width = configSpaceContentWidth / Math.pow(2, zoomLevel)
 
-    return new Rect(new Vec2(width * index, stackDepth), new Vec2(width, 1))
+    const nLayers = this.flamechart.getLayers().length
+    const y = this.options.inverted ? nLayers - 1 - stackDepth : stackDepth
+    return new Rect(new Vec2(width * index, y), new Vec2(width, 1))
   }
 
   render(props: FlamechartRendererProps) {
@@ -251,8 +258,11 @@ export class FlamechartRenderer {
       numAtlasEntriesPerLayer * configSpaceSrcRect.right() / configSpaceContentWidth,
     )
 
-    for (let stackDepth = top; stackDepth < bottom; stackDepth++) {
+    const nLayers = this.flamechart.getLayers().length
+
+    for (let y = top; y < bottom; y++) {
       for (let index = left; index <= right; index++) {
+        const stackDepth = this.options.inverted ? nLayers - 1 - y : y
         const key = FlamechartRowAtlasKey.getOrInsert(this.atlasKeys, {
           stackDepth,
           zoomLevel,
diff --git a/flamechart-style.ts b/flamechart-style.ts
index a000450..998dee0 100644
--- a/flamechart-style.ts
+++ b/flamechart-style.ts
@@ -1,40 +1,10 @@
 import {StyleSheet} from 'aphrodite'
-import {FontFamily, FontSize, Colors, Sizes} from './style'
-
-const HOVERTIP_PADDING = 2
+import {FontSize, Colors, Sizes} from './style'
 
 export const style = StyleSheet.create({
-  hoverTip: {
-    position: 'absolute',
-    background: Colors.WHITE,
-    border: '1px solid black',
-    maxWidth: Sizes.TOOLTIP_WIDTH_MAX,
-    paddingTop: HOVERTIP_PADDING,
-    paddingBottom: HOVERTIP_PADDING,
-    pointerEvents: 'none',
-    userSelect: 'none',
-    fontSize: FontSize.LABEL,
-    fontFamily: FontFamily.MONOSPACE,
-  },
-  hoverTipRow: {
-    textOverflow: 'ellipsis',
-    whiteSpace: 'nowrap',
-    overflowX: 'hidden',
-    paddingLeft: HOVERTIP_PADDING,
-    paddingRight: HOVERTIP_PADDING,
-    maxWidth: Sizes.TOOLTIP_WIDTH_MAX,
-  },
   hoverCount: {
     color: Colors.GREEN,
   },
-  clip: {
-    overflow: 'hidden',
-  },
-  vbox: {
-    display: 'flex',
-    flexDirection: 'column',
-    position: 'relative',
-  },
   fill: {
     width: '100%',
     height: '100%',
diff --git a/flamechart-view.tsx b/flamechart-view.tsx
index adfb525..94ee8f3 100644
--- a/flamechart-view.tsx
+++ b/flamechart-view.tsx
@@ -1,822 +1,21 @@
 import {h} from 'preact'
-import {css, StyleDeclarationValue} from 'aphrodite'
+import {css} from 'aphrodite'
 import {ReloadableComponent} from './reloadable'
 
 import {CallTreeNode, Frame} from './profile'
-import {Flamechart, FlamechartFrame} from './flamechart'
+import {Flamechart} from './flamechart'
 
-import {Rect, Vec2, AffineTransform, clamp} from './math'
-import {cachedMeasureTextWidth, formatPercent} from './utils'
+import {Rect, Vec2, AffineTransform} from './math'
+import {formatPercent} from './utils'
 import {FlamechartMinimapView} from './flamechart-minimap-view'
 
 import {style} from './flamechart-style'
-import {FontSize, FontFamily, Colors, Sizes} from './style'
+import {Sizes, commonStyle} from './style'
 import {CanvasContext} from './canvas-context'
 import {FlamechartRenderer} from './flamechart-renderer'
-import {ColorChit} from './color-chit'
-
-interface FlamechartFrameLabel {
-  configSpaceBounds: Rect
-  node: CallTreeNode
-}
-
-function binarySearch(
-  lo: number,
-  hi: number,
-  f: (val: number) => number,
-  target: number,
-  targetRangeSize = 1,
-): [number, number] {
-  console.assert(!isNaN(targetRangeSize) && !isNaN(target))
-  while (true) {
-    if (hi - lo <= targetRangeSize) return [lo, hi]
-    const mid = (hi + lo) / 2
-    const val = f(mid)
-    if (val < target) lo = mid
-    else hi = mid
-  }
-}
-
-const ELLIPSIS = '\u2026'
-
-function buildTrimmedText(text: string, length: number) {
-  const prefixLength = Math.floor(length / 2)
-  const prefix = text.substr(0, prefixLength)
-  const suffix = text.substr(text.length - prefixLength, prefixLength)
-  return prefix + ELLIPSIS + suffix
-}
-
-function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
-  if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text
-  const [lo] = binarySearch(
-    0,
-    text.length,
-    n => {
-      return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n))
-    },
-    maxWidth,
-  )
-  return buildTrimmedText(text, lo)
-}
-
-/**
- * Component to visualize a Flamechart and interact with it via hovering,
- * zooming, and panning.
- *
- * There are 3 vector spaces involved:
- * - Configuration Space: In this space, the horizontal unit is ms, and the
- *   vertical unit is stack depth. Each stack frame is one unit high.
- * - Logical view space: Origin is top-left, with +y downwards. This represents
- *   the coordinate space of the view as specified in CSS: horizontal and vertical
- *   units are both "logical" pixels.
- * - Physical view space: Origin is top-left, with +y downwards. This represents
- *   the coordinate space of the view as specified in hardware pixels: horizontal
- *   and vertical units are both "physical" pixels.
- *
- * We use two canvases to draw the flamechart itself: one for the rectangles,
- * which we render via WebGL, and one for the labels, which we render via 2D
- * canvas primitives.
- */
-interface FlamechartPanZoomViewProps {
-  flamechart: Flamechart
-
-  canvasContext: CanvasContext
-  flamechartRenderer: FlamechartRenderer
-  selectedNode: CallTreeNode | null
-
-  setNodeHover: (node: CallTreeNode | null, logicalViewSpaceMouse: Vec2) => void
-  setSelectedNode: (node: CallTreeNode | null) => void
-  configSpaceViewportRect: Rect
-  transformViewport: (transform: AffineTransform) => void
-  setConfigSpaceViewportRect: (rect: Rect) => void
-}
-
-export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoomViewProps, {}> {
-  private container: Element | null = null
-  private containerRef = (element?: Element) => {
-    this.container = element || null
-  }
-
-  private overlayCanvas: HTMLCanvasElement | null = null
-  private overlayCtx: CanvasRenderingContext2D | null = null
-
-  private hoveredLabel: FlamechartFrameLabel | null = null
-
-  private setConfigSpaceViewportRect(r: Rect) {
-    this.props.setConfigSpaceViewportRect(r)
-  }
-
-  private overlayCanvasRef = (element?: Element) => {
-    if (element) {
-      this.overlayCanvas = element as HTMLCanvasElement
-      this.overlayCtx = this.overlayCanvas.getContext('2d')
-      this.renderCanvas()
-    } else {
-      this.overlayCanvas = null
-      this.overlayCtx = null
-    }
-  }
-
-  private configSpaceSize() {
-    return new Vec2(
-      this.props.flamechart.getTotalWeight(),
-      this.props.flamechart.getLayers().length,
-    )
-  }
-
-  private physicalViewSize() {
-    return new Vec2(
-      this.overlayCanvas ? this.overlayCanvas.width : 0,
-      this.overlayCanvas ? this.overlayCanvas.height : 0,
-    )
-  }
-
-  private LOGICAL_VIEW_SPACE_FRAME_HEIGHT = Sizes.FRAME_HEIGHT
-
-  private configSpaceToPhysicalViewSpace() {
-    return AffineTransform.betweenRects(
-      this.props.configSpaceViewportRect,
-      new Rect(new Vec2(0, 0), this.physicalViewSize()),
-    )
-  }
-
-  private logicalToPhysicalViewSpace() {
-    return AffineTransform.withScale(new Vec2(window.devicePixelRatio, window.devicePixelRatio))
-  }
-
-  private resizeOverlayCanvasIfNeeded() {
-    if (!this.overlayCanvas) return
-    let {width, height} = this.overlayCanvas.getBoundingClientRect()
-    {
-      /*
-      We render text at a higher resolution then scale down to
-      ensure we're rendering at 1:1 device pixel ratio.
-      This ensures our text is rendered crisply.
-    */
-    }
-    width = Math.floor(width)
-    height = Math.floor(height)
-
-    // Still initializing: don't resize yet
-    if (width === 0 || height === 0) return
-
-    const scaledWidth = width * window.devicePixelRatio
-    const scaledHeight = height * window.devicePixelRatio
-
-    if (scaledWidth === this.overlayCanvas.width && scaledHeight === this.overlayCanvas.height)
-      return
-
-    this.overlayCanvas.width = scaledWidth
-    this.overlayCanvas.height = scaledHeight
-  }
-
-  private renderOverlays() {
-    const ctx = this.overlayCtx
-    if (!ctx) return
-    this.resizeOverlayCanvasIfNeeded()
-    if (this.props.configSpaceViewportRect.isEmpty()) return
-
-    const configToPhysical = this.configSpaceToPhysicalViewSpace()
-
-    const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
-    const physicalViewSpaceFrameHeight =
-      this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT * window.devicePixelRatio
-
-    const physicalViewSize = this.physicalViewSize()
-
-    ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
-
-    if (this.hoveredLabel) {
-      let color = Colors.DARK_GRAY
-      if (this.props.selectedNode === this.hoveredLabel.node) {
-        color = Colors.DARK_BLUE
-      }
-
-      ctx.lineWidth = 2 * devicePixelRatio
-      ctx.strokeStyle = color
-
-      const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
-      ctx.strokeRect(
-        Math.round(physicalViewBounds.left()),
-        Math.round(physicalViewBounds.top()),
-        Math.round(Math.max(0, physicalViewBounds.width())),
-        Math.round(Math.max(0, physicalViewBounds.height())),
-      )
-    }
-
-    ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${
-      FontFamily.MONOSPACE
-    }`
-    ctx.textBaseline = 'alphabetic'
-    ctx.fillStyle = Colors.DARK_GRAY
-
-    const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M')
-    const minConfigSpaceWidthToRender = (
-      configToPhysical.inverseTransformVector(new Vec2(minWidthToRender, 0)) || new Vec2(0, 0)
-    ).x
-
-    const LABEL_PADDING_PX = 5 * window.devicePixelRatio
-
-    const renderFrameLabelAndChildren = (frame: FlamechartFrame, depth = 0) => {
-      const width = frame.end - frame.start
-      const configSpaceBounds = new Rect(new Vec2(frame.start, depth), new Vec2(width, 1))
-
-      if (width < minConfigSpaceWidthToRender) return
-      if (configSpaceBounds.left() > this.props.configSpaceViewportRect.right()) return
-      if (configSpaceBounds.right() < this.props.configSpaceViewportRect.left()) return
-      if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return
-
-      if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) {
-        let physicalLabelBounds = configToPhysical.transformRect(configSpaceBounds)
-
-        if (physicalLabelBounds.left() < 0) {
-          physicalLabelBounds = physicalLabelBounds
-            .withOrigin(physicalLabelBounds.origin.withX(0))
-            .withSize(
-              physicalLabelBounds.size.withX(
-                physicalLabelBounds.size.x + physicalLabelBounds.left(),
-              ),
-            )
-        }
-        if (physicalLabelBounds.right() > physicalViewSize.x) {
-          physicalLabelBounds = physicalLabelBounds.withSize(
-            physicalLabelBounds.size.withX(physicalViewSize.x - physicalLabelBounds.left()),
-          )
-        }
-
-        const trimmedText = trimTextMid(
-          ctx,
-          frame.node.frame.name,
-          physicalLabelBounds.width() - 2 * LABEL_PADDING_PX,
-        )
-
-        // Note that this is specifying the position of the starting text
-        // baseline.
-        ctx.fillText(
-          trimmedText,
-          physicalLabelBounds.left() + LABEL_PADDING_PX,
-          Math.round(
-            physicalLabelBounds.bottom() -
-              (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2,
-          ),
-        )
-      }
-      for (let child of frame.children) {
-        renderFrameLabelAndChildren(child, depth + 1)
-      }
-    }
-
-    for (let frame of this.props.flamechart.getLayers()[0] || []) {
-      renderFrameLabelAndChildren(frame)
-    }
-
-    const frameOutlineWidth = 2 * window.devicePixelRatio
-    ctx.strokeStyle = Colors.PALE_DARK_BLUE
-    ctx.lineWidth = frameOutlineWidth
-    const minConfigSpaceWidthToRenderOutline = (
-      configToPhysical.inverseTransformVector(new Vec2(1, 0)) || new Vec2(0, 0)
-    ).x
-    const renderIndirectlySelectedFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
-      if (!this.props.selectedNode) return
-      const width = frame.end - frame.start
-      const configSpaceBounds = new Rect(new Vec2(frame.start, depth), new Vec2(width, 1))
-
-      if (width < minConfigSpaceWidthToRenderOutline) return
-      if (configSpaceBounds.left() > this.props.configSpaceViewportRect.right()) return
-      if (configSpaceBounds.right() < this.props.configSpaceViewportRect.left()) return
-      if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return
-
-      if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) {
-        const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
-
-        if (frame.node.frame === this.props.selectedNode.frame) {
-          if (frame.node === this.props.selectedNode) {
-            if (ctx.strokeStyle !== Colors.DARK_BLUE) {
-              ctx.stroke()
-              ctx.beginPath()
-              ctx.strokeStyle = Colors.DARK_BLUE
-            }
-          } else {
-            if (ctx.strokeStyle !== Colors.PALE_DARK_BLUE) {
-              ctx.stroke()
-              ctx.beginPath()
-              ctx.strokeStyle = Colors.PALE_DARK_BLUE
-            }
-          }
-
-          // Identify the flamechart frames with a function that matches the
-          // selected flamechart frame.
-          ctx.rect(
-            Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
-            Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
-            Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
-            Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)),
-          )
-        }
-      }
-
-      for (let child of frame.children) {
-        renderIndirectlySelectedFrameOutlines(child, depth + 1)
-      }
-    }
-
-    ctx.beginPath()
-    for (let frame of this.props.flamechart.getLayers()[0] || []) {
-      renderIndirectlySelectedFrameOutlines(frame)
-    }
-    ctx.stroke()
-
-    this.renderTimeIndicators()
-  }
-
-  private renderTimeIndicators() {
-    const ctx = this.overlayCtx
-    if (!ctx) return
-
-    const physicalViewSpaceFrameHeight =
-      this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT * window.devicePixelRatio
-    const physicalViewSize = this.physicalViewSize()
-    const configToPhysical = this.configSpaceToPhysicalViewSpace()
-    const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
-    const labelPaddingPx = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
-
-    const left = this.props.configSpaceViewportRect.left()
-    const right = this.props.configSpaceViewportRect.right()
-    // We want about 10 gridlines to be visible, and want the unit to be
-    // 1eN, 2eN, or 5eN for some N
-    // Ideally, we want an interval every 100 logical screen pixels
-    const logicalToConfig = (
-      this.configSpaceToPhysicalViewSpace().inverted() || new AffineTransform()
-    ).times(this.logicalToPhysicalViewSpace())
-    const targetInterval = logicalToConfig.transformVector(new Vec2(200, 1)).x
-    const minInterval = Math.pow(10, Math.floor(Math.log10(targetInterval)))
-    let interval = minInterval
-    if (targetInterval / interval > 5) {
-      interval *= 5
-    } else if (targetInterval / interval > 2) {
-      interval *= 2
-    }
-
-    {
-      ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
-      ctx.fillRect(0, 0, physicalViewSize.x, physicalViewSpaceFrameHeight)
-      ctx.fillStyle = Colors.DARK_GRAY
-      ctx.textBaseline = 'top'
-      for (let x = Math.ceil(left / interval) * interval; x < right; x += interval) {
-        // TODO(jlfwong): Ensure that labels do not overlap
-        const pos = Math.round(configToPhysical.transformPosition(new Vec2(x, 0)).x)
-        const labelText = this.props.flamechart.formatValue(x)
-        const textWidth = cachedMeasureTextWidth(ctx, labelText)
-        ctx.fillText(labelText, pos - textWidth - labelPaddingPx, labelPaddingPx)
-        ctx.fillRect(pos, 0, 1, physicalViewSize.y)
-      }
-    }
-  }
-
-  private lastBounds: ClientRect | null = null
-  private updateConfigSpaceViewport() {
-    if (!this.container) return
-    const bounds = this.container.getBoundingClientRect()
-    const {width, height} = bounds
-
-    // Still initializing: don't resize yet
-    if (width < 2 || height < 2) return
-
-    if (this.lastBounds == null) {
-      this.setConfigSpaceViewportRect(
-        new Rect(
-          new Vec2(0, -1),
-          new Vec2(this.configSpaceSize().x, height / this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT),
-        ),
-      )
-    } else if (this.lastBounds.width !== width || this.lastBounds.height !== height) {
-      // Resize the viewport rectangle to match the window size aspect
-      // ratio.
-      this.setConfigSpaceViewportRect(
-        this.props.configSpaceViewportRect.withSize(
-          this.props.configSpaceViewportRect.size.timesPointwise(
-            new Vec2(width / this.lastBounds.width, height / this.lastBounds.height),
-          ),
-        ),
-      )
-    }
-    this.lastBounds = bounds
-  }
-
-  onWindowResize = () => {
-    this.updateConfigSpaceViewport()
-    this.onBeforeFrame()
-  }
-
-  private renderRects() {
-    if (!this.container) return
-    this.updateConfigSpaceViewport()
-
-    if (this.props.configSpaceViewportRect.isEmpty()) return
-
-    this.props.canvasContext.renderInto(this.container, context => {
-      this.props.flamechartRenderer.render({
-        physicalSpaceDstRect: new Rect(Vec2.zero, this.physicalViewSize()),
-        configSpaceSrcRect: this.props.configSpaceViewportRect,
-        renderOutlines: true,
-      })
-    })
-  }
-
-  // Inertial scrolling introduces tricky interaction problems.
-  // Namely, if you start panning, and hit the edge of the scrollable
-  // area, the browser continues to receive WheelEvents from inertial
-  // scrolling. If we start zooming by holding Cmd + scrolling, then
-  // release the Cmd key, this can cause us to interpret the incoming
-  // inertial scrolling events as panning. To prevent this, we introduce
-  // a concept of an "Interaction Lock". Once a certain interaction has
-  // begun, we don't allow the other type of interaction to begin until
-  // we've received two frames with no inertial wheel events. This
-  // prevents us from accidentally switching between panning & zooming.
-  private frameHadWheelEvent = false
-  private framesWithoutWheelEvents = 0
-  private interactionLock: 'pan' | 'zoom' | null = null
-  private maybeClearInteractionLock = () => {
-    if (this.interactionLock) {
-      if (!this.frameHadWheelEvent) {
-        this.framesWithoutWheelEvents++
-        if (this.framesWithoutWheelEvents >= 2) {
-          this.interactionLock = null
-          this.framesWithoutWheelEvents = 0
-        }
-      }
-      this.props.canvasContext.requestFrame()
-    }
-    this.frameHadWheelEvent = false
-  }
-
-  private onBeforeFrame = () => {
-    this.renderRects()
-    this.renderOverlays()
-    this.maybeClearInteractionLock()
-  }
-
-  private renderCanvas = () => {
-    this.props.canvasContext.requestFrame()
-  }
-
-  private pan(logicalViewSpaceDelta: Vec2) {
-    this.interactionLock = 'pan'
-
-    const physicalDelta = this.logicalToPhysicalViewSpace().transformVector(logicalViewSpaceDelta)
-    const configDelta = this.configSpaceToPhysicalViewSpace().inverseTransformVector(physicalDelta)
-
-    if (this.hoveredLabel) {
-      this.props.setNodeHover(null, Vec2.zero)
-    }
-
-    if (!configDelta) return
-    this.props.transformViewport(AffineTransform.withTranslation(configDelta))
-  }
-
-  private zoom(logicalViewSpaceCenter: Vec2, multiplier: number) {
-    this.interactionLock = 'zoom'
-
-    const physicalCenter = this.logicalToPhysicalViewSpace().transformPosition(
-      logicalViewSpaceCenter,
-    )
-    const configSpaceCenter = this.configSpaceToPhysicalViewSpace().inverseTransformPosition(
-      physicalCenter,
-    )
-    if (!configSpaceCenter) return
-
-    const zoomTransform = AffineTransform.withTranslation(configSpaceCenter.times(-1))
-      .scaledBy(new Vec2(multiplier, 1))
-      .translatedBy(configSpaceCenter)
-
-    this.props.transformViewport(zoomTransform)
-  }
-
-  private lastDragPos: Vec2 | null = null
-  private onMouseDown = (ev: MouseEvent) => {
-    this.lastDragPos = new Vec2(ev.offsetX, ev.offsetY)
-    this.updateCursor()
-    window.addEventListener('mouseup', this.onWindowMouseUp)
-  }
-
-  private onMouseDrag = (ev: MouseEvent) => {
-    if (!this.lastDragPos) return
-    const logicalMousePos = new Vec2(ev.offsetX, ev.offsetY)
-    this.pan(this.lastDragPos.minus(logicalMousePos))
-    this.lastDragPos = logicalMousePos
-
-    // When panning by scrolling, the element under
-    // the cursor will change, so clear the hovered label.
-    if (this.hoveredLabel) {
-      this.props.setNodeHover(null, logicalMousePos)
-    }
-  }
-
-  private onDblClick = (ev: MouseEvent) => {
-    if (this.hoveredLabel) {
-      const hoveredBounds = this.hoveredLabel.configSpaceBounds
-      const viewportRect = new Rect(
-        hoveredBounds.origin.minus(new Vec2(0, 1)),
-        hoveredBounds.size.withY(this.props.configSpaceViewportRect.height()),
-      )
-      this.props.setConfigSpaceViewportRect(viewportRect)
-    }
-  }
-
-  private onClick = (ev: MouseEvent) => {
-    if (this.hoveredLabel) {
-      this.props.setSelectedNode(this.hoveredLabel.node)
-      this.renderCanvas()
-    } else {
-      this.props.setSelectedNode(null)
-    }
-  }
-
-  private updateCursor() {
-    if (this.lastDragPos) {
-      document.body.style.cursor = 'grabbing'
-      document.body.style.cursor = '-webkit-grabbing'
-    } else {
-      document.body.style.cursor = 'default'
-    }
-  }
-
-  private onWindowMouseUp = (ev: MouseEvent) => {
-    this.lastDragPos = null
-    this.updateCursor()
-    window.removeEventListener('mouseup', this.onWindowMouseUp)
-  }
-
-  private onMouseMove = (ev: MouseEvent) => {
-    this.updateCursor()
-    if (this.lastDragPos) {
-      ev.preventDefault()
-      this.onMouseDrag(ev)
-      return
-    }
-    this.hoveredLabel = null
-    const logicalViewSpaceMouse = new Vec2(ev.offsetX, ev.offsetY)
-    const physicalViewSpaceMouse = this.logicalToPhysicalViewSpace().transformPosition(
-      logicalViewSpaceMouse,
-    )
-    const configSpaceMouse = this.configSpaceToPhysicalViewSpace().inverseTransformPosition(
-      physicalViewSpaceMouse,
-    )
-
-    if (!configSpaceMouse) return
-
-    const setHoveredLabel = (frame: FlamechartFrame, depth = 0) => {
-      const width = frame.end - frame.start
-      const configSpaceBounds = new Rect(new Vec2(frame.start, depth), new Vec2(width, 1))
-      if (configSpaceMouse.x < configSpaceBounds.left()) return null
-      if (configSpaceMouse.x > configSpaceBounds.right()) return null
-
-      if (configSpaceBounds.contains(configSpaceMouse)) {
-        this.hoveredLabel = {
-          configSpaceBounds,
-          node: frame.node,
-        }
-      }
-
-      for (let child of frame.children) {
-        setHoveredLabel(child, depth + 1)
-      }
-    }
-
-    for (let frame of this.props.flamechart.getLayers()[0] || []) {
-      setHoveredLabel(frame)
-    }
-
-    if (this.hoveredLabel) {
-      this.props.setNodeHover(this.hoveredLabel!.node, logicalViewSpaceMouse)
-    } else {
-      this.props.setNodeHover(null, logicalViewSpaceMouse)
-    }
-
-    this.renderCanvas()
-  }
-
-  private onMouseLeave = (ev: MouseEvent) => {
-    this.hoveredLabel = null
-    this.props.setNodeHover(null, Vec2.zero)
-    this.renderCanvas()
-  }
-
-  private onWheel = (ev: WheelEvent) => {
-    ev.preventDefault()
-    this.frameHadWheelEvent = true
-
-    const isZoom = ev.metaKey || ev.ctrlKey
-
-    let deltaY = ev.deltaY
-    let deltaX = ev.deltaX
-    if (ev.deltaMode === ev.DOM_DELTA_LINE) {
-      deltaY *= this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT
-      deltaX *= this.LOGICAL_VIEW_SPACE_FRAME_HEIGHT
-    }
-
-    if (isZoom && this.interactionLock !== 'pan') {
-      let multiplier = 1 + deltaY / 100
-
-      // On Chrome & Firefox, pinch-to-zoom maps to
-      // WheelEvent + Ctrl Key. We'll accelerate it in
-      // this case, since it feels a bit sluggish otherwise.
-      if (ev.ctrlKey) {
-        multiplier = 1 + deltaY / 40
-      }
-
-      multiplier = clamp(multiplier, 0.1, 10.0)
-
-      this.zoom(new Vec2(ev.offsetX, ev.offsetY), multiplier)
-    } else if (this.interactionLock !== 'zoom') {
-      this.pan(new Vec2(deltaX, deltaY))
-    }
-
-    this.renderCanvas()
-  }
-
-  onWindowKeyPress = (ev: KeyboardEvent) => {
-    if (!this.container) return
-    const {width, height} = this.container.getBoundingClientRect()
-
-    if (ev.key === '=' || ev.key === '+') {
-      this.zoom(new Vec2(width / 2, height / 2), 0.5)
-      ev.preventDefault()
-    } else if (ev.key === '-' || ev.key === '_') {
-      this.zoom(new Vec2(width / 2, height / 2), 2)
-      ev.preventDefault()
-    } else if (ev.key === '0') {
-      this.zoom(new Vec2(width / 2, height / 2), 1e9)
-    } else if (ev.key === 'ArrowRight' || ev.key === 'd') {
-      this.pan(new Vec2(100, 0))
-    } else if (ev.key === 'ArrowLeft' || ev.key === 'a') {
-      this.pan(new Vec2(-100, 0))
-    } else if (ev.key === 'ArrowUp' || ev.key === 'w') {
-      this.pan(new Vec2(0, -100))
-    } else if (ev.key === 'ArrowDown' || ev.key === 's') {
-      this.pan(new Vec2(0, 100))
-    } else if (ev.key === 'Escape') {
-      this.props.setSelectedNode(null)
-      this.renderCanvas()
-    }
-  }
-
-  shouldComponentUpdate() {
-    return false
-  }
-  componentWillReceiveProps(nextProps: FlamechartPanZoomViewProps) {
-    if (this.props.flamechart !== nextProps.flamechart) {
-      this.hoveredLabel = null
-      this.renderCanvas()
-    } else if (this.props.selectedNode !== nextProps.selectedNode) {
-      this.renderCanvas()
-    } else if (this.props.configSpaceViewportRect !== nextProps.configSpaceViewportRect) {
-      this.renderCanvas()
-    }
-  }
-  componentDidMount() {
-    this.props.canvasContext.addBeforeFrameHandler(this.onBeforeFrame)
-    window.addEventListener('resize', this.onWindowResize)
-    window.addEventListener('keydown', this.onWindowKeyPress)
-  }
-  componentWillUnmount() {
-    this.props.canvasContext.removeBeforeFrameHandler(this.onBeforeFrame)
-    window.removeEventListener('resize', this.onWindowResize)
-    window.removeEventListener('keydown', this.onWindowKeyPress)
-  }
-
-  render() {
-    return (
-      <div
-        className={css(style.panZoomView, style.vbox)}
-        onMouseDown={this.onMouseDown}
-        onMouseMove={this.onMouseMove}
-        onMouseLeave={this.onMouseLeave}
-        onClick={this.onClick}
-        onDblClick={this.onDblClick}
-        onWheel={this.onWheel}
-        ref={this.containerRef}
-      >
-        <canvas width={1} height={1} ref={this.overlayCanvasRef} className={css(style.fill)} />
-      </div>
-    )
-  }
-}
-
-interface StatisticsTableProps {
-  title: string
-  grandTotal: number
-  selectedTotal: number
-  selectedSelf: number
-  cellStyle: StyleDeclarationValue
-  formatter: (v: number) => string
-}
-
-class StatisticsTable extends ReloadableComponent<StatisticsTableProps, {}> {
-  render() {
-    const total = this.props.formatter(this.props.selectedTotal)
-    const self = this.props.formatter(this.props.selectedSelf)
-    const totalPerc = 100.0 * this.props.selectedTotal / this.props.grandTotal
-    const selfPerc = 100.0 * this.props.selectedSelf / this.props.grandTotal
-
-    return (
-      <div className={css(style.statsTable)}>
-        <div className={css(this.props.cellStyle, style.statsTableCell, style.statsTableHeader)}>
-          {this.props.title}
-        </div>
-
-        <div className={css(this.props.cellStyle, style.statsTableCell)}>Total</div>
-        <div className={css(this.props.cellStyle, style.statsTableCell)}>Self</div>
-
-        <div className={css(this.props.cellStyle, style.statsTableCell)}>{total}</div>
-        <div className={css(this.props.cellStyle, style.statsTableCell)}>{self}</div>
-
-        <div className={css(this.props.cellStyle, style.statsTableCell)}>
-          {formatPercent(totalPerc)}
-          <div className={css(style.barDisplay)} style={{height: `${totalPerc}%`}} />
-        </div>
-        <div className={css(this.props.cellStyle, style.statsTableCell)}>
-          {formatPercent(selfPerc)}
-          <div className={css(style.barDisplay)} style={{height: `${selfPerc}%`}} />
-        </div>
-      </div>
-    )
-  }
-}
-
-interface StackTraceViewProps {
-  getFrameColor: (frame: Frame) => string
-  node: CallTreeNode
-}
-class StackTraceView extends ReloadableComponent<StackTraceViewProps, {}> {
-  render() {
-    const rows: JSX.Element[] = []
-    let node: CallTreeNode | null = this.props.node
-    for (; node && !node.isRoot(); node = node.parent) {
-      const row: (JSX.Element | string)[] = []
-      const {frame} = node
-
-      row.push(<ColorChit color={this.props.getFrameColor(frame)} />)
-
-      if (rows.length) {
-        row.push(<span className={css(style.stackFileLine)}>> </span>)
-      }
-      row.push(frame.name)
-
-      if (frame.file) {
-        let pos = frame.file
-        if (frame.line) {
-          pos += `:${frame.line}`
-          if (frame.col) {
-            pos += `:${frame.col}`
-          }
-        }
-        row.push(<span className={css(style.stackFileLine)}> ({pos})</span>)
-      }
-      rows.push(<div className={css(style.stackLine)}>{row}</div>)
-    }
-    return (
-      <div className={css(style.stackTraceView)}>
-        <div className={css(style.stackTraceViewPadding)}>{rows}</div>
-      </div>
-    )
-  }
-}
-
-interface FlamechartDetailViewProps {
-  flamechart: Flamechart
-  getCSSColorForFrame: (frame: Frame) => string
-  selectedNode: CallTreeNode
-}
-
-class FlamechartDetailView extends ReloadableComponent<FlamechartDetailViewProps, {}> {
-  render() {
-    const {flamechart, selectedNode} = this.props
-    const {frame} = selectedNode
-
-    return (
-      <div className={css(style.detailView)}>
-        <StatisticsTable
-          title={'This Instance'}
-          cellStyle={style.thisInstanceCell}
-          grandTotal={flamechart.getTotalWeight()}
-          selectedTotal={selectedNode.getTotalWeight()}
-          selectedSelf={selectedNode.getSelfWeight()}
-          formatter={flamechart.formatValue.bind(flamechart)}
-        />
-        <StatisticsTable
-          title={'All Instances'}
-          cellStyle={style.allInstancesCell}
-          grandTotal={flamechart.getTotalWeight()}
-          selectedTotal={frame.getTotalWeight()}
-          selectedSelf={frame.getSelfWeight()}
-          formatter={flamechart.formatValue.bind(flamechart)}
-        />
-        <StackTraceView node={selectedNode} getFrameColor={this.props.getCSSColorForFrame} />
-      </div>
-    )
-  }
-}
+import {FlamechartDetailView} from './flamechart-detail-view'
+import {FlamechartPanZoomView} from './flamechart-pan-zoom-view'
+import {Hovertip} from './hovertip'
 
 interface FlamechartViewProps {
   flamechart: Flamechart
@@ -826,22 +25,21 @@ interface FlamechartViewProps {
 }
 
 interface FlamechartViewState {
-  hoveredNode: CallTreeNode | null
+  hover: {
+    node: CallTreeNode
+    event: MouseEvent
+  } | null
   selectedNode: CallTreeNode | null
   configSpaceViewportRect: Rect
-  logicalSpaceMouse: Vec2
 }
 
 export class FlamechartView extends ReloadableComponent<FlamechartViewProps, FlamechartViewState> {
-  container: HTMLDivElement | null = null
-
   constructor() {
     super()
     this.state = {
-      hoveredNode: null,
+      hover: null,
       selectedNode: null,
       configSpaceViewportRect: Rect.empty,
-      logicalSpaceMouse: Vec2.zero,
     }
   }
 
@@ -890,11 +88,8 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
     this.setConfigSpaceViewportRect(viewportRect)
   }
 
-  onNodeHover = (node: CallTreeNode | null, logicalSpaceMouse: Vec2) => {
-    this.setState({
-      hoveredNode: node,
-      logicalSpaceMouse: logicalSpaceMouse.plus(new Vec2(0, Sizes.MINIMAP_HEIGHT)),
-    })
+  onNodeHover = (hover: {node: CallTreeNode; event: MouseEvent} | null) => {
+    this.setState({hover})
   }
 
   onNodeClick = (node: CallTreeNode | null) => {
@@ -913,43 +108,22 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
   renderTooltip() {
     if (!this.container) return null
 
-    const {hoveredNode, logicalSpaceMouse} = this.state
-    if (!hoveredNode) return null
-
-    const {width, height} = this.container.getBoundingClientRect()
-
-    const positionStyle: {
-      left?: number
-      right?: number
-      top?: number
-      bottom?: number
-    } = {}
-
-    const OFFSET_FROM_MOUSE = 7
-    if (logicalSpaceMouse.x + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_WIDTH_MAX < width) {
-      positionStyle.left = logicalSpaceMouse.x + OFFSET_FROM_MOUSE
-    } else {
-      positionStyle.right = width - logicalSpaceMouse.x + 1
-    }
-
-    if (logicalSpaceMouse.y + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_HEIGHT_MAX < height) {
-      positionStyle.top = logicalSpaceMouse.y + OFFSET_FROM_MOUSE
-    } else {
-      positionStyle.bottom = height - logicalSpaceMouse.y + 1
-    }
+    const {hover} = this.state
+    if (!hover) return null
+    const {width, height, left, top} = this.container.getBoundingClientRect()
+    const offset = new Vec2(hover.event.clientX - left, hover.event.clientY - top)
 
     return (
-      <div className={css(style.hoverTip)} style={positionStyle}>
-        <div className={css(style.hoverTipRow)}>
-          <span className={css(style.hoverCount)}>
-            {this.formatValue(hoveredNode.getTotalWeight())}
-          </span>{' '}
-          {hoveredNode.frame.name}
-        </div>
-      </div>
+      <Hovertip containerSize={new Vec2(width, height)} offset={offset}>
+        <span className={css(style.hoverCount)}>
+          {this.formatValue(hover.node.getTotalWeight())}
+        </span>{' '}
+        {hover.node.frame.name}
+      </Hovertip>
     )
   }
 
+  container: HTMLDivElement | null = null
   containerRef = (container?: Element) => {
     this.container = (container as HTMLDivElement) || null
   }
@@ -966,7 +140,7 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
 
   render() {
     return (
-      <div className={css(style.fill, style.clip, style.vbox)} ref={this.containerRef}>
+      <div className={css(style.fill, commonStyle.vbox)} ref={this.containerRef}>
         <FlamechartMinimapView
           configSpaceViewportRect={this.state.configSpaceViewportRect}
           transformViewport={this.transformViewport}
@@ -980,13 +154,15 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
           canvasContext={this.props.canvasContext}
           flamechart={this.props.flamechart}
           flamechartRenderer={this.props.flamechartRenderer}
-          setNodeHover={this.onNodeHover}
-          setSelectedNode={this.onNodeClick}
+          renderInverted={false}
+          onNodeHover={this.onNodeHover}
+          onNodeSelect={this.onNodeClick}
           selectedNode={this.state.selectedNode}
           transformViewport={this.transformViewport}
           configSpaceViewportRect={this.state.configSpaceViewportRect}
           setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
         />
+        {this.renderTooltip()}
         {this.state.selectedNode && (
           <FlamechartDetailView
             flamechart={this.props.flamechart}
@@ -994,7 +170,6 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
             selectedNode={this.state.selectedNode}
           />
         )}
-        {this.renderTooltip()}
       </div>
     )
   }
diff --git a/hovertip.tsx b/hovertip.tsx
new file mode 100644
index 0000000..efbd92f
--- /dev/null
+++ b/hovertip.tsx
@@ -0,0 +1,70 @@
+import {ReloadableComponent} from './reloadable'
+import {Vec2} from './math'
+import {Sizes, Colors, FontSize, FontFamily, ZIndex} from './style'
+import {css, StyleSheet} from 'aphrodite'
+import {h} from 'preact'
+
+interface HovertipProps {
+  containerSize: Vec2
+  offset: Vec2
+}
+
+export class Hovertip extends ReloadableComponent<HovertipProps, {}> {
+  render() {
+    const {containerSize, offset} = this.props
+    const width = containerSize.x
+    const height = containerSize.y
+
+    const positionStyle: {
+      left?: number
+      right?: number
+      top?: number
+      bottom?: number
+    } = {}
+
+    const OFFSET_FROM_MOUSE = 7
+    if (offset.x + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_WIDTH_MAX < width) {
+      positionStyle.left = offset.x + OFFSET_FROM_MOUSE
+    } else {
+      positionStyle.right = width - offset.x + 1
+    }
+
+    if (offset.y + OFFSET_FROM_MOUSE + Sizes.TOOLTIP_HEIGHT_MAX < height) {
+      positionStyle.top = offset.y + OFFSET_FROM_MOUSE
+    } else {
+      positionStyle.bottom = height - offset.y + 1
+    }
+
+    return (
+      <div className={css(style.hoverTip)} style={positionStyle}>
+        <div className={css(style.hoverTipRow)}>{this.props.children}</div>
+      </div>
+    )
+  }
+}
+
+const HOVERTIP_PADDING = 2
+
+const style = StyleSheet.create({
+  hoverTip: {
+    position: 'absolute',
+    background: Colors.WHITE,
+    border: '1px solid black',
+    maxWidth: Sizes.TOOLTIP_WIDTH_MAX,
+    paddingTop: HOVERTIP_PADDING,
+    paddingBottom: HOVERTIP_PADDING,
+    pointerEvents: 'none',
+    userSelect: 'none',
+    fontSize: FontSize.LABEL,
+    fontFamily: FontFamily.MONOSPACE,
+    zIndex: ZIndex.HOVERTIP,
+  },
+  hoverTipRow: {
+    textOverflow: 'ellipsis',
+    whiteSpace: 'nowrap',
+    overflowX: 'hidden',
+    paddingLeft: HOVERTIP_PADDING,
+    paddingRight: HOVERTIP_PADDING,
+    maxWidth: Sizes.TOOLTIP_WIDTH_MAX,
+  },
+})
diff --git a/profile-table-view.tsx b/profile-table-view.tsx
index be85908..5bf6050 100644
--- a/profile-table-view.tsx
+++ b/profile-table-view.tsx
@@ -3,7 +3,7 @@ import {StyleSheet, css} from 'aphrodite'
 import {ReloadableComponent} from './reloadable'
 import {Profile, Frame} from './profile'
 import {sortBy, formatPercent} from './utils'
-import {FontSize, Colors, Sizes} from './style'
+import {FontSize, Colors, Sizes, commonStyle} from './style'
 import {ColorChit} from './color-chit'
 import {ScrollableListView, ListItem} from './scrollable-list-view'
 
@@ -23,13 +23,6 @@ export interface SortMethod {
   direction: SortDirection
 }
 
-interface ProfileTableViewProps {
-  profile: Profile
-  getCSSColorForFrame: (frame: Frame) => string
-  sortMethod: SortMethod
-  setSortMethod: (sortMethod: SortMethod) => void
-}
-
 interface HBarProps {
   perc: number
 }
@@ -70,19 +63,42 @@ class SortIcon extends Component<SortIconProps, {}> {
   }
 }
 
+interface ProfileTableViewProps {
+  profile: Profile
+  selectedFrame: Frame | null
+  setSelectedFrame: (frame: Frame | null) => void
+  getCSSColorForFrame: (frame: Frame) => string
+  sortMethod: SortMethod
+  setSortMethod: (sortMethod: SortMethod) => void
+}
+
 export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps, void> {
+  setSelectedFrame = (frame: Frame | null) => {
+    this.props.setSelectedFrame(frame)
+  }
+
   renderRow(frame: Frame, index: number) {
-    const {profile} = this.props
+    const {profile, selectedFrame} = this.props
 
     const totalWeight = frame.getTotalWeight()
     const selfWeight = frame.getSelfWeight()
     const totalPerc = 100.0 * totalWeight / profile.getTotalNonIdleWeight()
     const selfPerc = 100.0 * selfWeight / profile.getTotalNonIdleWeight()
 
+    const selected = frame === selectedFrame
+
     // We intentionally use index rather than frame.key here as the tr key
     // in order to re-use rows when sorting rather than creating all new elements.
     return (
-      <tr key={`${index}`} className={css(style.tableRow, index % 2 == 0 && style.tableRowEven)}>
+      <tr
+        key={`${index}`}
+        onClick={this.setSelectedFrame.bind(null, frame)}
+        className={css(
+          style.tableRow,
+          index % 2 == 0 && style.tableRowEven,
+          selected && style.tableRowSelected,
+        )}
+      >
         <td className={css(style.numericCell)}>
           {profile.formatValue(totalWeight)} ({formatPercent(totalPerc)})
           <HBarDisplay perc={totalPerc} />
@@ -172,7 +188,7 @@ export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps,
     const listItems: ListItem[] = frameList.map(f => ({size: Sizes.FRAME_HEIGHT}))
 
     return (
-      <div className={css(style.vbox, style.profileTableView)}>
+      <div className={css(commonStyle.vbox, style.profileTableView)}>
         <table className={css(style.tableView)}>
           <thead className={css(style.tableHeader)}>
             <tr>
@@ -227,11 +243,6 @@ const style = StyleSheet.create({
     background: Colors.WHITE,
     height: '100%',
   },
-  vbox: {
-    display: 'flex',
-    flexDirection: 'column',
-    position: 'relative',
-  },
   scrollView: {
     overflowY: 'auto',
     overflowX: 'hidden',
@@ -259,6 +270,10 @@ const style = StyleSheet.create({
   tableRowEven: {
     background: Colors.OFF_WHITE,
   },
+  tableRowSelected: {
+    background: Colors.DARK_BLUE,
+    color: Colors.WHITE,
+  },
   numericCell: {
     textOverflow: 'ellipsis',
     overflow: 'hidden',
diff --git a/profile.ts b/profile.ts
index 9858a9d..2ded554 100644
--- a/profile.ts
+++ b/profile.ts
@@ -38,6 +38,11 @@ export class HasWeights {
   addToSelfWeight(delta: number) {
     this.selfWeight += delta
   }
+
+  overwriteWeightWith(other: HasWeights) {
+    this.selfWeight = other.selfWeight
+    this.totalWeight = other.totalWeight
+  }
 }
 
 export class Frame extends HasWeights {
@@ -218,7 +223,7 @@ export class Profile {
     this.frames.forEach(fn)
   }
 
-  flattenRecursion(): Profile {
+  getProfileWithRecursionFlattened(): Profile {
     const builder = new CallTreeProfileBuilder()
 
     const stack: (CallTreeNode | null)[] = []
@@ -246,9 +251,105 @@ export class Profile {
     const flattenedProfile = builder.build()
     flattenedProfile.name = this.name
     flattenedProfile.valueFormatter = this.valueFormatter
+
+    // When constructing a profile with recursion flattened,
+    // counter-intuitive things can happen to "self time" measurements
+    // for functions.
+    // For example, given the following list of stacks w/ weights:
+    //
+    // a 1
+    // a;b;a 1
+    // a;b;a;b;a 1
+    // a;b;a 1
+    //
+    // The resulting profile with recursion flattened out will look like this:
+    //
+    // a 1
+    // a;b 3
+    //
+    // Which is useful to view, but it's counter-intuitive to move self-time
+    // for frames around, since analyzing the self-time of functions is an important
+    // thing to be able to do accurately, and we don't want this to change when recursion
+    // is flattened. To work around that, we'll just copy the weights directly from the
+    // un-flattened profile.
+    this.forEachFrame(f => {
+      flattenedProfile.frames.getOrInsert(f).overwriteWeightWith(f)
+    })
+
     return flattenedProfile
   }
 
+  getInvertedProfileForCallersOf(focalFrameInfo: FrameInfo): Profile {
+    const focalFrame = Frame.getOrInsert(this.frames, focalFrameInfo)
+    const builder = new StackListProfileBuilder()
+
+    // TODO(jlfwong): Could construct this at profile
+    // construction time rather than on demand.
+    const nodes: CallTreeNode[] = []
+
+    function visit(node: CallTreeNode) {
+      if (node.frame === focalFrame) {
+        nodes.push(node)
+      } else {
+        for (let child of node.children) {
+          visit(child)
+        }
+      }
+    }
+
+    visit(this.appendOrderCalltreeRoot)
+
+    for (let node of nodes) {
+      const stack: FrameInfo[] = []
+      for (let n: CallTreeNode | null = node; n != null && n.frame !== Frame.root; n = n.parent) {
+        stack.push(n.frame)
+      }
+      builder.appendSample(stack, node.getTotalWeight())
+    }
+
+    const ret = builder.build()
+    ret.name = this.name
+    ret.valueFormatter = this.valueFormatter
+    return ret
+  }
+
+  getProfileForCalleesOf(focalFrameInfo: FrameInfo): Profile {
+    const focalFrame = Frame.getOrInsert(this.frames, focalFrameInfo)
+    const builder = new StackListProfileBuilder()
+
+    function recordSubtree(focalFrameNode: CallTreeNode) {
+      const stack: FrameInfo[] = []
+
+      function visit(node: CallTreeNode) {
+        stack.push(node.frame)
+        builder.appendSample(stack, node.getSelfWeight())
+        for (let child of node.children) {
+          visit(child)
+        }
+        stack.pop()
+      }
+
+      visit(focalFrameNode)
+    }
+
+    function findCalls(node: CallTreeNode) {
+      if (node.frame === focalFrame) {
+        recordSubtree(node)
+      } else {
+        for (let child of node.children) {
+          findCalls(child)
+        }
+      }
+    }
+
+    findCalls(this.appendOrderCalltreeRoot)
+
+    const ret = builder.build()
+    ret.name = this.name
+    ret.valueFormatter = this.valueFormatter
+    return ret
+  }
+
   // Demangle symbols for readability
   async demangle() {
     let demangleCpp: ((name: string) => string) | null = null
diff --git a/sandwich-view.tsx b/sandwich-view.tsx
new file mode 100644
index 0000000..c1c2b27
--- /dev/null
+++ b/sandwich-view.tsx
@@ -0,0 +1,350 @@
+import {ReloadableComponent} from './reloadable'
+import {Profile, Frame, CallTreeNode} from './profile'
+import {StyleSheet, css} from 'aphrodite'
+import {SortMethod, ProfileTableView} from './profile-table-view'
+import {h} from 'preact'
+import {commonStyle, Sizes, Colors, FontSize} from './style'
+import {CanvasContext} from './canvas-context'
+import {FlamechartRenderer, FlamechartRowAtlasKey} from './flamechart-renderer'
+import {Flamechart} from './flamechart'
+import {RowAtlas} from './row-atlas'
+import {Rect, AffineTransform, Vec2} from './math'
+import {FlamechartPanZoomView, FlamechartPanZoomViewProps} from './flamechart-pan-zoom-view'
+import {noop, formatPercent} from './utils'
+import {Hovertip} from './hovertip'
+
+interface FlamechartWrapperProps {
+  flamechart: Flamechart
+  canvasContext: CanvasContext
+  flamechartRenderer: FlamechartRenderer
+  renderInverted: boolean
+}
+
+interface FlamechartWrapperState {
+  hover: {
+    node: CallTreeNode
+    event: MouseEvent
+  } | null
+  configSpaceViewportRect: Rect
+}
+
+export class FlamechartWrapper extends ReloadableComponent<
+  FlamechartWrapperProps,
+  FlamechartWrapperState
+> {
+  constructor(props: FlamechartWrapperProps) {
+    super(props)
+    this.state = {
+      hover: null,
+      configSpaceViewportRect: Rect.empty,
+    }
+  }
+
+  private clampViewportToFlamegraph(viewportRect: Rect, flamegraph: Flamechart, inverted: boolean) {
+    const configSpaceSize = new Vec2(flamegraph.getTotalWeight(), flamegraph.getLayers().length)
+
+    let configSpaceOriginBounds = new Rect(
+      new Vec2(0, inverted ? 0 : -1),
+      Vec2.max(new Vec2(0, 0), configSpaceSize.minus(viewportRect.size).plus(new Vec2(0, 1))),
+    )
+
+    const minConfigSpaceViewportRectWidth = Math.min(
+      flamegraph.getTotalWeight(),
+      3 * flamegraph.getMinFrameWidth(),
+    )
+
+    const configSpaceSizeBounds = new Rect(
+      new Vec2(minConfigSpaceViewportRectWidth, viewportRect.height()),
+      new Vec2(configSpaceSize.x, viewportRect.height()),
+    )
+
+    return new Rect(
+      configSpaceOriginBounds.closestPointTo(viewportRect.origin),
+      configSpaceSizeBounds.closestPointTo(viewportRect.size),
+    )
+  }
+
+  private setConfigSpaceViewportRect = (viewportRect: Rect) => {
+    this.setState({
+      configSpaceViewportRect: this.clampViewportToFlamegraph(
+        viewportRect,
+        this.props.flamechart,
+        this.props.renderInverted,
+      ),
+    })
+  }
+
+  private transformViewport = (transform: AffineTransform) => {
+    this.setConfigSpaceViewportRect(transform.transformRect(this.state.configSpaceViewportRect))
+  }
+
+  private formatValue(weight: number) {
+    const totalWeight = this.props.flamechart.getTotalWeight()
+    const percent = 100 * weight / totalWeight
+    const formattedPercent = formatPercent(percent)
+    return `${this.props.flamechart.formatValue(weight)} (${formattedPercent})`
+  }
+
+  private renderTooltip() {
+    if (!this.container) return null
+
+    const {hover} = this.state
+    if (!hover) return null
+    const {width, height, left, top} = this.container.getBoundingClientRect()
+    const offset = new Vec2(hover.event.clientX - left, hover.event.clientY - top)
+
+    return (
+      <Hovertip containerSize={new Vec2(width, height)} offset={offset}>
+        <span className={css(style.hoverCount)}>
+          {this.formatValue(hover.node.getTotalWeight())}
+        </span>{' '}
+        Hello
+        {hover.node.frame.name}
+      </Hovertip>
+    )
+  }
+
+  container: HTMLDivElement | null = null
+  containerRef = (container?: Element) => {
+    this.container = (container as HTMLDivElement) || null
+  }
+
+  private setNodeHover = (hover: {node: CallTreeNode; event: MouseEvent} | null) => {
+    this.setState({hover})
+  }
+
+  render() {
+    const props: FlamechartPanZoomViewProps = {
+      ...(this.props as FlamechartWrapperProps),
+      selectedNode: null,
+      onNodeHover: this.setNodeHover,
+      onNodeSelect: noop,
+      configSpaceViewportRect: this.state.configSpaceViewportRect,
+      setConfigSpaceViewportRect: this.setConfigSpaceViewportRect,
+      transformViewport: this.transformViewport,
+    }
+    return (
+      <div
+        className={css(commonStyle.fillY, commonStyle.fillX, commonStyle.vbox)}
+        ref={this.containerRef}
+      >
+        <FlamechartPanZoomView {...props} />
+        {this.renderTooltip()}
+      </div>
+    )
+  }
+}
+
+interface SandwichViewProps {
+  profile: Profile
+  flattenRecursion: boolean
+
+  // TODO(jlfwong): It's kind of awkward requiring both of these
+  getColorBucketForFrame: (frame: Frame) => number
+  getCSSColorForFrame: (frame: Frame) => string
+
+  sortMethod: SortMethod
+  setSortMethod: (sortMethod: SortMethod) => void
+  canvasContext: CanvasContext
+  rowAtlas: RowAtlas<FlamechartRowAtlasKey>
+}
+
+interface CallerCalleeState {
+  selectedFrame: Frame
+
+  invertedCallerFlamegraph: Flamechart
+  invertedCallerFlamegraphRenderer: FlamechartRenderer
+
+  calleeFlamegraph: Flamechart
+  calleeFlamegraphRenderer: FlamechartRenderer
+}
+
+interface SandwichViewState {
+  callerCallee: CallerCalleeState | null
+}
+
+export class SandwichView extends ReloadableComponent<SandwichViewProps, SandwichViewState> {
+  constructor(props: SandwichViewProps) {
+    super(props)
+    this.state = {
+      callerCallee: null,
+    }
+  }
+
+  private setSelectedFrame = (
+    selectedFrame: Frame | null,
+    props: SandwichViewProps = this.props,
+  ) => {
+    const {profile, canvasContext, rowAtlas, getColorBucketForFrame, flattenRecursion} = props
+
+    if (!selectedFrame) {
+      this.setState({callerCallee: null})
+      return
+    }
+
+    let invertedCallerProfile = profile.getInvertedProfileForCallersOf(selectedFrame)
+    if (flattenRecursion) {
+      invertedCallerProfile = invertedCallerProfile.getProfileWithRecursionFlattened()
+    }
+
+    const invertedCallerFlamegraph = new Flamechart({
+      getTotalWeight: invertedCallerProfile.getTotalNonIdleWeight.bind(invertedCallerProfile),
+      forEachCall: invertedCallerProfile.forEachCallGrouped.bind(invertedCallerProfile),
+      formatValue: invertedCallerProfile.formatValue.bind(invertedCallerProfile),
+      getColorBucketForFrame,
+    })
+    const invertedCallerFlamegraphRenderer = new FlamechartRenderer(
+      canvasContext,
+      rowAtlas,
+      invertedCallerFlamegraph,
+      {inverted: true},
+    )
+
+    let calleeProfile = profile.getProfileForCalleesOf(selectedFrame)
+
+    if (flattenRecursion) {
+      calleeProfile = calleeProfile.getProfileWithRecursionFlattened()
+    }
+
+    const calleeFlamegraph = new Flamechart({
+      getTotalWeight: calleeProfile.getTotalNonIdleWeight.bind(calleeProfile),
+      forEachCall: calleeProfile.forEachCallGrouped.bind(calleeProfile),
+      formatValue: calleeProfile.formatValue.bind(calleeProfile),
+      getColorBucketForFrame,
+    })
+    const calleeFlamegraphRenderer = new FlamechartRenderer(
+      canvasContext,
+      rowAtlas,
+      calleeFlamegraph,
+    )
+
+    this.setState({
+      callerCallee: {
+        selectedFrame,
+        invertedCallerFlamegraph,
+        invertedCallerFlamegraphRenderer,
+        calleeFlamegraph,
+        calleeFlamegraphRenderer,
+      },
+    })
+  }
+
+  onWindowKeyPress = (ev: KeyboardEvent) => {
+    if (ev.key === 'Escape') {
+      this.setState({callerCallee: null})
+    }
+  }
+
+  componentWillReceiveProps(nextProps: SandwichViewProps) {
+    if (this.props.flattenRecursion !== nextProps.flattenRecursion) {
+      if (this.state.callerCallee) {
+        this.setSelectedFrame(this.state.callerCallee.selectedFrame, nextProps)
+      }
+    }
+  }
+
+  componentDidMount() {
+    window.addEventListener('keydown', this.onWindowKeyPress)
+  }
+  componentWillUnmount() {
+    window.removeEventListener('keydown', this.onWindowKeyPress)
+  }
+
+  render() {
+    const {canvasContext} = this.props
+    const {callerCallee} = this.state
+
+    let selectedFrame: Frame | null = null
+    let flamegraphViews: JSX.Element | null = null
+
+    if (callerCallee) {
+      selectedFrame = callerCallee.selectedFrame
+      flamegraphViews = (
+        <div className={css(commonStyle.fillY, style.callersAndCallees, commonStyle.vbox)}>
+          <div className={css(commonStyle.hbox, style.panZoomViewWraper)}>
+            <div className={css(style.flamechartLabelParent)}>
+              <div className={css(style.flamechartLabel)}>Callers</div>
+            </div>
+            <FlamechartWrapper
+              flamechart={callerCallee.invertedCallerFlamegraph}
+              canvasContext={canvasContext}
+              flamechartRenderer={callerCallee.invertedCallerFlamegraphRenderer}
+              renderInverted={true}
+            />
+          </div>
+          <div className={css(style.divider)} />
+          <div className={css(commonStyle.hbox, style.panZoomViewWraper)}>
+            <div className={css(style.flamechartLabelParent, style.flamechartLabelParentBottom)}>
+              <div className={css(style.flamechartLabel, style.flamechartLabelBottom)}>Callees</div>
+            </div>
+            <FlamechartWrapper
+              flamechart={callerCallee.calleeFlamegraph}
+              canvasContext={canvasContext}
+              flamechartRenderer={callerCallee.calleeFlamegraphRenderer}
+              renderInverted={false}
+            />
+          </div>
+        </div>
+      )
+    }
+
+    return (
+      <div className={css(commonStyle.hbox, commonStyle.fillY)}>
+        <div className={css(style.tableView)}>
+          <ProfileTableView
+            selectedFrame={selectedFrame}
+            setSelectedFrame={this.setSelectedFrame}
+            profile={this.props.profile}
+            getCSSColorForFrame={this.props.getCSSColorForFrame}
+            sortMethod={this.props.sortMethod}
+            setSortMethod={this.props.setSortMethod}
+          />
+        </div>
+        {flamegraphViews}
+      </div>
+    )
+  }
+}
+
+const style = StyleSheet.create({
+  tableView: {
+    flex: 1,
+  },
+  panZoomViewWraper: {
+    flex: 1,
+  },
+  flamechartLabelParent: {
+    display: 'flex',
+    flexDirection: 'column',
+    justifyContent: 'flex-end',
+    alignItems: 'flex-start',
+    fontSize: FontSize.TITLE,
+    width: FontSize.TITLE * 1.2,
+    borderRight: `1px solid ${Colors.LIGHT_GRAY}`,
+  },
+  flamechartLabelParentBottom: {
+    justifyContent: 'flex-start',
+  },
+  flamechartLabel: {
+    transform: 'rotate(-90deg)',
+    transformOrigin: '50% 50% 0',
+    width: FontSize.TITLE * 1.2,
+    flexShrink: 1,
+  },
+  flamechartLabelBottom: {
+    transform: 'rotate(-90deg)',
+    display: 'flex',
+    justifyContent: 'flex-end',
+  },
+  callersAndCallees: {
+    flex: 1,
+    borderLeft: `${Sizes.SEPARATOR_HEIGHT}px solid ${Colors.LIGHT_GRAY}`,
+  },
+  divider: {
+    height: 2,
+    background: Colors.LIGHT_GRAY,
+  },
+  hoverCount: {
+    color: Colors.GREEN,
+  },
+})
diff --git a/style.ts b/style.ts
index c06cbe5..00296d5 100644
--- a/style.ts
+++ b/style.ts
@@ -1,3 +1,5 @@
+import {StyleSheet} from 'aphrodite'
+
 export enum FontFamily {
   MONOSPACE = '"Source Code Pro", Courier, monospace',
 }
@@ -32,3 +34,28 @@ export enum Sizes {
   TOOLBAR_HEIGHT = 20,
   TOOLBAR_TAB_HEIGHT = TOOLBAR_HEIGHT - SEPARATOR_HEIGHT,
 }
+
+export enum ZIndex {
+  HOVERTIP = 1,
+}
+
+export const commonStyle = StyleSheet.create({
+  fillY: {
+    height: '100%',
+  },
+  fillX: {
+    width: '100%',
+  },
+  hbox: {
+    display: 'flex',
+    flexDirection: 'row',
+    position: 'relative',
+    overflow: 'hidden',
+  },
+  vbox: {
+    display: 'flex',
+    flexDirection: 'column',
+    position: 'relative',
+    overflow: 'hidden',
+  },
+})
diff --git a/text-utils.ts b/text-utils.ts
new file mode 100644
index 0000000..7b170b7
--- /dev/null
+++ b/text-utils.ts
@@ -0,0 +1,39 @@
+import {binarySearch} from './utils'
+
+export const ELLIPSIS = '\u2026'
+
+// NOTE: This blindly assumes the same result across contexts.
+const measureTextCache = new Map<string, number>()
+
+let lastDevicePixelRatio = -1
+export function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: string): number {
+  if (window.devicePixelRatio !== lastDevicePixelRatio) {
+    // This cache is no longer valid!
+    measureTextCache.clear()
+    lastDevicePixelRatio = window.devicePixelRatio
+  }
+  if (!measureTextCache.has(text)) {
+    measureTextCache.set(text, ctx.measureText(text).width)
+  }
+  return measureTextCache.get(text)!
+}
+
+function buildTrimmedText(text: string, length: number) {
+  const prefixLength = Math.floor(length / 2)
+  const prefix = text.substr(0, prefixLength)
+  const suffix = text.substr(text.length - prefixLength, prefixLength)
+  return prefix + ELLIPSIS + suffix
+}
+
+export function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
+  if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text
+  const [lo] = binarySearch(
+    0,
+    text.length,
+    n => {
+      return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n))
+    },
+    maxWidth,
+  )
+  return buildTrimmedText(text, lo)
+}
diff --git a/utils.ts b/utils.ts
index 9dc8091..82b4f25 100644
--- a/utils.ts
+++ b/utils.ts
@@ -85,22 +85,6 @@ export function zeroPad(s: string, width: number) {
   return new Array(Math.max(width - s.length, 0) + 1).join('0') + s
 }
 
-// NOTE: This blindly assumes the same result across contexts.
-const measureTextCache = new Map<string, number>()
-
-let lastDevicePixelRatio = -1
-export function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: string): number {
-  if (window.devicePixelRatio !== lastDevicePixelRatio) {
-    // This cache is no longer valid!
-    measureTextCache.clear()
-    lastDevicePixelRatio = window.devicePixelRatio
-  }
-  if (!measureTextCache.has(text)) {
-    measureTextCache.set(text, ctx.measureText(text).width)
-  }
-  return measureTextCache.get(text)!
-}
-
 export function formatPercent(percent: number) {
   let formattedPercent = `${percent.toFixed(0)}%`
   if (percent === 100) formattedPercent = '100%'
@@ -118,3 +102,22 @@ export function fract(x: number) {
 export function triangle(x: number) {
   return 2.0 * Math.abs(fract(x) - 0.5) - 1.0
 }
+
+export function binarySearch(
+  lo: number,
+  hi: number,
+  f: (val: number) => number,
+  target: number,
+  targetRangeSize = 1,
+): [number, number] {
+  console.assert(!isNaN(targetRangeSize) && !isNaN(target))
+  while (true) {
+    if (hi - lo <= targetRangeSize) return [lo, hi]
+    const mid = (hi + lo) / 2
+    const val = f(mid)
+    if (val < target) lo = mid
+    else hi = mid
+  }
+}
+
+export function noop(...args: any[]) {}


Test patch:
diff --git a/profile.test.ts b/profile.test.ts
index e09c8fc..9467972 100644
--- a/profile.test.ts
+++ b/profile.test.ts
@@ -21,12 +21,7 @@ const fc = getFrameInfo('c')
 const fd = getFrameInfo('d')
 const fe = getFrameInfo('e')
 
-function verifyProfile(profile: Profile) {
-  const allFrameKeys = new Set([fa, fb, fc, fd, fe].map(f => f.key))
-  const framesInProfile = new Set<string | number>()
-  profile.forEachFrame(f => framesInProfile.add(f.key))
-  expect(allFrameKeys).toEqual(framesInProfile)
-
+function toStackList(profile: Profile, grouped: boolean): string[] {
   let stackList: string[] = []
   const curStack: (number | string)[] = []
   let lastValue = 0
@@ -47,8 +42,21 @@ function verifyProfile(profile: Profile) {
     curStack.pop()
   }
 
-  profile.forEachCall(openFrame, closeFrame)
-  expect(stackList).toEqual([
+  if (grouped) {
+    profile.forEachCallGrouped(openFrame, closeFrame)
+  } else {
+    profile.forEachCall(openFrame, closeFrame)
+  }
+  return stackList
+}
+
+function verifyProfile(profile: Profile) {
+  const allFrameKeys = new Set([fa, fb, fc, fd, fe].map(f => f.key))
+  const framesInProfile = new Set<string | number>()
+  profile.forEachFrame(f => framesInProfile.add(f.key))
+  expect(allFrameKeys).toEqual(framesInProfile)
+
+  expect(toStackList(profile, false)).toEqual([
     // prettier-ignore
     'a',
     'a;b',
@@ -62,10 +70,7 @@ function verifyProfile(profile: Profile) {
     'a',
   ])
 
-  lastValue = 0
-  stackList = []
-  profile.forEachCallGrouped(openFrame, closeFrame)
-  expect(stackList).toEqual([
+  expect(toStackList(profile, true)).toEqual([
     // prettier-ignore
     'a;b;e',
     'a;b;b',
@@ -75,12 +80,8 @@ function verifyProfile(profile: Profile) {
     'a',
   ])
 
-  const flattened = profile.flattenRecursion()
-
-  lastValue = 0
-  stackList = []
-  flattened.forEachCall(openFrame, closeFrame)
-  expect(stackList).toEqual([
+  const flattened = profile.getProfileWithRecursionFlattened()
+  expect(toStackList(flattened, false)).toEqual([
     // prettier-ignore
     'a',
     'a;b',
@@ -93,10 +94,7 @@ function verifyProfile(profile: Profile) {
     'a',
   ])
 
-  lastValue = 0
-  stackList = []
-  flattened.forEachCallGrouped(openFrame, closeFrame)
-  expect(stackList).toEqual([
+  expect(toStackList(flattened, true)).toEqual([
     // prettier-ignore
     'a;b;e',
     'a;b;c',
@@ -168,3 +166,97 @@ test('CallTreeProfileBuilder', () => {
   const profile = b.build()
   verifyProfile(profile)
 })
+
+test('getInvertedProfileForCallersOf', () => {
+  const b = new StackListProfileBuilder()
+
+  const samples = [
+    // prettier-ignore
+    [fb],
+    [fa, fb],
+    [fa, fb, fc],
+    [fa],
+    [fa, fb, fd],
+    [fa],
+    [fd, fb],
+  ]
+  samples.forEach(stack => {
+    b.appendSample(stack, 1)
+  })
+
+  const profile = b.build()
+  const inverted = profile.getInvertedProfileForCallersOf(fb)
+
+  expect(toStackList(inverted, false)).toEqual([
+    // prettier-ignore
+    'b',
+    'b;a',
+    'b;d',
+  ])
+})
+
+test('getProfileForCalleesOf', () => {
+  const b = new StackListProfileBuilder()
+
+  const samples = [
+    // prettier-ignore
+    [fb],
+    [fa, fb],
+    [fa, fb, fc],
+    [fa],
+    [fa, fb, fd],
+    [fa],
+    [fd, fb],
+  ]
+  samples.forEach(stack => {
+    b.appendSample(stack, 1)
+  })
+
+  const profile = b.build()
+  const inverted = profile.getProfileForCalleesOf(fb)
+
+  expect(toStackList(inverted, false)).toEqual([
+    // prettier-ignore
+    'b',
+    'b;c',
+    'b;d',
+    'b',
+  ])
+})
+
+test('getProfileWithRecursionFlattened', () => {
+  const b = new StackListProfileBuilder()
+
+  const samples = [
+    // prettier-ignore
+    [fa],
+    [fa, fb, fa],
+    [fa, fb, fa, fb, fa],
+    [fa, fb, fa],
+  ]
+  samples.forEach(stack => {
+    b.appendSample(stack, 1)
+  })
+
+  const profile = b.build()
+  const inverted = profile.getProfileWithRecursionFlattened()
+
+  expect(toStackList(inverted, false)).toEqual([
+    // prettier-ignore
+    'a',
+    'a;b',
+  ])
+
+  const framesInProfile = new Set<string | number>()
+  inverted.forEachFrame(f => {
+    if (f.key === fa.key) {
+      expect(f.getSelfWeight()).toEqual(4)
+    }
+    if (f.key === fb.key) {
+      expect(f.getSelfWeight()).toEqual(0)
+    }
+    framesInProfile.add(f.key)
+  })
+  const allFrameKeys = new Set([fa, fb].map(f => f.key))
+  expect(allFrameKeys).toEqual(framesInProfile)
+})
diff --git a/utils.test.ts b/utils.test.ts
index b2f6694..f760624 100644
--- a/utils.test.ts
+++ b/utils.test.ts
@@ -9,6 +9,7 @@ import {
   zeroPad,
   formatPercent,
   KeyedSet,
+  binarySearch,
 } from './utils'
 
 test('sortBy', () => {
@@ -101,3 +102,10 @@ test('formatPercent', () => {
   expect(formatPercent(99.9)).toBe('>99%')
   expect(formatPercent(100)).toBe('100%')
 })
+
+test('binarySearch', () => {
+  const [lo, hi] = binarySearch(0, 10, n => Math.log(n), 1, 0.0001)
+  expect(lo).toBeCloseTo(Math.E, 4)
+  expect(lo).toBeLessThan(Math.E)
+  expect(hi).toBeGreaterThan(Math.E)
+})