From 981b1bbf592b8c6d1e83cc0a315131f26aae6b71 Mon Sep 17 00:00:00 2001
From: Joe Farro <tiffon@users.noreply.github.com>
Date: Sat, 16 Sep 2017 22:03:38 -0700
Subject: [PATCH] Use canvas instead of SVG for trace mini-map  (#72)

* WIP trace mini-map via canvas

* Fix #61 render span graph as canvas instead of SVG
- Render span graph via canvas instead of SVG
- Zoom range changed to [0, 1], e.g. time and trace agnostic allowed
  removal of some utils
- "Timeline" -> 0ms
- Use props instead of context to provide span graph with viewing range
- Move all span graph related classes into same folder
- Misc CSS cleanup

* Use flow instead of prop types (PR feedback)
---
 .../TracePage/SpanGraph/CanvasSpanGraph.css   |  28 ++
 .../TracePage/SpanGraph/CanvasSpanGraph.js    |  78 ++++++
 .../TracePage/SpanGraph/GraphTicks.css        |  26 ++
 .../TracePage/SpanGraph/GraphTicks.js         |  45 ++++
 .../TracePage/SpanGraph/GraphTicks.test.js    |  50 ++++
 .../TracePage/SpanGraph/Scrubber.css          |  50 ++++
 .../Scrubber.js}                              |  59 ++--
 .../Scrubber.test.js}                         |  16 +-
 .../TracePage/SpanGraph/TickLabels.css        |  33 +++
 .../{SpanGraphTickHeader.js => TickLabels.js} |  21 +-
 ...hTickHeader.test.js => TickLabels.test.js} |   8 +-
 src/components/TracePage/SpanGraph/index.css  |  25 +-
 src/components/TracePage/SpanGraph/index.js   | 252 ++++++++++++++----
 .../TracePage/SpanGraph/index.test.js         | 219 +++++++++++++--
 .../TracePage/SpanGraph/render-into-canvas.js |  49 ++++
 src/components/TracePage/TraceSpanGraph.js    | 215 ---------------
 .../TracePage/TraceSpanGraph.test.js          | 242 -----------------
 .../TracePage/TraceTimelineViewer/Ticks.css   |   3 +-
 .../VirtualizedTraceView.js                   |   6 +-
 .../TracePage/TraceTimelineViewer/index.js    |   6 +-
 .../TracePage/TraceTimelineViewer/utils.js    |  18 --
 .../TraceTimelineViewer/utils.test.js         |  11 -
 src/components/TracePage/index.css            |  57 ----
 src/components/TracePage/index.js             |  11 +-
 src/components/TracePage/index.test.js        |   7 +-
 src/utils/date.js                             |  11 -
 26 files changed, 830 insertions(+), 716 deletions(-)
 create mode 100644 src/components/TracePage/SpanGraph/CanvasSpanGraph.css
 create mode 100644 src/components/TracePage/SpanGraph/CanvasSpanGraph.js
 create mode 100644 src/components/TracePage/SpanGraph/GraphTicks.css
 create mode 100644 src/components/TracePage/SpanGraph/GraphTicks.js
 create mode 100644 src/components/TracePage/SpanGraph/GraphTicks.test.js
 create mode 100644 src/components/TracePage/SpanGraph/Scrubber.css
 rename src/components/TracePage/{TimelineScrubber.js => SpanGraph/Scrubber.js} (63%)
 rename src/components/TracePage/{TimelineScrubber.test.js => SpanGraph/Scrubber.test.js} (78%)
 create mode 100644 src/components/TracePage/SpanGraph/TickLabels.css
 rename src/components/TracePage/SpanGraph/{SpanGraphTickHeader.js => TickLabels.js} (81%)
 rename src/components/TracePage/SpanGraph/{SpanGraphTickHeader.test.js => TickLabels.test.js} (88%)
 create mode 100644 src/components/TracePage/SpanGraph/render-into-canvas.js
 delete mode 100644 src/components/TracePage/TraceSpanGraph.js
 delete mode 100644 src/components/TracePage/TraceSpanGraph.test.js

diff --git a/src/components/TracePage/SpanGraph/CanvasSpanGraph.css b/src/components/TracePage/SpanGraph/CanvasSpanGraph.css
new file mode 100644
index 0000000000..8d5eb7b7be
--- /dev/null
+++ b/src/components/TracePage/SpanGraph/CanvasSpanGraph.css
@@ -0,0 +1,28 @@
+/*
+Copyright (c) 2017 Uber Technologies, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+.CanvasSpanGraph {
+  background: #fafafa;
+  height: 60px;
+  position: absolute;
+  width: 100%;
+}
diff --git a/src/components/TracePage/SpanGraph/CanvasSpanGraph.js b/src/components/TracePage/SpanGraph/CanvasSpanGraph.js
new file mode 100644
index 0000000000..eb36e5f573
--- /dev/null
+++ b/src/components/TracePage/SpanGraph/CanvasSpanGraph.js
@@ -0,0 +1,78 @@
+// @flow
+
+// Copyright (c) 2017 Uber Technologies, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+import * as React from 'react';
+
+import renderIntoCanvas from './render-into-canvas';
+import colorGenerator from '../../../utils/color-generator';
+
+import './CanvasSpanGraph.css';
+
+type CanvasSpanGraphProps = {
+  items: { valueWidth: number, valueOffset: number, serviceName: string }[],
+  valueWidth: number,
+};
+
+const CV_WIDTH = 4000;
+
+const getColor = str => colorGenerator.getColorByKey(str);
+
+export default class CanvasSpanGraph extends React.PureComponent<CanvasSpanGraphProps> {
+  props: CanvasSpanGraphProps;
+  _canvasElm: ?HTMLCanvasElement;
+
+  constructor(props: CanvasSpanGraphProps) {
+    super(props);
+    this._canvasElm = undefined;
+    this._setCanvasRef = this._setCanvasRef.bind(this);
+  }
+
+  componentDidMount() {
+    this._draw();
+  }
+
+  componentDidUpdate() {
+    this._draw();
+  }
+
+  _setCanvasRef = function _setCanvasRef(elm: React.Node) {
+    this._canvasElm = elm;
+  };
+
+  _draw() {
+    if (this._canvasElm) {
+      const { valueWidth: totalValueWidth, items } = this.props;
+      renderIntoCanvas(this._canvasElm, items, totalValueWidth, getColor);
+    }
+  }
+
+  render() {
+    return (
+      <canvas
+        className="CanvasSpanGraph"
+        ref={this._setCanvasRef}
+        width={CV_WIDTH}
+        height={this.props.items.length}
+      />
+    );
+  }
+}
diff --git a/src/components/TracePage/SpanGraph/GraphTicks.css b/src/components/TracePage/SpanGraph/GraphTicks.css
new file mode 100644
index 0000000000..52f5055bf8
--- /dev/null
+++ b/src/components/TracePage/SpanGraph/GraphTicks.css
@@ -0,0 +1,26 @@
+/*
+Copyright (c) 2017 Uber Technologies, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+.GraphTick {
+  stroke: #aaa;
+  stroke-width: 1px;
+}
diff --git a/src/components/TracePage/SpanGraph/GraphTicks.js b/src/components/TracePage/SpanGraph/GraphTicks.js
new file mode 100644
index 0000000000..020c8dfd33
--- /dev/null
+++ b/src/components/TracePage/SpanGraph/GraphTicks.js
@@ -0,0 +1,45 @@
+// @flow
+
+// Copyright (c) 2017 Uber Technologies, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+import React from 'react';
+
+import './GraphTicks.css';
+
+type GraphTicksProps = {
+  numTicks: number,
+};
+
+export default function GraphTicks(props: GraphTicksProps) {
+  const { numTicks } = props;
+  const ticks = [];
+  // i starts at 1, limit is `i < numTicks` so the first and last ticks aren't drawn
+  for (let i = 1; i < numTicks; i++) {
+    const x = `${i / numTicks * 100}%`;
+    ticks.push(<line className="GraphTick" x1={x} y1="0%" x2={x} y2="100%" key={i / numTicks} />);
+  }
+
+  return (
+    <g data-test="ticks" aria-hidden="true">
+      {ticks}
+    </g>
+  );
+}
diff --git a/src/components/TracePage/SpanGraph/GraphTicks.test.js b/src/components/TracePage/SpanGraph/GraphTicks.test.js
new file mode 100644
index 0000000000..cd6f343382
--- /dev/null
+++ b/src/components/TracePage/SpanGraph/GraphTicks.test.js
@@ -0,0 +1,50 @@
+// Copyright (c) 2017 Uber Technologies, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import GraphTicks from './GraphTicks';
+
+describe('<GraphTicks>', () => {
+  const defaultProps = {
+    items: [
+      { valueWidth: 100, valueOffset: 25, serviceName: 'a' },
+      { valueWidth: 100, valueOffset: 50, serviceName: 'b' },
+    ],
+    valueWidth: 200,
+    numTicks: 4,
+  };
+
+  let ticksG;
+
+  beforeEach(() => {
+    const wrapper = shallow(<GraphTicks {...defaultProps} />);
+    ticksG = wrapper.find('[data-test="ticks"]');
+  });
+
+  it('creates a <g> for ticks', () => {
+    expect(ticksG.length).toBe(1);
+  });
+
+  it('creates a line for each ticks excluding the first and last', () => {
+    expect(ticksG.find('line').length).toBe(defaultProps.numTicks - 1);
+  });
+});
diff --git a/src/components/TracePage/SpanGraph/Scrubber.css b/src/components/TracePage/SpanGraph/Scrubber.css
new file mode 100644
index 0000000000..accdda7bbd
--- /dev/null
+++ b/src/components/TracePage/SpanGraph/Scrubber.css
@@ -0,0 +1,50 @@
+/*
+Copyright (c) 2017 Uber Technologies, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+.timeline-scrubber {
+  cursor: ew-resize;
+}
+
+.timeline-scrubber__line {
+  stroke: #999;
+  stroke-width: 1;
+}
+
+.timeline-scrubber:hover .timeline-scrubber__line {
+  stroke: #777;
+}
+
+.timeline-scrubber__handle {
+  stroke: #999;
+  fill: #fff;
+}
+
+.timeline-scrubber:hover .timeline-scrubber__handle {
+  stroke: #777;
+}
+
+.timeline-scrubber__handle--grip {
+  fill: #bbb;
+}
+.timeline-scrubber:hover .timeline-scrubber__handle--grip {
+  fill: #999;
+}
diff --git a/src/components/TracePage/TimelineScrubber.js b/src/components/TracePage/SpanGraph/Scrubber.js
similarity index 63%
rename from src/components/TracePage/TimelineScrubber.js
rename to src/components/TracePage/SpanGraph/Scrubber.js
index fe4b951899..16f2008206 100644
--- a/src/components/TracePage/TimelineScrubber.js
+++ b/src/components/TracePage/SpanGraph/Scrubber.js
@@ -1,3 +1,5 @@
+// @flow
+
 // Copyright (c) 2017 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -18,39 +20,35 @@
 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 // THE SOFTWARE.
 
-import PropTypes from 'prop-types';
 import React from 'react';
 
-import { getTraceTimestamp, getTraceDuration } from '../../selectors/trace';
-import { getPercentageOfInterval } from '../../utils/date';
+import './Scrubber.css';
+
+type ScrubberProps = {
+  position: number,
+  onMouseDown: (SyntheticMouseEvent<any>) => void,
+  handleTopOffset: number,
+  handleWidth: number,
+  handleHeight: number,
+};
 
 const HANDLE_WIDTH = 6;
 const HANDLE_HEIGHT = 20;
 const HANDLE_TOP_OFFSET = 0;
 
-export default function TimelineScrubber({
-  trace,
-  timestamp,
+export default function Scrubber({
+  position,
   onMouseDown,
   handleTopOffset = HANDLE_TOP_OFFSET,
   handleWidth = HANDLE_WIDTH,
   handleHeight = HANDLE_HEIGHT,
-}) {
-  const initialTimestamp = getTraceTimestamp(trace);
-  const totalDuration = getTraceDuration(trace);
-  const xPercentage = getPercentageOfInterval(timestamp, initialTimestamp, totalDuration);
-
+}: ScrubberProps) {
+  const xPercent = `${position * 100}%`;
   return (
     <g className="timeline-scrubber" onMouseDown={onMouseDown}>
-      <line
-        className="timeline-scrubber__line"
-        y1={0}
-        y2="100%"
-        x1={`${xPercentage}%`}
-        x2={`${xPercentage}%`}
-      />
+      <line className="timeline-scrubber__line" y2="100%" x1={xPercent} x2={xPercent} />
       <rect
-        x={`${xPercentage}%`}
+        x={xPercent}
         y={handleTopOffset}
         className="timeline-scrubber__handle"
         style={{ transform: `translate(${-(handleWidth / 2)}px)` }}
@@ -62,25 +60,18 @@ export default function TimelineScrubber({
       <circle
         className="timeline-scrubber__handle--grip"
         style={{ transform: `translateY(${handleHeight / 4}px)` }}
-        cx={`${xPercentage}%`}
-        cy={'50%'}
+        cx={xPercent}
+        cy="50%"
+        r="2"
       />
-      <circle className="timeline-scrubber__handle--grip" cx={`${xPercentage}%`} cy={'50%'} />
+      <circle className="timeline-scrubber__handle--grip" cx={xPercent} cy="50%" r="2" />
       <circle
         className="timeline-scrubber__handle--grip"
-        style={{ transform: `translateY(${-(handleHeight / 4)}px)` }}
-        cx={`${xPercentage}%`}
-        cy={'50%'}
+        style={{ transform: `translateY(${-handleHeight / 4}px)` }}
+        cx={xPercent}
+        cy="50%"
+        r="2"
       />
     </g>
   );
 }
-
-TimelineScrubber.propTypes = {
-  onMouseDown: PropTypes.func,
-  trace: PropTypes.object,
-  timestamp: PropTypes.number.isRequired,
-  handleTopOffset: PropTypes.number,
-  handleWidth: PropTypes.number,
-  handleHeight: PropTypes.number,
-};
diff --git a/src/components/TracePage/TimelineScrubber.test.js b/src/components/TracePage/SpanGraph/Scrubber.test.js
similarity index 78%
rename from src/components/TracePage/TimelineScrubber.test.js
rename to src/components/TracePage/SpanGraph/Scrubber.test.js
index fef202c9cb..8711ac97d1 100644
--- a/src/components/TracePage/TimelineScrubber.test.js
+++ b/src/components/TracePage/SpanGraph/Scrubber.test.js
@@ -22,23 +22,18 @@ import React from 'react';
 import { shallow } from 'enzyme';
 import sinon from 'sinon';
 
-import TimelineScrubber from '../../../src/components/TracePage/TimelineScrubber';
-import traceGenerator from '../../../src/demo/trace-generators';
+import Scrubber from './Scrubber';
 
-import { getTraceTimestamp, getTraceDuration } from '../../../src/selectors/trace';
-
-describe('<TimelineScrubber>', () => {
-  const generatedTrace = traceGenerator.trace({ numberOfSpans: 45 });
+describe('<Scrubber>', () => {
   const defaultProps = {
     onMouseDown: sinon.spy(),
-    trace: generatedTrace,
-    timestamp: getTraceTimestamp(generatedTrace),
+    position: 0,
   };
 
   let wrapper;
 
   beforeEach(() => {
-    wrapper = shallow(<TimelineScrubber {...defaultProps} />);
+    wrapper = shallow(<Scrubber {...defaultProps} />);
   });
 
   it('contains the proper svg components', () => {
@@ -56,8 +51,7 @@ describe('<TimelineScrubber>', () => {
   });
 
   it('calculates the correct x% for a timestamp', () => {
-    const timestamp = getTraceDuration(generatedTrace) * 0.5 + getTraceTimestamp(generatedTrace);
-    wrapper = shallow(<TimelineScrubber {...defaultProps} timestamp={timestamp} />);
+    wrapper = shallow(<Scrubber {...defaultProps} position={0.5} />);
     const line = wrapper.find('line').first();
     const rect = wrapper.find('rect').first();
     expect(line.prop('x1')).toBe('50%');
diff --git a/src/components/TracePage/SpanGraph/TickLabels.css b/src/components/TracePage/SpanGraph/TickLabels.css
new file mode 100644
index 0000000000..5e0c9a20ac
--- /dev/null
+++ b/src/components/TracePage/SpanGraph/TickLabels.css
@@ -0,0 +1,33 @@
+/*
+Copyright (c) 2017 Uber Technologies, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+.TickLabels {
+  height: 1.25rem;
+  position: relative;
+}
+
+.TickLabels--label {
+  color: #717171;
+  font-size: 0.8rem;
+  position: absolute;
+  user-select: none;
+}
diff --git a/src/components/TracePage/SpanGraph/SpanGraphTickHeader.js b/src/components/TracePage/SpanGraph/TickLabels.js
similarity index 81%
rename from src/components/TracePage/SpanGraph/SpanGraphTickHeader.js
rename to src/components/TracePage/SpanGraph/TickLabels.js
index 906f0b05a8..a00070471f 100644
--- a/src/components/TracePage/SpanGraph/SpanGraphTickHeader.js
+++ b/src/components/TracePage/SpanGraph/TickLabels.js
@@ -1,3 +1,5 @@
+// @flow
+
 // Copyright (c) 2017 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -18,12 +20,18 @@
 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 // THE SOFTWARE.
 
-import PropTypes from 'prop-types';
 import React from 'react';
 
 import { formatDuration } from '../../../utils/date';
 
-export default function SpanGraphTickHeader(props) {
+import './TickLabels.css';
+
+type TickLabelsProps = {
+  numTicks: number,
+  duration: number,
+};
+
+export default function TickLabels(props: TickLabelsProps) {
   const { numTicks, duration } = props;
 
   const ticks = [];
@@ -31,20 +39,15 @@ export default function SpanGraphTickHeader(props) {
     const portion = i / numTicks;
     const style = portion === 1 ? { right: '0%' } : { left: `${portion * 100}%` };
     ticks.push(
-      <div key={portion} className="span-graph--tick-header__label" style={style} data-test="tick">
+      <div key={portion} className="TickLabels--label" style={style} data-test="tick">
         {formatDuration(duration * portion)}
       </div>
     );
   }
 
   return (
-    <div className="span-graph--tick-header">
+    <div className="TickLabels">
       {ticks}
     </div>
   );
 }
-
-SpanGraphTickHeader.propTypes = {
-  numTicks: PropTypes.number.isRequired,
-  duration: PropTypes.number.isRequired,
-};
diff --git a/src/components/TracePage/SpanGraph/SpanGraphTickHeader.test.js b/src/components/TracePage/SpanGraph/TickLabels.test.js
similarity index 88%
rename from src/components/TracePage/SpanGraph/SpanGraphTickHeader.test.js
rename to src/components/TracePage/SpanGraph/TickLabels.test.js
index 1821f54731..62cf0b6fa8 100644
--- a/src/components/TracePage/SpanGraph/SpanGraphTickHeader.test.js
+++ b/src/components/TracePage/SpanGraph/TickLabels.test.js
@@ -21,9 +21,9 @@
 import React from 'react';
 import { shallow } from 'enzyme';
 
-import SpanGraphTickHeader from './SpanGraphTickHeader';
+import TickLabels from './TickLabels';
 
-describe('<SpanGraphTickHeader>', () => {
+describe('<TickLabels>', () => {
   const defaultProps = {
     numTicks: 4,
     duration: 5000,
@@ -33,7 +33,7 @@ describe('<SpanGraphTickHeader>', () => {
   let ticks;
 
   beforeEach(() => {
-    wrapper = shallow(<SpanGraphTickHeader {...defaultProps} />);
+    wrapper = shallow(<TickLabels {...defaultProps} />);
     ticks = wrapper.find('[data-test="tick"]');
   });
 
@@ -60,6 +60,6 @@ describe('<SpanGraphTickHeader>', () => {
   });
 
   it("doesn't explode if no trace is present", () => {
-    expect(() => shallow(<SpanGraphTickHeader {...defaultProps} trace={null} />)).not.toThrow();
+    expect(() => shallow(<TickLabels {...defaultProps} trace={null} />)).not.toThrow();
   });
 });
diff --git a/src/components/TracePage/SpanGraph/index.css b/src/components/TracePage/SpanGraph/index.css
index 5210423317..77a28a37b9 100644
--- a/src/components/TracePage/SpanGraph/index.css
+++ b/src/components/TracePage/SpanGraph/index.css
@@ -20,19 +20,24 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
 */
 
-.span-graph--tick {
-  stroke: #aaa;
-  stroke-width: 1px;
+.SpanGraph--zlayer {
+  position: relative;
+  z-index: 1;
 }
 
-.span-graph--tick-header {
+.SpanGraph--graph {
+  border: 1px solid #999;
+  /* need !important here to overcome something from semantic UI */
+  overflow: visible !important;
   position: relative;
+  transform-origin: 0 0;
+  width: 100%;
 }
 
-.span-graph--tick-header__label {
-  position: absolute;
-  top: 0;
-  width: auto;
-  user-select: none;
-  cursor: default;
+.SpanGraph--graph.is-dragging {
+  cursor: ew-resize;
 }
+
+.SpanGraph--inactive {
+  fill: rgba(214, 214, 214, 0.5);
+}
\ No newline at end of file
diff --git a/src/components/TracePage/SpanGraph/index.js b/src/components/TracePage/SpanGraph/index.js
index 4b99908c6a..cae22b935f 100644
--- a/src/components/TracePage/SpanGraph/index.js
+++ b/src/components/TracePage/SpanGraph/index.js
@@ -1,3 +1,5 @@
+// @flow
+
 // Copyright (c) 2017 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -18,65 +20,217 @@
 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 // THE SOFTWARE.
 
+import * as React from 'react';
 import PropTypes from 'prop-types';
-import React from 'react';
+import { window } from 'global';
 
-import colorGenerator from '../../../utils/color-generator';
+import GraphTicks from './GraphTicks';
+import CanvasSpanGraph from './CanvasSpanGraph';
+import TickLabels from './TickLabels';
+import Scrubber from './Scrubber';
+import type { Trace } from '../../../types';
 
 import './index.css';
 
-const MIN_SPAN_WIDTH = 0.002;
+const TIMELINE_TICK_INTERVAL = 4;
+
+type SpanGraphProps = {
+  height: number,
+  trace: Trace,
+  viewRange: [number, number],
+};
+
+type SpanGraphState = {
+  currentlyDragging: ?string,
+  leftBound: ?number,
+  prevX: ?number,
+  rightBound: ?number,
+};
+
+export default class SpanGraph extends React.Component<SpanGraphProps, SpanGraphState> {
+  props: SpanGraphProps;
+  state: SpanGraphState;
+
+  _wrapper: ?HTMLElement;
+  _publishIntervalID: ?number;
 
-export default function SpanGraph(props) {
-  const { valueWidth: totalValueWidth, numTicks, items } = props;
+  static defaultProps = {
+    height: 60,
+  };
 
-  const itemHeight = 1 / items.length * 100;
+  static contextTypes = {
+    updateTimeRangeFilter: PropTypes.func.isRequired,
+  };
 
-  const ticks = [];
-  // i starts at 1, limit is `i < numTicks` so the first and last ticks aren't drawn
-  for (let i = 1; i < numTicks; i++) {
-    const x = `${i / numTicks * 100}%`;
-    ticks.push(<line className="span-graph--tick" x1={x} y1="0%" x2={x} y2="100%" key={i / numTicks} />);
+  constructor(props: SpanGraphProps) {
+    super(props);
+    this.state = {
+      currentlyDragging: null,
+      leftBound: null,
+      prevX: null,
+      rightBound: null,
+    };
+    this._wrapper = undefined;
+    this._setWrapper = this._setWrapper.bind(this);
+    this._publishTimeRange = this._publishTimeRange.bind(this);
+    this._publishIntervalID = undefined;
   }
 
-  const spanItems = items.map((item, i) => {
-    const { valueWidth, valueOffset, serviceName } = item;
-    const key = `span-graph-${i}`;
-    const fill = colorGenerator.getColorByKey(serviceName);
-    const width = `${Math.max(valueWidth / totalValueWidth, MIN_SPAN_WIDTH) * 100}%`;
+  shouldComponentUpdate(nextProps: SpanGraphProps, nextState: SpanGraphState) {
+    const { trace: newTrace, viewRange: newViewRange } = nextProps;
+    const {
+      currentlyDragging: newCurrentlyDragging,
+      leftBound: newLeftBound,
+      rightBound: newRightBound,
+    } = nextState;
+    const { trace, viewRange } = this.props;
+    const { currentlyDragging, leftBound, rightBound } = this.state;
+
     return (
-      <rect
-        key={key}
-        className="span-graph--span-rect"
-        height={`${itemHeight}%`}
-        style={{ fill }}
-        width={width}
-        x={`${valueOffset / totalValueWidth * 100}%`}
-        y={`${i / items.length * 100}%`}
-      />
+      trace.traceID !== newTrace.traceID ||
+      viewRange[0] !== newViewRange[0] ||
+      viewRange[1] !== newViewRange[1] ||
+      currentlyDragging !== newCurrentlyDragging ||
+      leftBound !== newLeftBound ||
+      rightBound !== newRightBound
     );
-  });
-
-  return (
-    <g>
-      <g data-test="ticks" aria-hidden="true">
-        {ticks}
-      </g>
-      <g data-test="span-items">
-        {spanItems}
-      </g>
-    </g>
-  );
-}
+  }
 
-SpanGraph.propTypes = {
-  items: PropTypes.arrayOf(
-    PropTypes.shape({
-      valueWidth: PropTypes.number.isRequired,
-      valueOffset: PropTypes.number.isRequired,
-      serviceName: PropTypes.string.isRequired,
-    })
-  ).isRequired,
-  numTicks: PropTypes.number.isRequired,
-  valueWidth: PropTypes.number.isRequired,
-};
+  _setWrapper = function _setWrapper(elm: React.Node) {
+    this._wrapper = elm;
+  };
+
+  _startDragging(boundName: string, { clientX }: SyntheticMouseEvent<any>) {
+    const { viewRange } = this.props;
+    const [leftBound, rightBound] = viewRange;
+
+    this.setState({ currentlyDragging: boundName, prevX: clientX, leftBound, rightBound });
+
+    const mouseMoveHandler = (...args) => this._onMouseMove(...args);
+    const mouseUpHandler = () => {
+      this._stopDragging();
+      window.removeEventListener('mouseup', mouseUpHandler);
+      window.removeEventListener('mousemove', mouseMoveHandler);
+    };
+
+    window.addEventListener('mouseup', mouseUpHandler);
+    window.addEventListener('mousemove', mouseMoveHandler);
+  }
+
+  _stopDragging() {
+    this._publishTimeRange();
+    this.setState({ currentlyDragging: null, prevX: null });
+  }
+
+  _publishTimeRange = function _publishTimeRange() {
+    const { currentlyDragging, leftBound, rightBound } = this.state;
+    const { updateTimeRangeFilter } = this.context;
+    clearTimeout(this._publishIntervalID);
+    this._publishIntervalID = undefined;
+    if (currentlyDragging) {
+      updateTimeRangeFilter(leftBound, rightBound);
+    }
+  };
+
+  _onMouseMove({ clientX }: SyntheticMouseEvent<any>) {
+    const { currentlyDragging } = this.state;
+    let { leftBound, rightBound } = this.state;
+    if (!currentlyDragging || !this._wrapper) {
+      return;
+    }
+    const newValue = clientX / this._wrapper.clientWidth;
+    switch (currentlyDragging) {
+      case 'leftBound':
+        leftBound = Math.max(0, newValue);
+        break;
+      case 'rightBound':
+        rightBound = Math.min(1, newValue);
+        break;
+      default:
+        break;
+    }
+    this.setState({ prevX: clientX, leftBound, rightBound });
+    if (this._publishIntervalID == null) {
+      this._publishIntervalID = window.requestAnimationFrame(this._publishTimeRange);
+    }
+  }
+
+  render() {
+    const { height, trace, viewRange } = this.props;
+    if (!trace) {
+      return <div />;
+    }
+    const { currentlyDragging } = this.state;
+    let { leftBound, rightBound } = this.state;
+    if (!currentlyDragging) {
+      leftBound = viewRange[0];
+      rightBound = viewRange[1];
+    }
+    let leftInactive;
+    if (leftBound) {
+      leftInactive = leftBound * 100;
+    }
+    let rightInactive;
+    if (rightBound) {
+      rightInactive = 100 - rightBound * 100;
+    }
+    return (
+      <div>
+        <TickLabels numTicks={TIMELINE_TICK_INTERVAL} duration={trace.duration} />
+        <div className="relative" ref={this._setWrapper}>
+          <CanvasSpanGraph
+            valueWidth={trace.duration}
+            items={trace.spans.map(span => ({
+              valueOffset: span.relativeStartTime,
+              valueWidth: span.duration,
+              serviceName: span.process.serviceName,
+            }))}
+          />
+          <div className="SpanGraph--zlayer">
+            <svg height={height} className={`SpanGraph--graph ${currentlyDragging ? 'is-dragging' : ''}`}>
+              {leftInactive &&
+                <rect x={0} y={0} height="100%" width={`${leftInactive}%`} className="SpanGraph--inactive" />}
+              {rightInactive &&
+                <rect
+                  x={`${100 - rightInactive}%`}
+                  y={0}
+                  height="100%"
+                  width={`${rightInactive}%`}
+                  className="SpanGraph--inactive"
+                />}
+              <GraphTicks
+                valueWidth={trace.duration}
+                numTicks={TIMELINE_TICK_INTERVAL}
+                items={trace.spans.map(span => ({
+                  valueOffset: span.relativeStartTime,
+                  valueWidth: span.duration,
+                  serviceName: span.process.serviceName,
+                }))}
+              />
+              {
+                <Scrubber
+                  id="trace-page-timeline__left-bound-handle"
+                  position={leftBound || 0}
+                  handleWidth={8}
+                  handleHeight={30}
+                  handleTopOffset={15}
+                  onMouseDown={event => this._startDragging('leftBound', event)}
+                />
+              }
+              {
+                <Scrubber
+                  id="trace-page-timeline__right-bound-handle"
+                  position={rightBound || 1}
+                  handleWidth={8}
+                  handleHeight={30}
+                  handleTopOffset={15}
+                  onMouseDown={event => this._startDragging('rightBound', event)}
+                />
+              }
+            </svg>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/src/components/TracePage/SpanGraph/index.test.js b/src/components/TracePage/SpanGraph/index.test.js
index 932c98d7d7..b64ce19940 100644
--- a/src/components/TracePage/SpanGraph/index.test.js
+++ b/src/components/TracePage/SpanGraph/index.test.js
@@ -19,48 +19,217 @@
 // THE SOFTWARE.
 
 import React from 'react';
+import sinon from 'sinon';
 import { shallow } from 'enzyme';
 
-import SpanGraph from './';
+import CanvasSpanGraph from './CanvasSpanGraph';
+import SpanGraph from './index';
+import GraphTicks from './GraphTicks';
+import TickLabels from './TickLabels';
+import TimelineScrubber from './Scrubber';
+import traceGenerator from '../../../../src/demo/trace-generators';
+import transformTraceData from '../../../model/transform-trace-data';
+import { polyfill as polyfillAnimationFrame } from '../../../utils/test/requestAnimationFrame';
 
 describe('<SpanGraph>', () => {
-  const defaultProps = {
-    items: [
-      { valueWidth: 100, valueOffset: 25, serviceName: 'a' },
-      { valueWidth: 100, valueOffset: 50, serviceName: 'b' },
-    ],
-    valueWidth: 200,
-    numTicks: 4,
+  polyfillAnimationFrame(window);
+
+  const trace = transformTraceData(traceGenerator.trace({}));
+  const props = { trace, viewRange: [0, 1] };
+  const options = {
+    context: {
+      updateTimeRangeFilter: () => {},
+    },
   };
 
-  let itemsG;
-  let ticksG;
+  let wrapper;
 
   beforeEach(() => {
-    const wrapper = shallow(<SpanGraph {...defaultProps} />);
-    itemsG = wrapper.find('[data-test="span-items"]');
-    ticksG = wrapper.find('[data-test="ticks"]');
+    wrapper = shallow(<SpanGraph {...props} />, options);
+  });
+
+  it('renders a <CanvasSpanGraph />', () => {
+    expect(wrapper.find(CanvasSpanGraph).length).toBe(1);
+  });
+
+  it('renders a <TickLabels />', () => {
+    expect(wrapper.find(TickLabels).length).toBe(1);
+  });
+
+  it('returns a <div> if a trace is not provided', () => {
+    wrapper = shallow(<SpanGraph {...props} trace={null} />, options);
+    expect(wrapper.matchesElement(<div />)).toBeTruthy();
   });
 
-  it('renders a <g>', () => {
-    expect(itemsG.length).toBe(1);
+  it('renders a filtering box if leftBound exists', () => {
+    const _props = { ...props, viewRange: [0.2, 1] };
+    wrapper = shallow(<SpanGraph {..._props} />, options);
+    const leftBox = wrapper.find('.SpanGraph--inactive');
+    expect(leftBox.length).toBe(1);
+    const width = Number(leftBox.prop('width').slice(0, -1));
+    const x = leftBox.prop('x');
+    expect(Math.round(width)).toBe(20);
+    expect(x).toBe(0);
   });
 
-  it('calculates the height of rects based on the number of items', () => {
-    const rect = itemsG.find('rect').first();
-    expect(rect).toBeDefined();
-    expect(rect.prop('height')).toBe('50%');
+  it('renders a filtering box if rightBound exists', () => {
+    const _props = { ...props, viewRange: [0, 0.8] };
+    wrapper = shallow(<SpanGraph {..._props} />, options);
+    const rightBox = wrapper.find('.SpanGraph--inactive');
+    const width = Number(rightBox.prop('width').slice(0, -1));
+    const x = Number(rightBox.prop('x').slice(0, -1));
+    expect(rightBox.length).toBe(1);
+    expect(Math.round(width)).toBe(20);
+    expect(Math.round(x)).toBe(80);
   });
 
-  it('creates a <g> for ticks', () => {
-    expect(ticksG.length).toBe(1);
+  it('renders handles for the timeRangeFilter', () => {
+    const { viewRange } = props;
+    let scrubber = <TimelineScrubber position={viewRange[0]} />;
+    expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy();
+    scrubber = <TimelineScrubber position={viewRange[1]} />;
+    expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy();
   });
 
-  it('creates a line for each ticks excluding the first and last', () => {
-    expect(ticksG.find('line').length).toBe(defaultProps.numTicks - 1);
+  it('calls startDragging() for the leftBound handle', () => {
+    const event = { clientX: 50 };
+    sinon.stub(wrapper.instance(), '_startDragging');
+    wrapper.find('#trace-page-timeline__left-bound-handle').prop('onMouseDown')(event);
+    expect(wrapper.instance()._startDragging.calledWith('leftBound', event)).toBeTruthy();
+  });
+
+  it('calls startDragging for the rightBound handle', () => {
+    const event = { clientX: 50 };
+    sinon.stub(wrapper.instance(), '_startDragging');
+    wrapper.find('#trace-page-timeline__right-bound-handle').prop('onMouseDown')(event);
+    expect(wrapper.instance()._startDragging.calledWith('rightBound', event)).toBeTruthy();
+  });
+
+  it('passes the number of ticks to render to components', () => {
+    const tickHeader = wrapper.find(TickLabels);
+    const graphTicks = wrapper.find(GraphTicks);
+    expect(tickHeader.prop('numTicks')).toBeGreaterThan(1);
+    expect(graphTicks.prop('numTicks')).toBeGreaterThan(1);
+    expect(tickHeader.prop('numTicks')).toBe(graphTicks.prop('numTicks'));
+  });
+
+  it('passes items to CanvasSpanGraph', () => {
+    const canvasGraph = wrapper.find(CanvasSpanGraph).first();
+    const items = trace.spans.map(span => ({
+      valueOffset: span.relativeStartTime,
+      valueWidth: span.duration,
+      serviceName: span.process.serviceName,
+    }));
+    expect(canvasGraph.prop('items')).toEqual(items);
+  });
+
+  describe('# shouldComponentUpdate()', () => {
+    it('returns true for new timeRangeFilter', () => {
+      const state = { ...wrapper.state(), leftBound: Math.random(), rightBound: Math.random() };
+      const instance = wrapper.instance();
+      expect(instance.shouldComponentUpdate(props, state, options.context)).toBe(true);
+    });
+
+    it('returns true for new trace', () => {
+      const state = wrapper.state();
+      const instance = wrapper.instance();
+      const trace2 = transformTraceData(traceGenerator.trace({}));
+      const altProps = { trace: trace2 };
+      expect(instance.shouldComponentUpdate(altProps, state, options.context)).toBe(true);
+    });
+
+    it('returns true for currentlyDragging', () => {
+      const state = { ...wrapper.state(), currentlyDragging: !wrapper.state('currentlyDragging') };
+      const instance = wrapper.instance();
+      expect(instance.shouldComponentUpdate(props, state, options.context)).toBe(true);
+    });
+
+    it('returns false, generally', () => {
+      const state = wrapper.state();
+      const instance = wrapper.instance();
+      expect(instance.shouldComponentUpdate(props, state, options.context)).toBe(false);
+    });
+  });
+
+  describe('# onMouseMove()', () => {
+    it('does nothing if currentlyDragging is false', () => {
+      const updateTimeRangeFilter = sinon.spy();
+      const context = { ...options.context, updateTimeRangeFilter };
+      wrapper = shallow(<SpanGraph {...props} />, { ...options, context });
+      wrapper.instance()._onMouseMove({ clientX: 45 });
+      expect(wrapper.state('prevX')).toBe(null);
+      expect(updateTimeRangeFilter.called).toBeFalsy();
+    });
+
+    it('stores the clientX on .state', () => {
+      wrapper.instance()._wrapper = { clientWidth: 100 };
+      wrapper.setState({ currentlyDragging: 'leftBound' });
+      wrapper.instance()._onMouseMove({ clientX: 45 });
+      expect(wrapper.state('prevX')).toBe(45);
+    });
+
+    it('updates the timeRangeFilter for the left handle', () => {
+      const updateTimeRangeFilter = sinon.spy();
+      const context = { ...options.context, updateTimeRangeFilter };
+      wrapper = shallow(<SpanGraph {...props} />, { ...options, context });
+      wrapper.instance()._wrapper = { clientWidth: 100 };
+      const [leftBound, rightBound] = props.viewRange;
+      const state = { ...wrapper.state(), leftBound, rightBound, prevX: 0, currentlyDragging: 'leftBound' };
+      wrapper.setState(state);
+      wrapper.instance()._onMouseMove({ clientX: 45 });
+      wrapper.instance()._publishTimeRange();
+      expect(updateTimeRangeFilter.calledWith(0.45, 1)).toBeTruthy();
+    });
+
+    it('updates the timeRangeFilter for the right handle', () => {
+      const updateTimeRangeFilter = sinon.spy();
+      const context = { ...options.context, updateTimeRangeFilter };
+      wrapper = shallow(<SpanGraph {...props} />, { ...options, context });
+      wrapper.instance()._wrapper = { clientWidth: 100 };
+      const [leftBound, rightBound] = props.viewRange;
+      const state = {
+        ...wrapper.state(),
+        leftBound,
+        rightBound,
+        prevX: 100,
+        currentlyDragging: 'rightBound',
+      };
+      wrapper.setState(state);
+      wrapper.instance()._onMouseMove({ clientX: 45 });
+      wrapper.instance()._publishTimeRange();
+      expect(updateTimeRangeFilter.calledWith(0, 0.45)).toBeTruthy();
+    });
+  });
+
+  describe('# _startDragging()', () => {
+    it('stores the boundName and the prevX in state', () => {
+      wrapper.instance()._startDragging('leftBound', { clientX: 100 });
+      expect(wrapper.state('currentlyDragging')).toBe('leftBound');
+      expect(wrapper.state('prevX')).toBe(100);
+    });
+
+    it('binds event listeners to the window', () => {
+      const oldFn = window.addEventListener;
+      const fn = jest.fn();
+      window.addEventListener = fn;
+
+      wrapper.instance()._startDragging('leftBound', { clientX: 100 });
+      expect(fn.mock.calls.length).toBe(2);
+      const eventNames = [fn.mock.calls[0][0], fn.mock.calls[1][0]].sort();
+      expect(eventNames).toEqual(['mousemove', 'mouseup']);
+      window.addEventListener = oldFn;
+    });
   });
 
-  it('creates a rect for each item in the items prop', () => {
-    expect(itemsG.find('rect').length).toBe(defaultProps.items.length);
+  describe('# _stopDragging()', () => {
+    it('clears currentlyDragging and prevX', () => {
+      const instance = wrapper.instance();
+      instance._startDragging('leftBound', { clientX: 100 });
+      expect(wrapper.state('currentlyDragging')).toBe('leftBound');
+      expect(wrapper.state('prevX')).toBe(100);
+      instance._stopDragging();
+      expect(wrapper.state('currentlyDragging')).toBe(null);
+      expect(wrapper.state('prevX')).toBe(null);
+    });
   });
 });
diff --git a/src/components/TracePage/SpanGraph/render-into-canvas.js b/src/components/TracePage/SpanGraph/render-into-canvas.js
new file mode 100644
index 0000000000..3f09c3de19
--- /dev/null
+++ b/src/components/TracePage/SpanGraph/render-into-canvas.js
@@ -0,0 +1,49 @@
+// @flow
+
+// Copyright (c) 2017 Uber Technologies, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+const CV_WIDTH = 4000;
+const MIN_WIDTH = 50;
+
+export default function renderIntoCanvas(
+  canvas: HTMLCanvasElement,
+  items: { valueWidth: number, valueOffset: number, serviceName: string }[],
+  totalValueWidth: number,
+  getFillColor: string => string
+) {
+  // eslint-disable-next-line  no-param-reassign
+  canvas.width = CV_WIDTH;
+  // eslint-disable-next-line  no-param-reassign
+  canvas.height = items.length;
+  const ctx = canvas.getContext('2d');
+  for (let i = 0; i < items.length; i++) {
+    const { valueWidth, valueOffset, serviceName } = items[i];
+    // eslint-disable-next-line no-bitwise
+    const x = (valueOffset / totalValueWidth * CV_WIDTH) | 0;
+    // eslint-disable-next-line no-bitwise
+    let width = (valueWidth / totalValueWidth * CV_WIDTH) | 0;
+    if (width < MIN_WIDTH) {
+      width = MIN_WIDTH;
+    }
+    ctx.fillStyle = getFillColor(serviceName);
+    ctx.fillRect(x, i, width, 1);
+  }
+}
diff --git a/src/components/TracePage/TraceSpanGraph.js b/src/components/TracePage/TraceSpanGraph.js
deleted file mode 100644
index c9bbfd9540..0000000000
--- a/src/components/TracePage/TraceSpanGraph.js
+++ /dev/null
@@ -1,215 +0,0 @@
-// Copyright (c) 2017 Uber Technologies, Inc.
-//
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-// THE SOFTWARE.
-
-import React, { Component } from 'react';
-import { window } from 'global';
-import PropTypes from 'prop-types';
-
-import SpanGraph from './SpanGraph';
-import SpanGraphTickHeader from './SpanGraph/SpanGraphTickHeader';
-import TimelineScrubber from './TimelineScrubber';
-import { getPercentageOfInterval } from '../../utils/date';
-
-const TIMELINE_TICK_INTERVAL = 4;
-
-export default class TraceSpanGraph extends Component {
-  static get propTypes() {
-    return {
-      trace: PropTypes.object,
-      height: PropTypes.number.isRequired,
-    };
-  }
-
-  static get defaultProps() {
-    return {
-      height: 60,
-    };
-  }
-
-  static get contextTypes() {
-    return {
-      timeRangeFilter: PropTypes.arrayOf(PropTypes.number).isRequired,
-      updateTimeRangeFilter: PropTypes.func.isRequired,
-    };
-  }
-
-  constructor(props) {
-    super(props);
-    this.state = { currentlyDragging: null, prevX: null };
-  }
-
-  shouldComponentUpdate(
-    { trace: newTrace },
-    { currentlyDragging: newCurrentlyDragging },
-    { timeRangeFilter: newTimeRangeFilter }
-  ) {
-    const { trace } = this.props;
-    const { currentlyDragging } = this.state;
-    const { timeRangeFilter } = this.context;
-    const leftBound = timeRangeFilter[0];
-    const rightBound = timeRangeFilter[1];
-    const newLeftBound = newTimeRangeFilter[0];
-    const newRightBound = newTimeRangeFilter[1];
-
-    return (
-      trace.traceID !== newTrace.traceID ||
-      leftBound !== newLeftBound ||
-      rightBound !== newRightBound ||
-      currentlyDragging !== newCurrentlyDragging
-    );
-  }
-
-  startDragging(boundName, { clientX }) {
-    this.setState({ currentlyDragging: boundName, prevX: clientX });
-
-    const mouseMoveHandler = (...args) => this.onMouseMove(...args);
-    const mouseUpHandler = () => {
-      this.stopDragging();
-      window.removeEventListener('mouseup', mouseUpHandler);
-      window.removeEventListener('mousemove', mouseMoveHandler);
-    };
-
-    window.addEventListener('mouseup', mouseUpHandler);
-    window.addEventListener('mousemove', mouseMoveHandler);
-  }
-
-  stopDragging() {
-    this.setState({ currentlyDragging: null, prevX: null });
-  }
-
-  onMouseMove({ clientX }) {
-    const { trace } = this.props;
-    const { prevX, currentlyDragging } = this.state;
-    const { timeRangeFilter, updateTimeRangeFilter } = this.context;
-
-    if (!currentlyDragging) {
-      return;
-    }
-
-    let leftBound = timeRangeFilter[0];
-    let rightBound = timeRangeFilter[1];
-
-    const deltaX = (clientX - prevX) / this.svg.clientWidth;
-    const prevValue = { leftBound, rightBound }[currentlyDragging];
-    const newValue = prevValue + trace.duration * deltaX;
-
-    // enforce the edges of the graph
-    switch (currentlyDragging) {
-      case 'leftBound':
-        leftBound = Math.max(trace.startTime, newValue);
-        break;
-      case 'rightBound':
-        rightBound = Math.min(trace.endTime, newValue);
-        break;
-      /* istanbul ignore next */ default:
-        break;
-    }
-
-    this.setState({ prevX: clientX });
-    if (leftBound <= rightBound) {
-      updateTimeRangeFilter(leftBound, rightBound);
-    }
-  }
-
-  render() {
-    const { trace, height } = this.props;
-    const { currentlyDragging } = this.state;
-    const { timeRangeFilter } = this.context;
-    const leftBound = timeRangeFilter[0];
-    const rightBound = timeRangeFilter[1];
-
-    if (!trace) {
-      return <div />;
-    }
-
-    let leftInactive;
-    if (leftBound) {
-      leftInactive = getPercentageOfInterval(leftBound, trace.startTime, trace.duration);
-    }
-
-    let rightInactive;
-    if (rightBound) {
-      rightInactive = 100 - getPercentageOfInterval(rightBound, trace.startTime, trace.duration);
-    }
-
-    return (
-      <div>
-        <div className="trace-page-timeline--tick-container">
-          <SpanGraphTickHeader numTicks={TIMELINE_TICK_INTERVAL} duration={trace.duration} />
-        </div>
-        <div>
-          <svg
-            height={height}
-            className={`trace-page-timeline__graph ${currentlyDragging ? 'is-dragging' : ''}`}
-            ref={/* istanbul ignore next */ c => {
-              this.svg = c;
-            }}
-          >
-            {leftInactive > 0 &&
-              <rect
-                x={0}
-                y={0}
-                height="100%"
-                width={`${leftInactive}%`}
-                className="trace-page-timeline__graph--inactive"
-              />}
-            {rightInactive > 0 &&
-              <rect
-                x={`${100 - rightInactive}%`}
-                y={0}
-                height="100%"
-                width={`${rightInactive}%`}
-                className="trace-page-timeline__graph--inactive"
-              />}
-            <SpanGraph
-              valueWidth={trace.duration}
-              numTicks={TIMELINE_TICK_INTERVAL}
-              items={trace.spans.map(span => ({
-                valueOffset: span.relativeStartTime,
-                valueWidth: span.duration,
-                serviceName: span.process.serviceName,
-              }))}
-            />
-            {leftBound &&
-              <TimelineScrubber
-                id="trace-page-timeline__left-bound-handle"
-                trace={trace}
-                timestamp={leftBound}
-                handleWidth={8}
-                handleHeight={30}
-                handleTopOffset={15}
-                onMouseDown={(...args) => this.startDragging('leftBound', ...args)}
-              />}
-            {rightBound &&
-              <TimelineScrubber
-                id="trace-page-timeline__right-bound-handle"
-                trace={trace}
-                timestamp={rightBound}
-                handleWidth={8}
-                handleHeight={30}
-                handleTopOffset={15}
-                onMouseDown={(...args) => this.startDragging('rightBound', ...args)}
-              />}
-          </svg>
-        </div>
-      </div>
-    );
-  }
-}
diff --git a/src/components/TracePage/TraceSpanGraph.test.js b/src/components/TracePage/TraceSpanGraph.test.js
deleted file mode 100644
index 9c9c8e1450..0000000000
--- a/src/components/TracePage/TraceSpanGraph.test.js
+++ /dev/null
@@ -1,242 +0,0 @@
-// Copyright (c) 2017 Uber Technologies, Inc.
-//
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-// THE SOFTWARE.
-
-import React from 'react';
-import sinon from 'sinon';
-import { shallow } from 'enzyme';
-
-import SpanGraph from './SpanGraph';
-import TraceSpanGraph from './TraceSpanGraph';
-import SpanGraphTickHeader from './SpanGraph/SpanGraphTickHeader';
-import TimelineScrubber from './TimelineScrubber';
-import traceGenerator from '../../../src/demo/trace-generators';
-import transformTraceData from '../../model/transform-trace-data';
-
-describe('<TraceSpanGraph>', () => {
-  const trace = transformTraceData(traceGenerator.trace({}));
-  const props = { trace };
-  const options = {
-    context: {
-      timeRangeFilter: [trace.startTime, trace.startTime + trace.duration],
-      updateTimeRangeFilter: () => {},
-    },
-  };
-
-  let wrapper;
-
-  beforeEach(() => {
-    wrapper = shallow(<TraceSpanGraph {...props} />, options);
-  });
-
-  it('renders a <SpanGraph />', () => {
-    expect(wrapper.find(SpanGraph).length).toBe(1);
-  });
-
-  it('renders a <SpanGraphTickHeader />', () => {
-    expect(wrapper.find(SpanGraphTickHeader).length).toBe(1);
-  });
-
-  it('returns a <div> if a trace is not provided', () => {
-    wrapper = shallow(<TraceSpanGraph {...props} trace={null} />, options);
-    expect(wrapper.matchesElement(<div />)).toBeTruthy();
-  });
-
-  it('renders a filtering box if leftBound exists', () => {
-    const context = {
-      ...options.context,
-      timeRangeFilter: [trace.startTime + 0.2 * trace.duration, trace.startTime + trace.duration],
-    };
-    wrapper = shallow(<TraceSpanGraph {...props} />, { ...options, context });
-    const leftBox = wrapper.find('.trace-page-timeline__graph--inactive');
-    expect(leftBox.length).toBe(1);
-    const width = Number(leftBox.prop('width').slice(0, -1));
-    const x = leftBox.prop('x');
-    expect(Math.round(width)).toBe(20);
-    expect(x).toBe(0);
-  });
-
-  it('renders a filtering box if rightBound exists', () => {
-    const context = {
-      ...options.context,
-      timeRangeFilter: [trace.startTime, trace.startTime + 0.8 * trace.duration],
-    };
-    wrapper = shallow(<TraceSpanGraph {...props} />, { ...options, context });
-    const rightBox = wrapper.find('.trace-page-timeline__graph--inactive');
-    const width = Number(rightBox.prop('width').slice(0, -1));
-    const x = Number(rightBox.prop('x').slice(0, -1));
-    expect(rightBox.length).toBe(1);
-    expect(Math.round(width)).toBe(20);
-    expect(Math.round(x)).toBe(80);
-  });
-
-  it('renders handles for the timeRangeFilter', () => {
-    const timeRangeFilter = options.context.timeRangeFilter;
-    let scrubber = <TimelineScrubber timestamp={timeRangeFilter[0]} />;
-    expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy();
-    scrubber = <TimelineScrubber timestamp={timeRangeFilter[1]} />;
-    expect(wrapper.containsMatchingElement(scrubber)).toBeTruthy();
-  });
-
-  it('calls startDragging() for the leftBound handle', () => {
-    const event = { clientX: 50 };
-    sinon.stub(wrapper.instance(), 'startDragging');
-    wrapper.find('#trace-page-timeline__left-bound-handle').prop('onMouseDown')(event);
-    expect(wrapper.instance().startDragging.calledWith('leftBound', event)).toBeTruthy();
-  });
-
-  it('calls startDragging for the rightBound handle', () => {
-    const event = { clientX: 50 };
-    sinon.stub(wrapper.instance(), 'startDragging');
-    wrapper.find('#trace-page-timeline__right-bound-handle').prop('onMouseDown')(event);
-    expect(wrapper.instance().startDragging.calledWith('rightBound', event)).toBeTruthy();
-  });
-
-  it('renders without handles if not filtering', () => {
-    const context = { ...options.context, timeRangeFilter: [] };
-    wrapper = shallow(<TraceSpanGraph {...props} />, { ...options, context });
-    expect(wrapper.find('rect').length).toBe(0);
-    expect(wrapper.find(TimelineScrubber).length).toBe(0);
-  });
-
-  it('passes the number of ticks to rendered to components', () => {
-    const tickHeader = wrapper.find(SpanGraphTickHeader);
-    const spanGraph = wrapper.find(SpanGraph);
-    expect(tickHeader.prop('numTicks')).toBeGreaterThan(1);
-    expect(spanGraph.prop('numTicks')).toBeGreaterThan(1);
-    expect(tickHeader.prop('numTicks')).toBe(spanGraph.prop('numTicks'));
-  });
-
-  it('passes items to SpanGraph', () => {
-    const spanGraph = wrapper.find(SpanGraph).first();
-    const items = trace.spans.map(span => ({
-      valueOffset: span.relativeStartTime,
-      valueWidth: span.duration,
-      serviceName: span.process.serviceName,
-    }));
-    expect(spanGraph.prop('items')).toEqual(items);
-  });
-
-  describe('# shouldComponentUpdate()', () => {
-    it('returns true for new timeRangeFilter', () => {
-      const state = wrapper.state();
-      const context = { timeRangeFilter: [Math.random(), Math.random()] };
-      const instance = wrapper.instance();
-      expect(instance.shouldComponentUpdate(props, state, context)).toBe(true);
-    });
-
-    it('returns true for new trace', () => {
-      const state = wrapper.state();
-      const instance = wrapper.instance();
-      const trace2 = transformTraceData(traceGenerator.trace({}));
-      const altProps = { trace: trace2 };
-      expect(instance.shouldComponentUpdate(altProps, state, options.context)).toBe(true);
-    });
-
-    it('returns true for currentlyDragging', () => {
-      const state = { ...wrapper.state(), currentlyDragging: !wrapper.state('currentlyDragging') };
-      const instance = wrapper.instance();
-      expect(instance.shouldComponentUpdate(props, state, options.context)).toBe(true);
-    });
-
-    it('returns false, generally', () => {
-      const state = wrapper.state();
-      const instance = wrapper.instance();
-      expect(instance.shouldComponentUpdate(props, state, options.context)).toBe(false);
-    });
-  });
-
-  describe('# onMouseMove()', () => {
-    it('does nothing if currentlyDragging is false', () => {
-      const updateTimeRangeFilter = sinon.spy();
-      const context = { ...options.context, updateTimeRangeFilter };
-      wrapper = shallow(<TraceSpanGraph {...props} />, { ...options, context });
-      wrapper.instance().svg = { clientWidth: 100 };
-      wrapper.setState({ currentlyDragging: null });
-      wrapper.instance().onMouseMove({ clientX: 45 });
-      expect(wrapper.state('prevX')).toBe(null);
-      expect(updateTimeRangeFilter.called).toBeFalsy();
-    });
-
-    it('stores the clientX on .state', () => {
-      wrapper.instance().svg = { clientWidth: 100 };
-      wrapper.setState({ currentlyDragging: 'leftBound' });
-      wrapper.instance().onMouseMove({ clientX: 45 });
-      expect(wrapper.state('prevX')).toBe(45);
-    });
-
-    it('updates the timeRangeFilter for the left handle', () => {
-      const timestamp = trace.startTime;
-      const duration = trace.duration;
-      const updateTimeRangeFilter = sinon.spy();
-      const context = { ...options.context, updateTimeRangeFilter };
-      wrapper = shallow(<TraceSpanGraph {...props} />, { ...options, context });
-      wrapper.instance().svg = { clientWidth: 100 };
-      wrapper.setState({ prevX: 0, currentlyDragging: 'leftBound' });
-      wrapper.instance().onMouseMove({ clientX: 45 });
-      expect(
-        updateTimeRangeFilter.calledWith(timestamp + 0.45 * duration, timestamp + duration)
-      ).toBeTruthy();
-    });
-
-    it('updates the timeRangeFilter for the right handle', () => {
-      const timestamp = trace.startTime;
-      const duration = trace.duration;
-      const updateTimeRangeFilter = sinon.spy();
-      const context = { ...options.context, updateTimeRangeFilter };
-      wrapper = shallow(<TraceSpanGraph {...props} />, { ...options, context });
-      wrapper.instance().svg = { clientWidth: 100 };
-      wrapper.setState({ prevX: 100, currentlyDragging: 'rightBound' });
-      wrapper.instance().onMouseMove({ clientX: 45 });
-      expect(updateTimeRangeFilter.calledWith(timestamp, timestamp + 0.45 * duration)).toBeTruthy();
-    });
-  });
-
-  describe('# startDragging()', () => {
-    it('stores the boundName and the prevX in state', () => {
-      wrapper.instance().startDragging('leftBound', { clientX: 100 });
-      expect(wrapper.state('currentlyDragging')).toBe('leftBound');
-      expect(wrapper.state('prevX')).toBe(100);
-    });
-
-    it('binds event listeners to the window', () => {
-      const oldFn = window.addEventListener;
-      const fn = jest.fn();
-      window.addEventListener = fn;
-
-      wrapper.instance().startDragging('leftBound', { clientX: 100 });
-      expect(fn.mock.calls.length).toBe(2);
-      const eventNames = [fn.mock.calls[0][0], fn.mock.calls[1][0]].sort();
-      expect(eventNames).toEqual(['mousemove', 'mouseup']);
-      window.addEventListener = oldFn;
-    });
-  });
-
-  describe('# stopDragging()', () => {
-    it('TraceSpanGraph.stopDragging should clear currentlyDragging and prevX', () => {
-      const instance = wrapper.instance();
-      instance.startDragging('leftBound', { clientX: 100 });
-      expect(wrapper.state('currentlyDragging')).toBe('leftBound');
-      expect(wrapper.state('prevX')).toBe(100);
-      instance.stopDragging();
-      expect(wrapper.state('currentlyDragging')).toBe(null);
-      expect(wrapper.state('prevX')).toBe(null);
-    });
-  });
-});
diff --git a/src/components/TracePage/TraceTimelineViewer/Ticks.css b/src/components/TracePage/TraceTimelineViewer/Ticks.css
index f89b9ac975..71fde1738d 100644
--- a/src/components/TracePage/TraceTimelineViewer/Ticks.css
+++ b/src/components/TracePage/TraceTimelineViewer/Ticks.css
@@ -27,8 +27,9 @@ THE SOFTWARE.
   background: #d3d3d3;
 }
 .span-row-tick-label {
-  position: absolute;
+  bottom: 0.5rem;
   left: 5px;
+  position: absolute;
 }
 
 .span-row-tick-label.is-end-anchor {
diff --git a/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js b/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js
index 484a1ed1f5..152f9076a7 100644
--- a/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js
+++ b/src/components/TracePage/TraceTimelineViewer/VirtualizedTraceView.js
@@ -347,11 +347,7 @@ class VirtualizedTraceView extends React.PureComponent<VirtualizedTraceViewProps
             <h3 className="m0 p1 VirtualizedTraceView--labelHeader">Service &amp; Operation</h3>
           </TimelineRow.Left>
           <TimelineRow.Right>
-            <Ticks
-              labels={ticks.map(tick => (tick > 0 ? formatDuration(getDuationAtTick(tick)) : ''))}
-              ticks={ticks}
-            />
-            <h3 className="m0 p1">Timeline</h3>
+            <Ticks labels={ticks.map(tick => formatDuration(getDuationAtTick(tick)))} ticks={ticks} />
           </TimelineRow.Right>
         </TimelineRow>
         <div className="VirtualizedTraceView--spans">
diff --git a/src/components/TracePage/TraceTimelineViewer/index.js b/src/components/TracePage/TraceTimelineViewer/index.js
index 48c8d870fa..3ea28a3b87 100644
--- a/src/components/TracePage/TraceTimelineViewer/index.js
+++ b/src/components/TracePage/TraceTimelineViewer/index.js
@@ -22,7 +22,6 @@ import PropTypes from 'prop-types';
 import React, { Component } from 'react';
 
 import VirtualizedTraceView from './VirtualizedTraceView';
-import { getPositionInRange } from './utils';
 
 import './grid.css';
 import './index.css';
@@ -37,14 +36,13 @@ export default class TraceTimelineViewer extends Component {
 
   render() {
     const { timeRangeFilter: zoomRange, textFilter, trace } = this.props;
-    const { startTime, endTime } = trace;
     return (
       <div className="trace-timeline-viewer">
         <VirtualizedTraceView
           textFilter={textFilter}
           trace={trace}
-          zoomStart={getPositionInRange(startTime, endTime, zoomRange[0])}
-          zoomEnd={getPositionInRange(startTime, endTime, zoomRange[1])}
+          zoomStart={zoomRange[0]}
+          zoomEnd={zoomRange[1]}
         />
       </div>
     );
diff --git a/src/components/TracePage/TraceTimelineViewer/utils.js b/src/components/TracePage/TraceTimelineViewer/utils.js
index 7420efd00a..d15b128b43 100644
--- a/src/components/TracePage/TraceTimelineViewer/utils.js
+++ b/src/components/TracePage/TraceTimelineViewer/utils.js
@@ -44,24 +44,6 @@ export function getViewedBounds({ min, max, start, end, viewStart, viewEnd }) {
   };
 }
 
-/**
- * Given `start` and `end`, returns the position of `value` within that range
- * with `0` returned when `value` is equal to `start` and `1` return when it
- * is equal to `end`.
- *
- * @param  {number} start The start of the range to find `value`'s position in.
- * @param  {number} end   The end of the range.
- * @param  {number} value The value to find the position of.
- * @return {number}       A number representing the placement of `value`
- *                        relative to `start` and `end`.
- */
-export function getPositionInRange(start, end, value) {
-  if (value == null) {
-    return undefined;
-  }
-  return (value - start) / (end - start);
-}
-
 /**
  * Returns `true` if the `span` has a tag matching `key` = `value`.
  *
diff --git a/src/components/TracePage/TraceTimelineViewer/utils.test.js b/src/components/TracePage/TraceTimelineViewer/utils.test.js
index 661dfca0f2..d2d1210a94 100644
--- a/src/components/TracePage/TraceTimelineViewer/utils.test.js
+++ b/src/components/TracePage/TraceTimelineViewer/utils.test.js
@@ -19,7 +19,6 @@
 // THE SOFTWARE.
 
 import {
-  getPositionInRange,
   getViewedBounds,
   isClientSpan,
   isErrorSpan,
@@ -61,16 +60,6 @@ describe('TraceTimelineViewer/utils', () => {
     });
   });
 
-  describe('getPositionInRange()', () => {
-    it('gets the position of a value within a range', () => {
-      expect(getPositionInRange(100, 200, 150)).toBe(0.5);
-      expect(getPositionInRange(100, 200, 0)).toBe(-1);
-      expect(getPositionInRange(100, 200, 200)).toBe(1);
-      expect(getPositionInRange(100, 200, 100)).toBe(0);
-      expect(getPositionInRange(0, 200, 100)).toBe(0.5);
-    });
-  });
-
   describe('spanHasTag() and variants', () => {
     it('returns true iff the key/value pair is found', () => {
       const tags = traceGenerator.tags();
diff --git a/src/components/TracePage/index.css b/src/components/TracePage/index.css
index 3f1ddd3ba9..018a9241f0 100644
--- a/src/components/TracePage/index.css
+++ b/src/components/TracePage/index.css
@@ -38,60 +38,3 @@ THE SOFTWARE.
 .trace-timeline-section {
   border-top: 1px solid #999;
 }
-
-/* timeline */
-.trace-page-timeline--tick-container{
-  position: relative;
-  height: 1.25rem;
-}
-
-.trace-page-timeline--tick-container .span-graph--tick-header__label {
-  font-size: 0.8rem;
-  color: #717171;
-}
-
-.trace-page-timeline__graph {
-  background: #f8f8f8;
-  border: 1px solid #999;
-  overflow: visible !important;
-  transform-origin: 0 0;
-  width: 100%;
-}
-
-.trace-page-timeline__graph.is-dragging {
-  cursor: ew-resize;
-}
-
-.trace-page-timeline__graph--inactive {
-  fill: #d6d6d5;
-}
-
-.timeline-scrubber {
-  cursor: ew-resize;
-}
-
-.timeline-scrubber__line {
-  stroke: #999;
-  stroke-width: 1;
-}
-
-.timeline-scrubber:hover .timeline-scrubber__line {
-  stroke: #777;
-}
-
-.timeline-scrubber__handle {
-  stroke: #999;
-  fill: #fff;
-}
-
-.timeline-scrubber:hover .timeline-scrubber__handle {
-  stroke: #777;
-}
-
-.timeline-scrubber__handle--grip {
-  r: 2;
-  fill: #bbb;
-}
-.timeline-scrubber:hover .timeline-scrubber__handle--grip {
-  fill: #999;
-}
diff --git a/src/components/TracePage/index.js b/src/components/TracePage/index.js
index 59d76e585c..035727f37a 100644
--- a/src/components/TracePage/index.js
+++ b/src/components/TracePage/index.js
@@ -26,12 +26,11 @@ import { connect } from 'react-redux';
 import { bindActionCreators } from 'redux';
 
 import TracePageHeader from './TracePageHeader';
-import TraceSpanGraph from './TraceSpanGraph';
+import SpanGraph from './SpanGraph';
 import TraceTimelineViewer from './TraceTimelineViewer';
 import NotFound from '../App/NotFound';
 import * as jaegerApiActions from '../../actions/jaeger-api';
 import { getTraceName } from '../../model/trace-viewer';
-import { getTraceTimestamp, getTraceEndTimestamp, getTraceId } from '../../selectors/trace';
 import colorGenerator from '../../utils/color-generator';
 
 import './index.css';
@@ -49,7 +48,6 @@ export default class TracePage extends Component {
   static get childContextTypes() {
     return {
       textFilter: PropTypes.string,
-      timeRangeFilter: PropTypes.arrayOf(PropTypes.number),
       updateTextFilter: PropTypes.func,
       updateTimeRangeFilter: PropTypes.func,
       slimView: PropTypes.bool,
@@ -72,6 +70,7 @@ export default class TracePage extends Component {
   getChildContext() {
     const state = { ...this.state };
     delete state.headerHeight;
+    delete state.timeRangeFilter;
     return {
       updateTextFilter: this.updateTextFilter.bind(this),
       updateTimeRangeFilter: this.updateTimeRangeFilter.bind(this),
@@ -92,7 +91,7 @@ export default class TracePage extends Component {
       this.ensureTraceFetched();
       return;
     }
-    if (!(trace instanceof Error) && (!prevTrace || getTraceId(prevTrace) !== getTraceId(trace))) {
+    if (!(trace instanceof Error) && (!prevTrace || prevTrace.traceID !== trace.traceID)) {
       this.setDefaultTimeRange();
     }
   }
@@ -114,7 +113,7 @@ export default class TracePage extends Component {
       this.updateTimeRangeFilter(null, null);
       return;
     }
-    this.updateTimeRangeFilter(getTraceTimestamp(trace), getTraceEndTimestamp(trace));
+    this.updateTimeRangeFilter(0, 1);
   }
 
   updateTextFilter(textFilter) {
@@ -166,7 +165,7 @@ export default class TracePage extends Component {
             traceID={traceID}
             onSlimViewClicked={this.toggleSlimView}
           />
-          {!slimView && <TraceSpanGraph trace={trace} />}
+          {!slimView && <SpanGraph trace={trace} viewRange={this.state.timeRangeFilter} />}
         </section>
         {headerHeight &&
           <section className="trace-timeline-section" style={{ paddingTop: headerHeight }}>
diff --git a/src/components/TracePage/index.test.js b/src/components/TracePage/index.test.js
index 737a4d93c4..1e9fe7a101 100644
--- a/src/components/TracePage/index.test.js
+++ b/src/components/TracePage/index.test.js
@@ -25,7 +25,7 @@ import { shallow, mount } from 'enzyme';
 import traceGenerator from '../../demo/trace-generators';
 import TracePage from './';
 import TracePageHeader from './TracePageHeader';
-import TraceSpanGraph from './TraceSpanGraph';
+import SpanGraph from './SpanGraph';
 import transformTraceData from '../../model/transform-trace-data';
 
 describe('<TracePage>', () => {
@@ -46,9 +46,8 @@ describe('<TracePage>', () => {
     expect(wrapper.find(TracePageHeader).get(0)).toBeTruthy();
   });
 
-  it('renders a <TraceSpanGraph>', () => {
-    const props = { trace: defaultProps.trace };
-    expect(wrapper.contains(<TraceSpanGraph {...props} />)).toBeTruthy();
+  it('renders a <SpanGraph>', () => {
+    expect(wrapper.find(SpanGraph).length).toBe(1);
   });
 
   it('renders an empty page when not provided a trace', () => {
diff --git a/src/utils/date.js b/src/utils/date.js
index b38be0b5c1..55b8385f64 100644
--- a/src/utils/date.js
+++ b/src/utils/date.js
@@ -40,17 +40,6 @@ export function getPercentageOfDuration(duration, totalDuration) {
   return duration / totalDuration * 100;
 }
 
-/**
- * @param {number} timestamp
- * @param {number} initialTimestamp
- * @param {number} totalDuration
- * @return {number} 0-100 percentage value for location of timestamp in interval starting
- *   at initialTimestamp and lasting totalDuration
- */
-export function getPercentageOfInterval(timestamp, initialTimestamp, totalDuration) {
-  return getPercentageOfDuration(timestamp - initialTimestamp, totalDuration);
-}
-
 const quantizeDuration = (duration, floatPrecision, conversionFactor) =>
   toFloatPrecision(duration / conversionFactor, floatPrecision) * conversionFactor;