Repository: jlfwong/speedscope
Pull request #59 (2018-06-13 06:34:20)

Introduce a simple table view
This is a pretty barebones table view. Eventually I'd like this to provide some better way of identifying problem areas by seeing flamegraphs of all callers and all callees, but this is better than nothing.

![image](https://user-images.githubusercontent.com/150329/41460197-b4f6f0dc-7040-11e8-9390-69bb460bd8f1.png)

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

* **9** of **69**   **(13.04%)**  changed or added relevant lines in **5** files are covered.
* **3** unchanged lines in **1** file lost coverage.
* Overall coverage decreased (**-0.02%**) to **37.847%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [flamechart-view.tsx](https://coveralls.io/builds/17461601/source?filename=flamechart-view.tsx#L9) | 0 | 1 | 0.0%
| [profile-table-view.tsx](https://coveralls.io/builds/17461601/source?filename=profile-table-view.tsx#L1) | 0 | 19 | 0.0%
| [application.tsx](https://coveralls.io/builds/17461601/source?filename=application.tsx#L24) | 0 | 40 | 0.0%
<!-- | **Total:** | **9** | **69** | **13.04%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [application.tsx](https://coveralls.io/builds/17461601/source?filename=application.tsx#L269) | 3 | 0.0% |
<!-- | **Total:** | **3** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/17461601/badge)](https://coveralls.io/builds/17461601) |
| :-- | --: |
| Change from base [Build 109](https://coveralls.io/builds/17396154): |  -0.02% |
| Covered Lines: | 966 |
| Relevant Lines: | 2446 |

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

jlfwong: Need to fix layout in Firefox before I can land this

Patch:
diff --git a/application.tsx b/application.tsx
index ac14fee..36bd527 100644
--- a/application.tsx
+++ b/application.tsx
@@ -19,31 +19,40 @@ import {CanvasContext} from './canvas-context'
 import {Profile, Frame} from './profile'
 import {Flamechart} from './flamechart'
 import {FlamechartView} from './flamechart-view'
-import {FontFamily, FontSize, Colors} from './style'
+import {FontFamily, FontSize, Colors, Sizes} from './style'
 import {getHashParams, HashParams} from './hash-params'
+import {ProfileTableView, SortMethod, SortField, SortDirection} from './profile-table-view'
+import {triangle} from './utils'
+import {Color} from './color'
 
 declare function require(x: string): any
 const exampleProfileURL = require('./sample/profiles/stackcollapse/perf-vertx-stacks-01-collapsed-all.txt')
 
-const enum SortOrder {
-  CHRONO,
-  LEFT_HEAVY,
+const enum ViewMode {
+  CHRONO_FLAME_CHART,
+  LEFT_HEAVY_FLAME_GRAPH,
+  TABLE_VIEW,
 }
 
 interface ApplicationState {
   profile: Profile | null
-  flamechart: Flamechart | null
-  flamechartRenderer: FlamechartRenderer | null
-  sortedFlamechart: Flamechart | null
-  sortedFlamechartRenderer: FlamechartRenderer | null
-  sortOrder: SortOrder
+
+  chronoFlamechart: Flamechart | null
+  chronoFlamechartRenderer: FlamechartRenderer | null
+
+  leftHeavyFlamegraph: Flamechart | null
+  leftHeavyFlamegraphRenderer: FlamechartRenderer | null
+
+  tableSortMethod: SortMethod
+
+  viewMode: ViewMode
   dragActive: boolean
   loading: boolean
   error: boolean
 }
 
 interface ToolbarProps extends ApplicationState {
-  setSortOrder(order: SortOrder): void
+  setViewMode(order: ViewMode): void
 }
 
 function importProfile(fileName: string, contents: string): Profile | null {
@@ -110,11 +119,15 @@ function importProfile(fileName: string, contents: string): Profile | null {
 
 export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
   setTimeOrder = () => {
-    this.props.setSortOrder(SortOrder.CHRONO)
+    this.props.setViewMode(ViewMode.CHRONO_FLAME_CHART)
   }
 
   setLeftHeavyOrder = () => {
-    this.props.setSortOrder(SortOrder.LEFT_HEAVY)
+    this.props.setViewMode(ViewMode.LEFT_HEAVY_FLAME_GRAPH)
+  }
+
+  setTableView = () => {
+    this.props.setViewMode(ViewMode.TABLE_VIEW)
   }
 
   render() {
@@ -144,7 +157,7 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
           <div
             className={css(
               style.toolbarTab,
-              this.props.sortOrder === SortOrder.CHRONO && style.toolbarTabActive,
+              this.props.viewMode === ViewMode.CHRONO_FLAME_CHART && style.toolbarTabActive,
             )}
             onClick={this.setTimeOrder}
           >
@@ -153,16 +166,24 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
           <div
             className={css(
               style.toolbarTab,
-              this.props.sortOrder === SortOrder.LEFT_HEAVY && style.toolbarTabActive,
+              this.props.viewMode === ViewMode.LEFT_HEAVY_FLAME_GRAPH && style.toolbarTabActive,
             )}
             onClick={this.setLeftHeavyOrder}
           >
             <span className={css(style.emoji)}>⬅️</span>Left Heavy
           </div>
+          <div
+            className={css(
+              style.toolbarTab,
+              this.props.viewMode === ViewMode.TABLE_VIEW && style.toolbarTabActive,
+            )}
+            onClick={this.setTableView}
+          >
+            <span className={css(style.emoji)}>📒</span>Table View
+          </div>
           {help}
         </div>
         {this.props.profile.getName()}
-        <div className={css(style.toolbarRight)}>🔬speedscope</div>
       </div>
     )
   }
@@ -234,28 +255,39 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
       dragActive: false,
       error: false,
       profile: null,
-      flamechart: null,
-      flamechartRenderer: null,
-      sortedFlamechart: null,
-      sortedFlamechartRenderer: null,
-      sortOrder: SortOrder.CHRONO,
+
+      chronoFlamechart: null,
+      chronoFlamechartRenderer: null,
+
+      leftHeavyFlamegraph: null,
+      leftHeavyFlamegraphRenderer: null,
+
+      tableSortMethod: {
+        field: SortField.SELF,
+        direction: SortDirection.DESCENDING,
+      },
+
+      viewMode: ViewMode.CHRONO_FLAME_CHART,
     }
   }
 
   serialize() {
     const result = super.serialize()
-    delete result.state.flamechartRenderer
-    delete result.state.sortedFlamechartRenderer
+    delete result.state.chronoFlamechartRenderer
+    delete result.state.leftHeavyFlamegraphRenderer
     return result
   }
 
   rehydrate(serialized: SerializedComponent<ApplicationState>) {
     super.rehydrate(serialized)
-    const {flamechart, sortedFlamechart} = serialized.state
-    if (this.canvasContext && flamechart && sortedFlamechart) {
+    const {chronoFlamechart, leftHeavyFlamegraph} = serialized.state
+    if (this.canvasContext && chronoFlamechart && leftHeavyFlamegraph) {
       this.setState({
-        flamechartRenderer: new FlamechartRenderer(this.canvasContext, flamechart),
-        sortedFlamechartRenderer: new FlamechartRenderer(this.canvasContext, sortedFlamechart),
+        chronoFlamechartRenderer: new FlamechartRenderer(this.canvasContext, chronoFlamechart),
+        leftHeavyFlamegraphRenderer: new FlamechartRenderer(
+          this.canvasContext,
+          leftHeavyFlamegraph,
+        ),
       })
     }
   }
@@ -309,21 +341,24 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
       return frameToColorBucket.get(frame) || 0
     }
 
-    const flamechart = new Flamechart({
+    const chronoFlamechart = new Flamechart({
       getTotalWeight: profile.getTotalWeight.bind(profile),
       forEachCall: profile.forEachCall.bind(profile),
       formatValue: profile.formatValue.bind(profile),
       getColorBucketForFrame,
     })
-    const flamechartRenderer = new FlamechartRenderer(this.canvasContext, flamechart)
+    const chronoFlamechartRenderer = new FlamechartRenderer(this.canvasContext, chronoFlamechart)
 
-    const sortedFlamechart = new Flamechart({
+    const leftHeavyFlamegraph = new Flamechart({
       getTotalWeight: profile.getTotalNonIdleWeight.bind(profile),
       forEachCall: profile.forEachCallGrouped.bind(profile),
       formatValue: profile.formatValue.bind(profile),
       getColorBucketForFrame,
     })
-    const sortedFlamechartRenderer = new FlamechartRenderer(this.canvasContext, sortedFlamechart)
+    const leftHeavyFlamegraphRenderer = new FlamechartRenderer(
+      this.canvasContext,
+      leftHeavyFlamegraph,
+    )
 
     console.timeEnd('import')
 
@@ -331,10 +366,13 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
     this.setState(
       {
         profile,
-        flamechart,
-        flamechartRenderer,
-        sortedFlamechart,
-        sortedFlamechartRenderer,
+
+        chronoFlamechart,
+        chronoFlamechartRenderer,
+
+        leftHeavyFlamegraph,
+        leftHeavyFlamegraphRenderer,
+
         loading: false,
       },
       () => {
@@ -402,11 +440,15 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
   onWindowKeyPress = (ev: KeyboardEvent) => {
     if (ev.key === '1') {
       this.setState({
-        sortOrder: SortOrder.CHRONO,
+        viewMode: ViewMode.CHRONO_FLAME_CHART,
       })
     } else if (ev.key === '2') {
       this.setState({
-        sortOrder: SortOrder.LEFT_HEAVY,
+        viewMode: ViewMode.LEFT_HEAVY_FLAME_GRAPH,
+      })
+    } else if (ev.key === '3') {
+      this.setState({
+        viewMode: ViewMode.TABLE_VIEW,
       })
     }
   }
@@ -535,8 +577,12 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
     return <div className={css(style.loading)} />
   }
 
-  setSortOrder = (sortOrder: SortOrder) => {
-    this.setState({sortOrder})
+  setViewMode = (viewMode: ViewMode) => {
+    this.setState({viewMode})
+  }
+
+  setTableSortMethod = (tableSortMethod: SortMethod) => {
+    this.setState({tableSortMethod})
   }
 
   private canvasContext: CanvasContext | null = null
@@ -544,47 +590,92 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
     this.canvasContext = canvasContext
   }
 
-  render() {
-    const {
-      flamechart,
-      flamechartRenderer,
-      sortedFlamechart,
-      sortedFlamechartRenderer,
-      sortOrder,
-      loading,
-      error,
-      dragActive,
-    } = this.state
-    const flamechartToView = sortOrder == SortOrder.CHRONO ? flamechart : sortedFlamechart
-    const flamechartRendererToUse =
-      sortOrder == SortOrder.CHRONO ? flamechartRenderer : sortedFlamechartRenderer
+  getCSSColorForFrame = (frame: Frame): string => {
+    const {chronoFlamechart} = this.state
+    if (!chronoFlamechart) return '#FFFFFF'
+
+    const t = chronoFlamechart.getColorBucketForFrame(frame) / 255
+
+    const x = triangle(30.0 * t)
+    const H = 360.0 * (0.9 * t)
+    const C = 0.25 + 0.2 * x
+    const L = 0.8 - 0.15 * x
+    return Color.fromLumaChromaHue(L, C, H).toCSS()
+  }
+
+  renderContent() {
+    const {viewMode} = this.state
+
+    if (this.state.error) {
+      return this.renderError()
+    }
+
+    if (this.state.loading) {
+      return this.renderLoadingBar()
+    }
+
+    if (!this.state.profile) {
+      return this.renderLanding()
+    }
 
+    if (!this.canvasContext) {
+      throw new Error('Missing canvas context')
+    }
+
+    switch (viewMode) {
+      case ViewMode.CHRONO_FLAME_CHART: {
+        const {chronoFlamechart, chronoFlamechartRenderer} = this.state
+        if (!chronoFlamechart || !chronoFlamechartRenderer)
+          throw new Error('Missing dependencies for chrono flame chart')
+        return (
+          <FlamechartView
+            canvasContext={this.canvasContext}
+            flamechartRenderer={chronoFlamechartRenderer}
+            ref={this.flamechartRef}
+            flamechart={chronoFlamechart}
+            getCSSColorForFrame={this.getCSSColorForFrame}
+          />
+        )
+      }
+      case ViewMode.LEFT_HEAVY_FLAME_GRAPH: {
+        const {leftHeavyFlamegraph, leftHeavyFlamegraphRenderer} = this.state
+        if (!leftHeavyFlamegraph || !leftHeavyFlamegraphRenderer)
+          throw new Error('Missing dependencies for left heavy flame graph')
+        return (
+          <FlamechartView
+            canvasContext={this.canvasContext}
+            flamechartRenderer={leftHeavyFlamegraphRenderer}
+            ref={this.flamechartRef}
+            flamechart={leftHeavyFlamegraph}
+            getCSSColorForFrame={this.getCSSColorForFrame}
+          />
+        )
+      }
+      case ViewMode.TABLE_VIEW: {
+        return (
+          <ProfileTableView
+            profile={this.state.profile}
+            getCSSColorForFrame={this.getCSSColorForFrame}
+            sortMethod={this.state.tableSortMethod}
+            setSortMethod={this.setTableSortMethod}
+          />
+        )
+      }
+    }
+  }
+
+  render() {
     return (
       <div
         onDrop={this.onDrop}
         onDragOver={this.onDragOver}
         onDragLeave={this.onDragLeave}
-        className={css(style.root, dragActive && style.dragTargetRoot)}
+        className={css(style.root, this.state.dragActive && style.dragTargetRoot)}
       >
         <GLCanvas setCanvasContext={this.setCanvasContext} />
-        <Toolbar setSortOrder={this.setSortOrder} {...this.state} />
-        <div className={css(style.contentContainer)}>
-          {error ? (
-            this.renderError()
-          ) : loading ? (
-            this.renderLoadingBar()
-          ) : this.canvasContext && flamechartToView && flamechartRendererToUse ? (
-            <FlamechartView
-              canvasContext={this.canvasContext}
-              flamechartRenderer={flamechartRendererToUse}
-              ref={this.flamechartRef}
-              flamechart={flamechartToView}
-            />
-          ) : (
-            this.renderLanding()
-          )}
-          {dragActive && <div className={css(style.dragTarget)} />}
-        </div>
+        <Toolbar setViewMode={this.setViewMode} {...this.state} />
+        <div className={css(style.contentContainer)}>{this.renderContent()}</div>
+        {this.state.dragActive && <div className={css(style.dragTarget)} />}
       </div>
     )
   }
@@ -649,6 +740,7 @@ const style = StyleSheet.create({
   contentContainer: {
     position: 'relative',
     display: 'flex',
+    overflow: 'hidden',
     flexDirection: 'column',
     flex: 1,
   },
@@ -681,7 +773,7 @@ const style = StyleSheet.create({
     fontSize: FontSize.BIG_BUTTON,
     lineHeight: '72px',
     background: Colors.DARK_BLUE,
-    color: 'white',
+    color: Colors.WHITE,
     cursor: 'pointer',
   },
   link: {
@@ -690,18 +782,19 @@ const style = StyleSheet.create({
     textDecoration: 'none',
   },
   toolbar: {
-    height: 18,
-    background: 'black',
-    color: 'white',
+    height: Sizes.TOOLBAR_HEIGHT,
+    flexShrink: 0,
+    background: Colors.BLACK,
+    color: Colors.WHITE,
     textAlign: 'center',
     fontFamily: FontFamily.MONOSPACE,
     fontSize: FontSize.TITLE,
-    lineHeight: '18px',
+    lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
     userSelect: 'none',
   },
   toolbarLeft: {
     position: 'absolute',
-    height: 18,
+    height: Sizes.TOOLBAR_HEIGHT,
     overflow: 'hidden',
     top: 0,
     left: 0,
@@ -709,7 +802,7 @@ const style = StyleSheet.create({
     textAlign: 'left',
   },
   toolbarRight: {
-    height: 18,
+    height: Sizes.TOOLBAR_HEIGHT,
     overflow: 'hidden',
     position: 'absolute',
     top: 0,
@@ -719,15 +812,15 @@ const style = StyleSheet.create({
   },
   toolbarTab: {
     background: Colors.DARK_GRAY,
-    marginTop: 2,
-    height: 16,
-    lineHeight: '16px',
+    marginTop: Sizes.SEPARATOR_HEIGHT,
+    height: Sizes.TOOLBAR_TAB_HEIGHT,
+    lineHeight: `${Sizes.TOOLBAR_TAB_HEIGHT}px`,
     paddingLeft: 2,
     paddingRight: 8,
     display: 'inline-block',
     marginLeft: 2,
     ':hover': {
-      background: Colors.GRAY,
+      background: Colors.DARK_GRAY,
       cursor: 'pointer',
     },
   },
diff --git a/color-chit.tsx b/color-chit.tsx
new file mode 100644
index 0000000..0caae07
--- /dev/null
+++ b/color-chit.tsx
@@ -0,0 +1,26 @@
+import {h, Component} from 'preact'
+import {StyleSheet, css} from 'aphrodite'
+import {Colors, FontSize} from './style'
+
+interface ColorChitProps {
+  color: string
+}
+
+export class ColorChit extends Component<ColorChitProps, {}> {
+  render() {
+    return <span className={css(style.stackChit)} style={{backgroundColor: this.props.color}} />
+  }
+}
+
+const style = StyleSheet.create({
+  stackChit: {
+    position: 'relative',
+    top: -1,
+    display: 'inline-block',
+    verticalAlign: 'middle',
+    marginRight: '0.5em',
+    border: `1px solid ${Colors.LIGHT_GRAY}`,
+    width: FontSize.LABEL - 2,
+    height: FontSize.LABEL - 2,
+  },
+})
diff --git a/flamechart-minimap-view.tsx b/flamechart-minimap-view.tsx
index e362e6a..ec9e4bb 100644
--- a/flamechart-minimap-view.tsx
+++ b/flamechart-minimap-view.tsx
@@ -4,8 +4,8 @@ import {Flamechart} from './flamechart'
 import {Rect, Vec2, AffineTransform, clamp} from './math'
 import {FlamechartRenderer} from './flamechart-renderer'
 import {cachedMeasureTextWidth} from './utils'
-import {style, Sizes} from './flamechart-style'
-import {FontFamily, FontSize, Colors} from './style'
+import {style} from './flamechart-style'
+import {FontFamily, FontSize, Colors, Sizes} from './style'
 import {CanvasContext} from './canvas-context'
 import {TextureCachedRenderer} from './texture-cached-renderer'
 
@@ -152,7 +152,7 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
 
     const physicalViewSpaceFrameHeight = Sizes.FRAME_HEIGHT * window.devicePixelRatio
     const physicalViewSpaceFontSize = FontSize.LABEL * window.devicePixelRatio
-    const LABEL_PADDING_PX = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
+    const labelPaddingPx = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2
 
     ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${
       FontFamily.MONOSPACE
@@ -171,15 +171,16 @@ export class FlamechartMinimapView extends Component<FlamechartMinimapViewProps,
     {
       ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
       ctx.fillRect(0, 0, physicalViewSize.x, physicalViewSpaceFrameHeight)
+      ctx.textBaseline = 'top'
 
-      ctx.fillStyle = Colors.GRAY
+      ctx.fillStyle = Colors.DARK_GRAY
       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 = Math.ceil(cachedMeasureTextWidth(ctx, labelText))
 
-        ctx.fillText(labelText, pos - textWidth - LABEL_PADDING_PX, LABEL_PADDING_PX)
+        ctx.fillText(labelText, pos - textWidth - labelPaddingPx, labelPaddingPx)
         ctx.fillRect(pos, 0, 1, physicalViewSize.y)
       }
     }
diff --git a/flamechart-style.ts b/flamechart-style.ts
index 6a7d7a3..a000450 100644
--- a/flamechart-style.ts
+++ b/flamechart-style.ts
@@ -1,21 +1,12 @@
 import {StyleSheet} from 'aphrodite'
-import {FontFamily, FontSize, Colors} from './style'
+import {FontFamily, FontSize, Colors, Sizes} from './style'
 
 const HOVERTIP_PADDING = 2
 
-export namespace Sizes {
-  export const MINIMAP_HEIGHT = 100
-  export const DETAIL_VIEW_HEIGHT = 150
-  export const TOOLTIP_WIDTH_MAX = 300
-  export const TOOLTIP_HEIGHT_MAX = 75
-  export const SEPARATOR_HEIGHT = 2
-  export const FRAME_HEIGHT = 16
-}
-
 export const style = StyleSheet.create({
   hoverTip: {
     position: 'absolute',
-    background: 'white',
+    background: Colors.WHITE,
     border: '1px solid black',
     maxWidth: Sizes.TOOLTIP_WIDTH_MAX,
     paddingTop: HOVERTIP_PADDING,
@@ -34,7 +25,7 @@ export const style = StyleSheet.create({
     maxWidth: Sizes.TOOLTIP_WIDTH_MAX,
   },
   hoverCount: {
-    color: '#6FCF97',
+    color: Colors.GREEN,
   },
   clip: {
     overflow: 'hidden',
@@ -53,7 +44,7 @@ export const style = StyleSheet.create({
   },
   minimap: {
     height: Sizes.MINIMAP_HEIGHT,
-    borderBottom: `${Sizes.SEPARATOR_HEIGHT}px solid ${Colors.MEDIUM_GRAY}`,
+    borderBottom: `${Sizes.SEPARATOR_HEIGHT}px solid ${Colors.LIGHT_GRAY}`,
   },
   panZoomView: {
     flex: 1,
@@ -65,10 +56,10 @@ export const style = StyleSheet.create({
     overflow: 'hidden',
     gridTemplateColumns: '120px 120px 1fr',
     gridTemplateRows: 'repeat(4, 1fr)',
-    borderTop: `${Sizes.SEPARATOR_HEIGHT}px solid ${Colors.MEDIUM_GRAY}`,
+    borderTop: `${Sizes.SEPARATOR_HEIGHT}px solid ${Colors.LIGHT_GRAY}`,
     fontSize: FontSize.LABEL,
     position: 'absolute',
-    background: 'white',
+    background: Colors.WHITE,
     width: '100vw',
     bottom: 0,
   },
@@ -80,19 +71,11 @@ export const style = StyleSheet.create({
     lineHeight: `${FontSize.LABEL + 2}px`,
     overflow: 'auto',
   },
-  stackChit: {
-    display: 'inline-block',
-    verticalAlign: 'bottom',
-    marginRight: '0.5em',
-    border: `1px solid ${Colors.MEDIUM_GRAY}`,
-    width: FontSize.LABEL - 2,
-    height: FontSize.LABEL - 2,
-  },
   stackLine: {
     whiteSpace: 'nowrap',
   },
   stackFileLine: {
-    color: Colors.MEDIUM_GRAY,
+    color: Colors.LIGHT_GRAY,
   },
   statsTable: {
     display: 'grid',
@@ -113,11 +96,11 @@ export const style = StyleSheet.create({
   },
   thisInstanceCell: {
     background: Colors.DARK_BLUE,
-    color: 'white',
+    color: Colors.WHITE,
   },
   allInstancesCell: {
     background: Colors.PALE_DARK_BLUE,
-    color: 'white',
+    color: Colors.WHITE,
   },
   barDisplay: {
     position: 'absolute',
diff --git a/flamechart-view.tsx b/flamechart-view.tsx
index 572b150..5da7f1e 100644
--- a/flamechart-view.tsx
+++ b/flamechart-view.tsx
@@ -6,14 +6,14 @@ import {CallTreeNode, Frame} from './profile'
 import {Flamechart, FlamechartFrame} from './flamechart'
 
 import {Rect, Vec2, AffineTransform, clamp} from './math'
-import {cachedMeasureTextWidth} from './utils'
+import {cachedMeasureTextWidth, formatPercent} from './utils'
 import {FlamechartMinimapView} from './flamechart-minimap-view'
 
-import {style, Sizes} from './flamechart-style'
-import {FontSize, FontFamily, Colors} from './style'
+import {style} from './flamechart-style'
+import {FontSize, FontFamily, Colors, Sizes} from './style'
 import {CanvasContext} from './canvas-context'
 import {FlamechartRenderer} from './flamechart-renderer'
-import {Color} from './color'
+import {ColorChit} from './color-chit'
 
 interface FlamechartFrameLabel {
   configSpaceBounds: Rect
@@ -131,7 +131,7 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
     )
   }
 
-  private LOGICAL_VIEW_SPACE_FRAME_HEIGHT = 20
+  private LOGICAL_VIEW_SPACE_FRAME_HEIGHT = Sizes.FRAME_HEIGHT
 
   private configSpaceToPhysicalViewSpace() {
     return AffineTransform.betweenRects(
@@ -337,6 +337,8 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
       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()
@@ -358,14 +360,14 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
     {
       ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
       ctx.fillRect(0, 0, physicalViewSize.x, physicalViewSpaceFrameHeight)
-      ctx.fillStyle = Colors.GRAY
+      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 - 5, 2)
+        ctx.fillText(labelText, pos - textWidth - labelPaddingPx, labelPaddingPx)
         ctx.fillRect(pos, 0, 1, physicalViewSize.y)
       }
     }
@@ -754,12 +756,7 @@ class StackTraceView extends ReloadableComponent<StackTraceViewProps, {}> {
       const row: (JSX.Element | string)[] = []
       const {frame} = node
 
-      row.push(
-        <span
-          className={css(style.stackChit)}
-          style={{backgroundColor: this.props.getFrameColor(frame)}}
-        />,
-      )
+      row.push(<ColorChit color={this.props.getFrameColor(frame)} />)
 
       if (rows.length) {
         row.push(<span className={css(style.stackFileLine)}>> </span>)
@@ -788,28 +785,11 @@ class StackTraceView extends ReloadableComponent<StackTraceViewProps, {}> {
 
 interface FlamechartDetailViewProps {
   flamechart: Flamechart
+  getCSSColorForFrame: (frame: Frame) => string
   selectedNode: CallTreeNode
 }
 
-function fract(x: number) {
-  return x - Math.floor(x)
-}
-
-function triangle(x: number) {
-  return 2.0 * Math.abs(fract(x) - 0.5) - 1.0
-}
-
 class FlamechartDetailView extends ReloadableComponent<FlamechartDetailViewProps, {}> {
-  getFrameColor = (frame: Frame): string => {
-    const t = this.props.flamechart.getColorBucketForFrame(frame) / 255
-
-    const x = triangle(30.0 * t)
-    const H = 360.0 * (0.9 * t)
-    const C = 0.25 + 0.2 * x
-    const L = 0.8 - 0.15 * x
-    return Color.fromLumaChromaHue(L, C, H).toCSS()
-  }
-
   render() {
     const {flamechart, selectedNode} = this.props
     const {frame} = selectedNode
@@ -832,7 +812,7 @@ class FlamechartDetailView extends ReloadableComponent<FlamechartDetailViewProps
           selectedSelf={frame.getSelfWeight()}
           formatter={flamechart.formatValue.bind(flamechart)}
         />
-        <StackTraceView node={selectedNode} getFrameColor={this.getFrameColor} />
+        <StackTraceView node={selectedNode} getFrameColor={this.props.getCSSColorForFrame} />
       </div>
     )
   }
@@ -842,6 +822,7 @@ interface FlamechartViewProps {
   flamechart: Flamechart
   canvasContext: CanvasContext
   flamechartRenderer: FlamechartRenderer
+  getCSSColorForFrame: (frame: Frame) => string
 }
 
 interface FlamechartViewState {
@@ -1002,6 +983,7 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
         {this.state.selectedNode && (
           <FlamechartDetailView
             flamechart={this.props.flamechart}
+            getCSSColorForFrame={this.props.getCSSColorForFrame}
             selectedNode={this.state.selectedNode}
           />
         )}
@@ -1010,12 +992,3 @@ export class FlamechartView extends ReloadableComponent<FlamechartViewProps, Fla
     )
   }
 }
-function formatPercent(percent: number) {
-  let formattedPercent = `${percent.toFixed(0)}%`
-  if (percent === 100) formattedPercent = '100%'
-  else if (percent > 99) formattedPercent = '>99%'
-  else if (percent < 0.01) formattedPercent = '<0.01%'
-  else if (percent < 1) formattedPercent = `${percent.toFixed(2)}%`
-  else if (percent < 10) formattedPercent = `${percent.toFixed(1)}%`
-  return formattedPercent
-}
diff --git a/profile-table-view.tsx b/profile-table-view.tsx
new file mode 100644
index 0000000..be85908
--- /dev/null
+++ b/profile-table-view.tsx
@@ -0,0 +1,293 @@
+import {h, Component} from 'preact'
+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 {ColorChit} from './color-chit'
+import {ScrollableListView, ListItem} from './scrollable-list-view'
+
+export enum SortField {
+  SYMBOL_NAME,
+  SELF,
+  TOTAL,
+}
+
+export enum SortDirection {
+  ASCENDING,
+  DESCENDING,
+}
+
+export interface SortMethod {
+  field: SortField
+  direction: SortDirection
+}
+
+interface ProfileTableViewProps {
+  profile: Profile
+  getCSSColorForFrame: (frame: Frame) => string
+  sortMethod: SortMethod
+  setSortMethod: (sortMethod: SortMethod) => void
+}
+
+interface HBarProps {
+  perc: number
+}
+
+class HBarDisplay extends Component<HBarProps, {}> {
+  render() {
+    return (
+      <div className={css(style.hBarDisplay)}>
+        <div className={css(style.hBarDisplayFilled)} style={{width: `${this.props.perc}%`}} />
+      </div>
+    )
+  }
+}
+
+interface SortIconProps {
+  activeDirection: SortDirection | null
+}
+
+class SortIcon extends Component<SortIconProps, {}> {
+  render() {
+    const {activeDirection} = this.props
+    const upFill = activeDirection === SortDirection.ASCENDING ? Colors.GRAY : Colors.LIGHT_GRAY
+    const downFill = activeDirection === SortDirection.DESCENDING ? Colors.GRAY : Colors.LIGHT_GRAY
+
+    return (
+      <svg
+        width="8"
+        height="10"
+        viewBox="0 0 8 10"
+        fill="none"
+        xmlns="http://www.w3.org/2000/svg"
+        className={css(style.sortIcon)}
+      >
+        <path d="M0 4L4 0L8 4H0Z" fill={upFill} />
+        <path d="M0 4L4 0L8 4H0Z" transform="translate(0 10) scale(1 -1)" fill={downFill} />
+      </svg>
+    )
+  }
+}
+
+export class ProfileTableView extends ReloadableComponent<ProfileTableViewProps, void> {
+  renderRow(frame: Frame, index: number) {
+    const {profile} = this.props
+
+    const totalWeight = frame.getTotalWeight()
+    const selfWeight = frame.getSelfWeight()
+    const totalPerc = 100.0 * totalWeight / profile.getTotalNonIdleWeight()
+    const selfPerc = 100.0 * selfWeight / profile.getTotalNonIdleWeight()
+
+    // 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)}>
+        <td className={css(style.numericCell)}>
+          {profile.formatValue(totalWeight)} ({formatPercent(totalPerc)})
+          <HBarDisplay perc={totalPerc} />
+        </td>
+        <td className={css(style.numericCell)}>
+          {profile.formatValue(selfWeight)} ({formatPercent(selfPerc)})
+          <HBarDisplay perc={selfPerc} />
+        </td>
+        <td title={frame.file} className={css(style.textCell)}>
+          <ColorChit color={this.props.getCSSColorForFrame(frame)} />
+          {frame.name}
+        </td>
+      </tr>
+    )
+  }
+
+  onSortClick = (field: SortField, ev: MouseEvent) => {
+    ev.preventDefault()
+
+    const {sortMethod} = this.props
+
+    if (sortMethod.field == field) {
+      // Toggle
+      this.props.setSortMethod({
+        field,
+        direction:
+          sortMethod.direction === SortDirection.ASCENDING
+            ? SortDirection.DESCENDING
+            : SortDirection.ASCENDING,
+      })
+    } else {
+      // Set a sane default
+      switch (field) {
+        case SortField.SYMBOL_NAME: {
+          this.props.setSortMethod({field, direction: SortDirection.ASCENDING})
+          break
+        }
+        case SortField.SELF: {
+          this.props.setSortMethod({field, direction: SortDirection.DESCENDING})
+          break
+        }
+        case SortField.TOTAL: {
+          this.props.setSortMethod({field, direction: SortDirection.DESCENDING})
+          break
+        }
+      }
+    }
+  }
+
+  render() {
+    const {profile, sortMethod} = this.props
+
+    const frameList: Frame[] = []
+
+    profile.forEachFrame(f => frameList.push(f))
+
+    // TODO(jlfwong): This is pretty inefficient to do this on every render, but doesn't
+    // seem to be a bottleneck, so we'll leave it alone.
+    switch (sortMethod.field) {
+      case SortField.SYMBOL_NAME: {
+        sortBy(frameList, f => f.name.toLowerCase())
+        break
+      }
+      case SortField.SELF: {
+        sortBy(frameList, f => f.getSelfWeight())
+        break
+      }
+      case SortField.TOTAL: {
+        sortBy(frameList, f => f.getTotalWeight())
+        break
+      }
+    }
+    if (sortMethod.direction === SortDirection.DESCENDING) {
+      frameList.reverse()
+    }
+
+    const renderItems = (firstIndex: number, lastIndex: number) => {
+      const rows: JSX.Element[] = []
+
+      for (let i = firstIndex; i <= lastIndex; i++) {
+        rows.push(this.renderRow(frameList[i], i))
+      }
+
+      return <table className={css(style.tableView)}>{rows}</table>
+    }
+
+    const listItems: ListItem[] = frameList.map(f => ({size: Sizes.FRAME_HEIGHT}))
+
+    return (
+      <div className={css(style.vbox, style.profileTableView)}>
+        <table className={css(style.tableView)}>
+          <thead className={css(style.tableHeader)}>
+            <tr>
+              <th
+                className={css(style.numericCell)}
+                onClick={ev => this.onSortClick(SortField.TOTAL, ev)}
+              >
+                <SortIcon
+                  activeDirection={
+                    sortMethod.field === SortField.TOTAL ? sortMethod.direction : null
+                  }
+                />
+                Total
+              </th>
+              <th
+                className={css(style.numericCell)}
+                onClick={ev => this.onSortClick(SortField.SELF, ev)}
+              >
+                <SortIcon
+                  activeDirection={
+                    sortMethod.field === SortField.SELF ? sortMethod.direction : null
+                  }
+                />
+                Self
+              </th>
+              <th
+                className={css(style.textCell)}
+                onClick={ev => this.onSortClick(SortField.SYMBOL_NAME, ev)}
+              >
+                <SortIcon
+                  activeDirection={
+                    sortMethod.field === SortField.SYMBOL_NAME ? sortMethod.direction : null
+                  }
+                />
+                Symbol Name
+              </th>
+            </tr>
+          </thead>
+        </table>
+        <ScrollableListView
+          items={listItems}
+          className={css(style.scrollView)}
+          renderItems={renderItems}
+        />
+      </div>
+    )
+  }
+}
+
+const style = StyleSheet.create({
+  profileTableView: {
+    background: Colors.WHITE,
+    height: '100%',
+  },
+  vbox: {
+    display: 'flex',
+    flexDirection: 'column',
+    position: 'relative',
+  },
+  scrollView: {
+    overflowY: 'auto',
+    overflowX: 'hidden',
+  },
+  tableView: {
+    width: '100%',
+    fontSize: FontSize.LABEL,
+    background: Colors.WHITE,
+    cursor: 'default',
+  },
+  tableHeader: {
+    borderBottom: `2px solid ${Colors.LIGHT_GRAY}`,
+    textAlign: 'left',
+    color: Colors.GRAY,
+    userSelect: 'none',
+  },
+  sortIcon: {
+    position: 'relative',
+    top: 1,
+    marginRight: Sizes.FRAME_HEIGHT / 4,
+  },
+  tableRow: {
+    height: Sizes.FRAME_HEIGHT,
+  },
+  tableRowEven: {
+    background: Colors.OFF_WHITE,
+  },
+  numericCell: {
+    textOverflow: 'ellipsis',
+    overflow: 'hidden',
+    whiteSpace: 'nowrap',
+    position: 'relative',
+    textAlign: 'right',
+    paddingRight: Sizes.FRAME_HEIGHT,
+    width: 6 * Sizes.FRAME_HEIGHT,
+    minWidth: 6 * Sizes.FRAME_HEIGHT,
+  },
+  textCell: {
+    textOverflow: 'ellipsis',
+    overflow: 'hidden',
+    whiteSpace: 'nowrap',
+    width: '100%',
+    maxWidth: 0,
+  },
+  hBarDisplay: {
+    position: 'absolute',
+    background: Colors.TRANSPARENT_GREEN,
+    bottom: 2,
+    height: 2,
+    width: `calc(100% - ${2 * Sizes.FRAME_HEIGHT}px)`,
+    right: Sizes.FRAME_HEIGHT,
+  },
+  hBarDisplayFilled: {
+    height: '100%',
+    position: 'absolute',
+    background: Colors.GREEN,
+    right: 0,
+  },
+})
diff --git a/profile.ts b/profile.ts
index be7567a..6e2b0c3 100644
--- a/profile.ts
+++ b/profile.ts
@@ -121,8 +121,16 @@ export class Profile {
   getTotalWeight() {
     return this.totalWeight
   }
+
+  private totalNonIdleWeight: number | null = null
   getTotalNonIdleWeight() {
-    return this.groupedCalltreeRoot.children.reduce((n, c) => n + c.getTotalWeight(), 0)
+    if (this.totalNonIdleWeight === null) {
+      this.totalNonIdleWeight = this.groupedCalltreeRoot.children.reduce(
+        (n, c) => n + c.getTotalWeight(),
+        0,
+      )
+    }
+    return this.totalNonIdleWeight
   }
 
   forEachCallGrouped(
diff --git a/scrollable-list-view.tsx b/scrollable-list-view.tsx
new file mode 100644
index 0000000..393f766
--- /dev/null
+++ b/scrollable-list-view.tsx
@@ -0,0 +1,121 @@
+// A simple implementation of an efficient scrolling list view which
+// renders only items within the viewport + a couple extra items.
+
+import {h} from 'preact'
+import {ReloadableComponent} from './reloadable'
+
+export interface ListItem {
+  size: number
+}
+
+interface ScrollableListViewProps {
+  items: ListItem[]
+  renderItems: (firstVisibleIndex: number, lastVisibleIndex: number) => JSX.Element | JSX.Element[]
+  className?: string
+}
+
+interface ScrollableListViewState {
+  firstVisibleIndex: number | null
+  lastVisibleIndex: number | null
+  invisiblePrefixSize: number | null
+  viewportSize: number | null
+  cachedTotalSize: number
+}
+
+export class ScrollableListView extends ReloadableComponent<
+  ScrollableListViewProps,
+  ScrollableListViewState
+> {
+  constructor(props: ScrollableListViewProps) {
+    super(props)
+    this.state = {
+      firstVisibleIndex: null,
+      lastVisibleIndex: null,
+      invisiblePrefixSize: null,
+      viewportSize: null,
+      cachedTotalSize: props.items.reduce((a, b) => a + b.size, 0),
+    }
+  }
+
+  private viewport: HTMLDivElement | null = null
+  private viewportRef = (viewport?: Element) => {
+    this.viewport = (viewport as HTMLDivElement) || null
+  }
+
+  private recomputeVisibleIndices(props: ScrollableListViewProps) {
+    if (!this.viewport) return
+    const {items} = props
+
+    const viewportSize = this.viewport.getBoundingClientRect().height
+
+    // We render items up to a quarter viewport height outside of the
+    // viewport both above and below to prevent flickering.
+    const minY = this.viewport.scrollTop - viewportSize / 4
+    const maxY = this.viewport.scrollTop + viewportSize + viewportSize / 4
+
+    let total = 0
+    let invisiblePrefixSize = 0
+
+    let i = 0
+    for (; i < items.length; i++) {
+      const item = items[i]
+      invisiblePrefixSize = total
+      total += item.size
+      if (total >= minY) {
+        break
+      }
+    }
+
+    const firstVisibleIndex = i
+
+    for (; i < items.length; i++) {
+      const item = items[i]
+      total += item.size
+      if (total >= maxY) {
+        break
+      }
+    }
+
+    const lastVisibleIndex = Math.min(i, items.length - 1)
+    this.setState({invisiblePrefixSize, firstVisibleIndex, lastVisibleIndex})
+  }
+
+  componentWillReceiveProps(nextProps: ScrollableListViewProps) {
+    if (this.props.items !== nextProps.items) {
+      this.recomputeVisibleIndices(nextProps)
+    }
+  }
+
+  componentDidMount() {
+    this.recomputeVisibleIndices(this.props)
+    window.addEventListener('resize', this.onWindowResize)
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('resize', this.onWindowResize)
+  }
+
+  onWindowResize = () => {
+    this.recomputeVisibleIndices(this.props)
+  }
+
+  onViewportScroll = (ev: UIEvent) => {
+    this.recomputeVisibleIndices(this.props)
+  }
+
+  render() {
+    const {cachedTotalSize, firstVisibleIndex, lastVisibleIndex, invisiblePrefixSize} = this.state
+
+    return (
+      <div className={this.props.className} ref={this.viewportRef} onScroll={this.onViewportScroll}>
+        <div style={{height: cachedTotalSize}}>
+          <div style={{transform: `translateY(${invisiblePrefixSize}px)`}}>
+            {firstVisibleIndex != null &&
+              lastVisibleIndex != null &&
+              this.props.renderItems(firstVisibleIndex, lastVisibleIndex)}
+          </div>
+        </div>
+      </div>
+    )
+  }
+}
diff --git a/style.ts b/style.ts
index ce50aac..c06cbe5 100644
--- a/style.ts
+++ b/style.ts
@@ -9,11 +9,26 @@ export enum FontSize {
 }
 
 export enum Colors {
-  LIGHT_GRAY = '#C4C4C4',
-  MEDIUM_GRAY = '#BDBDBD',
+  WHITE = '#FFFFFF',
+  OFF_WHITE = '#F6F6F6',
+  LIGHT_GRAY = '#BDBDBD',
   GRAY = '#666666',
   DARK_GRAY = '#222222',
+  BLACK = '#000000',
   BRIGHT_BLUE = '#56CCF2',
   DARK_BLUE = '#2F80ED',
   PALE_DARK_BLUE = '#8EB7ED',
+  GREEN = '#6FCF97',
+  TRANSPARENT_GREEN = 'rgba(111, 207, 151, 0.2)',
+}
+
+export enum Sizes {
+  MINIMAP_HEIGHT = 100,
+  DETAIL_VIEW_HEIGHT = 150,
+  TOOLTIP_WIDTH_MAX = 300,
+  TOOLTIP_HEIGHT_MAX = 80,
+  SEPARATOR_HEIGHT = 2,
+  FRAME_HEIGHT = 20,
+  TOOLBAR_HEIGHT = 20,
+  TOOLBAR_TAB_HEIGHT = TOOLBAR_HEIGHT - SEPARATOR_HEIGHT,
 }
diff --git a/utils.ts b/utils.ts
index 69c3318..124664c 100644
--- a/utils.ts
+++ b/utils.ts
@@ -65,3 +65,21 @@ export function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: stri
   }
   return measureTextCache.get(text)!
 }
+
+export function formatPercent(percent: number) {
+  let formattedPercent = `${percent.toFixed(0)}%`
+  if (percent === 100) formattedPercent = '100%'
+  else if (percent > 99) formattedPercent = '>99%'
+  else if (percent < 0.01) formattedPercent = '<0.01%'
+  else if (percent < 1) formattedPercent = `${percent.toFixed(2)}%`
+  else if (percent < 10) formattedPercent = `${percent.toFixed(1)}%`
+  return formattedPercent
+}
+
+export function fract(x: number) {
+  return x - Math.floor(x)
+}
+
+export function triangle(x: number) {
+  return 2.0 * Math.abs(fract(x) - 0.5) - 1.0
+}
diff --git a/value-formatters.ts b/value-formatters.ts
index ae3daf5..f6da965 100644
--- a/value-formatters.ts
+++ b/value-formatters.ts
@@ -25,7 +25,7 @@ export class TimeFormatter implements ValueFormatter {
     if (s / 1 >= 1) return `${s.toFixed(2)}s`
     if (s / 1e-3 >= 1) return `${(s / 1e-3).toFixed(2)}ms`
     if (s / 1e-6 >= 1) return `${(s / 1e-6).toFixed(2)}µs`
-    else return `${(s / 1e-9).toFixed(2)}ms`
+    else return `${(s / 1e-9).toFixed(2)}ns`
   }
 }
 


Test patch:
diff --git a/utils.test.ts b/utils.test.ts
index a0e177f..0c8dbf8 100644
--- a/utils.test.ts
+++ b/utils.test.ts
@@ -7,6 +7,7 @@ import {
   itForEach,
   itReduce,
   zeroPad,
+  formatPercent,
 } from './utils'
 
 test('sortBy', () => {
@@ -64,3 +65,13 @@ test('zeroPad', () => {
   expect(zeroPad('1000', 3)).toBe('1000')
   expect(zeroPad('FF', 4)).toBe('00FF')
 })
+
+test('formatPercent', () => {
+  expect(formatPercent(0.42)).toBe('0.42%')
+  expect(formatPercent(0.4)).toBe('0.40%')
+  expect(formatPercent(1)).toBe('1.0%')
+  expect(formatPercent(1.6)).toBe('1.6%')
+  expect(formatPercent(99)).toBe('99%')
+  expect(formatPercent(99.9)).toBe('>99%')
+  expect(formatPercent(100)).toBe('100%')
+})
diff --git a/value-formatters.test.ts b/value-formatters.test.ts
index 27597df..57042b4 100644
--- a/value-formatters.test.ts
+++ b/value-formatters.test.ts
@@ -3,6 +3,7 @@ import {TimeFormatter, ByteFormatter} from './value-formatters'
 describe('TimeFormatter', () => {
   test('input units milliseconds', () => {
     const f = new TimeFormatter('milliseconds')
+    expect(f.format(0.00004)).toEqual('40.00ns')
     expect(f.format(0.04)).toEqual('40.00µs')
     expect(f.format(3)).toEqual('3.00ms')
     expect(f.format(2070)).toEqual('2.07s')