Bug 1077444 - Create Flamechart Widget, r=jsantell
authorVictor Porof <vporof@mozilla.com>
Sun, 11 Jan 2015 09:54:26 -0500
changeset 240002 c92d931cbd4a50299a6be08b3f8e25a5516f9fbd
parent 240001 15c6f78cd4ff9ea342d274490875526b2c9451a5
child 240003 f919a460dc30cacdf64465560f7d4f3ed85a3b01
push id7472
push userraliiev@mozilla.com
push dateMon, 12 Jan 2015 20:36:27 +0000
treeherdermozilla-aurora@300ca104f8fb [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjsantell
bugs1077444
milestone37.0a1
Bug 1077444 - Create Flamechart Widget, r=jsantell
browser/devtools/shared/moz.build
browser/devtools/shared/test/browser.ini
browser/devtools/shared/test/browser_flame-graph-01.js
browser/devtools/shared/test/browser_flame-graph-02.js
browser/devtools/shared/test/browser_flame-graph-03.js
browser/devtools/shared/test/browser_flame-graph-04.js
browser/devtools/shared/widgets/FlameGraph.jsm
browser/devtools/shared/widgets/Graphs.jsm
--- a/browser/devtools/shared/moz.build
+++ b/browser/devtools/shared/moz.build
@@ -17,16 +17,17 @@ EXTRA_JS_MODULES.devtools += [
     'Parser.jsm',
     'SplitView.jsm',
 ]
 
 EXTRA_JS_MODULES.devtools += [
     'widgets/AbstractTreeItem.jsm',
     'widgets/BreadcrumbsWidget.jsm',
     'widgets/Chart.jsm',
+    'widgets/FlameGraph.jsm',
     'widgets/Graphs.jsm',
     'widgets/GraphsWorker.js',
     'widgets/SideMenuWidget.jsm',
     'widgets/SimpleListWidget.jsm',
     'widgets/VariablesView.jsm',
     'widgets/VariablesViewController.jsm',
     'widgets/ViewHelpers.jsm',
 ]
--- a/browser/devtools/shared/test/browser.ini
+++ b/browser/devtools/shared/test/browser.ini
@@ -10,16 +10,20 @@ support-files =
   browser_toolbar_webconsole_errors_count.html
   head.js
   leakhunt.js
 
 [browser_css_color.js]
 [browser_cubic-bezier-01.js]
 [browser_cubic-bezier-02.js]
 [browser_cubic-bezier-03.js]
+[browser_flame-graph-01.js]
+[browser_flame-graph-02.js]
+[browser_flame-graph-03.js]
+[browser_flame-graph-04.js]
 [browser_graphs-01.js]
 [browser_graphs-02.js]
 [browser_graphs-03.js]
 [browser_graphs-04.js]
 [browser_graphs-05.js]
 [browser_graphs-06.js]
 [browser_graphs-07a.js]
 [browser_graphs-07b.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_flame-graph-01.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that flame graph widget works properly.
+
+let {FlameGraph} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {DOMHelpers} = Cu.import("resource:///modules/devtools/DOMHelpers.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+let {Hosts} = devtools.require("devtools/framework/toolbox-hosts");
+
+let test = Task.async(function*() {
+  yield promiseTab("about:blank");
+  yield performTest();
+  gBrowser.removeCurrentTab();
+  finish();
+});
+
+function* performTest() {
+  let [host, win, doc] = yield createHost();
+  doc.body.setAttribute("style", "position: fixed; width: 100%; height: 100%; margin: 0;");
+
+  let graph = new FlameGraph(doc.body);
+
+  let readyEventEmitted;
+  graph.once("ready", () => readyEventEmitted = true);
+
+  yield graph.ready();
+  ok(readyEventEmitted, "The 'ready' event should have been emitted");
+
+  testGraph(host, graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(host, graph) {
+  ok(graph._container.classList.contains("flame-graph-widget-container"),
+    "The correct graph container was created.");
+  ok(graph._canvas.classList.contains("flame-graph-widget-canvas"),
+    "The correct graph container was created.");
+
+  let bounds = host.frame.getBoundingClientRect();
+
+  is(graph.width, bounds.width * window.devicePixelRatio,
+    "The graph has the correct width.");
+  is(graph.height, bounds.height * window.devicePixelRatio,
+    "The graph has the correct height.");
+
+  ok(graph._selection.start === null,
+    "The graph's selection start value is initially null.");
+  ok(graph._selection.end === null,
+    "The graph's selection end value is initially null.");
+
+  ok(graph._selectionDragger.origin === null,
+    "The graph's dragger origin value is initially null.");
+  ok(graph._selectionDragger.anchor.start === null,
+    "The graph's dragger anchor start value is initially null.");
+  ok(graph._selectionDragger.anchor.end === null,
+    "The graph's dragger anchor end value is initially null.");
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_flame-graph-02.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that flame graph widgets may have a fixed width or height.
+
+let {FlameGraph} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {DOMHelpers} = Cu.import("resource:///modules/devtools/DOMHelpers.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+let {Hosts} = devtools.require("devtools/framework/toolbox-hosts");
+
+let test = Task.async(function*() {
+  yield promiseTab("about:blank");
+  yield performTest();
+  gBrowser.removeCurrentTab();
+  finish();
+});
+
+function* performTest() {
+  let [host, win, doc] = yield createHost();
+  doc.body.setAttribute("style", "position: fixed; width: 100%; height: 100%; margin: 0;");
+
+  let graph = new FlameGraph(doc.body);
+  graph.fixedWidth = 200;
+  graph.fixedHeight = 100;
+
+  yield graph.ready();
+  testGraph(host, graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(host, graph) {
+  let bounds = host.frame.getBoundingClientRect();
+
+  isnot(graph.width, bounds.width * window.devicePixelRatio,
+    "The graph should not span all the parent node's width.");
+  isnot(graph.height, bounds.height * window.devicePixelRatio,
+    "The graph should not span all the parent node's height.");
+
+  is(graph.width, graph.fixedWidth * window.devicePixelRatio,
+    "The graph has the correct width.");
+  is(graph.height, graph.fixedHeight * window.devicePixelRatio,
+    "The graph has the correct height.");
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_flame-graph-03.js
@@ -0,0 +1,122 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that selections in the flame graph widget work properly.
+
+let TEST_DATA = [{ color: "#f00", blocks: [{ x: 0, y: 0, width: 50, height: 20, text: "FOO" }, { x: 50, y: 0, width: 100, height: 20, text: "BAR" }] }, { color: "#00f", blocks: [{ x: 0, y: 30, width: 30, height: 20, text: "BAZ" }] }];
+let TEST_WIDTH = 200;
+let TEST_HEIGHT = 100;
+
+let {FlameGraph} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {DOMHelpers} = Cu.import("resource:///modules/devtools/DOMHelpers.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+let {Hosts} = devtools.require("devtools/framework/toolbox-hosts");
+
+let test = Task.async(function*() {
+  yield promiseTab("about:blank");
+  yield performTest();
+  gBrowser.removeCurrentTab();
+  finish();
+});
+
+function* performTest() {
+  let [host, win, doc] = yield createHost();
+  doc.body.setAttribute("style", "position: fixed; width: 100%; height: 100%; margin: 0;");
+
+  let graph = new FlameGraph(doc.body, 1);
+  graph.fixedWidth = TEST_WIDTH;
+  graph.fixedHeight = TEST_HEIGHT;
+
+  yield graph.ready();
+
+  testGraph(graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(graph) {
+  graph.setData(TEST_DATA);
+
+  is(graph.getDataWindowStart(), 0,
+    "The selection start boundary is correct (1).");
+  is(graph.getDataWindowEnd(), TEST_WIDTH,
+    "The selection end boundary is correct (1).");
+
+  scroll(graph, 200, HORIZONTAL_AXIS, 10);
+  is(graph.getDataWindowStart() | 0, 100,
+    "The selection start boundary is correct (2).");
+  is(graph.getDataWindowEnd() | 0, 300,
+    "The selection end boundary is correct (2).");
+
+  scroll(graph, -200, HORIZONTAL_AXIS, 10);
+  is(graph.getDataWindowStart() | 0, 0,
+    "The selection start boundary is correct (3).");
+  is(graph.getDataWindowEnd() | 0, 200,
+    "The selection end boundary is correct (3).");
+
+  scroll(graph, 200, VERTICAL_AXIS, TEST_WIDTH / 2);
+  is(graph.getDataWindowStart() | 0, 0,
+    "The selection start boundary is correct (4).");
+  is(graph.getDataWindowEnd() | 0, 207,
+    "The selection end boundary is correct (4).");
+
+  scroll(graph, -200, VERTICAL_AXIS, TEST_WIDTH / 2);
+  is(graph.getDataWindowStart() | 0, 7,
+    "The selection start boundary is correct (5).");
+  is(graph.getDataWindowEnd() | 0, 199,
+    "The selection end boundary is correct (5).");
+
+  dragStart(graph, TEST_WIDTH / 2);
+  is(graph.getDataWindowStart() | 0, 7,
+    "The selection start boundary is correct (6).");
+  is(graph.getDataWindowEnd() | 0, 199,
+    "The selection end boundary is correct (6).");
+
+  hover(graph, TEST_WIDTH / 2 - 10);
+  is(graph.getDataWindowStart() | 0, 16,
+    "The selection start boundary is correct (7).");
+  is(graph.getDataWindowEnd() | 0, 209,
+    "The selection end boundary is correct (7).");
+
+  dragStop(graph, 10);
+  is(graph.getDataWindowStart() | 0, 93,
+    "The selection start boundary is correct (8).");
+  is(graph.getDataWindowEnd() | 0, 286,
+    "The selection end boundary is correct (8).");
+}
+
+// EventUtils just doesn't work!
+
+function hover(graph, x, y = 1) {
+  x /= window.devicePixelRatio;
+  y /= window.devicePixelRatio;
+  graph._onMouseMove({ clientX: x, clientY: y });
+}
+
+function dragStart(graph, x, y = 1) {
+  x /= window.devicePixelRatio;
+  y /= window.devicePixelRatio;
+  graph._onMouseMove({ clientX: x, clientY: y });
+  graph._onMouseDown({ clientX: x, clientY: y });
+}
+
+function dragStop(graph, x, y = 1) {
+  x /= window.devicePixelRatio;
+  y /= window.devicePixelRatio;
+  graph._onMouseMove({ clientX: x, clientY: y });
+  graph._onMouseUp({ clientX: x, clientY: y });
+}
+
+let HORIZONTAL_AXIS = 1;
+let VERTICAL_AXIS = 2;
+
+function scroll(graph, wheel, axis, x, y = 1) {
+  x /= window.devicePixelRatio;
+  y /= window.devicePixelRatio;
+  graph._onMouseMove({ clientX: x, clientY: y });
+  graph._onMouseWheel({ clientX: x, clientY: y, axis, detail: wheel, axis,
+    HORIZONTAL_AXIS,
+    VERTICAL_AXIS
+  });
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_flame-graph-04.js
@@ -0,0 +1,88 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that text metrics in the flame graph widget work properly.
+
+let HTML_NS = "http://www.w3.org/1999/xhtml";
+let FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE = 9; // px
+let FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY = "sans-serif";
+let {ViewHelpers} = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {});
+let {FlameGraph} = Cu.import("resource:///modules/devtools/FlameGraph.jsm", {});
+let {DOMHelpers} = Cu.import("resource:///modules/devtools/DOMHelpers.jsm", {});
+let {Promise} = devtools.require("resource://gre/modules/Promise.jsm");
+let {Hosts} = devtools.require("devtools/framework/toolbox-hosts");
+
+let L10N = new ViewHelpers.L10N();
+
+let test = Task.async(function*() {
+  yield promiseTab("about:blank");
+  yield performTest();
+  gBrowser.removeCurrentTab();
+  finish();
+});
+
+function* performTest() {
+  let [host, win, doc] = yield createHost();
+  let graph = new FlameGraph(doc.body, 1);
+  yield graph.ready();
+
+  testGraph(graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(graph) {
+  is(graph._averageCharWidth, getAverageCharWidth(),
+    "The average char width was calculated correctly.");
+  is(graph._overflowCharWidth, getCharWidth(L10N.ellipsis),
+    "The ellipsis char width was calculated correctly.");
+
+  is(graph._getTextWidthApprox("This text is maybe overflowing"),
+    getAverageCharWidth() * 30,
+    "The approximate width was calculated correctly.");
+
+  is(graph._getFittedText("This text is maybe overflowing", 1000),
+    "This text is maybe overflowing",
+    "The fitted text for 1000px width is correct.");
+
+  isnot(graph._getFittedText("This text is maybe overflowing", 100),
+    "This text is maybe overflowing",
+    "The fitted text for 100px width is correct (1).");
+
+  ok(graph._getFittedText("This text is maybe overflowing", 100)
+    .contains(L10N.ellipsis),
+    "The fitted text for 100px width is correct (2).");
+
+  is(graph._getFittedText("This text is maybe overflowing", 10),
+    L10N.ellipsis,
+    "The fitted text for 10px width is correct.");
+
+  is(graph._getFittedText("This text is maybe overflowing", 1),
+    "",
+    "The fitted text for 1px width is correct.");
+}
+
+function getAverageCharWidth() {
+  let letterWidthsSum = 0;
+  let start = 32; // space
+  let end = 123; // "z"
+
+  for (let i = start; i < end; i++) {
+    let char = String.fromCharCode(i);
+    letterWidthsSum += getCharWidth(char);
+  }
+
+  return letterWidthsSum / (end - start);
+}
+
+function getCharWidth(char) {
+  let canvas = document.createElementNS(HTML_NS, "canvas");
+  let ctx = canvas.getContext("2d");
+
+  let fontSize = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE;
+  let fontFamily = FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY;
+  ctx.font = fontSize + "px " + fontFamily;
+
+  return ctx.measureText(char).width;
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/widgets/FlameGraph.jsm
@@ -0,0 +1,755 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const Cu = Components.utils;
+
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+Cu.import("resource:///modules/devtools/Graphs.jsm");
+const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
+const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
+
+this.EXPORTED_SYMBOLS = [
+  "FlameGraph",
+  "FlameGraphUtils"
+];
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const GRAPH_SRC = "chrome://browser/content/devtools/graphs-frame.xhtml";
+const L10N = new ViewHelpers.L10N();
+
+const GRAPH_WHEEL_ZOOM_SENSITIVITY = 0.00035;
+const GRAPH_WHEEL_SCROLL_SENSITIVITY = 0.5;
+const GRAPH_MIN_SELECTION_WIDTH = 10; // ms
+
+const TIMELINE_TICKS_MULTIPLE = 5; // ms
+const TIMELINE_TICKS_SPACING_MIN = 75; // px
+
+const OVERVIEW_HEADER_HEIGHT = 18; // px
+const OVERVIEW_HEADER_SAFE_BOUNDS = 50; // px
+const OVERVIEW_HEADER_TEXT_COLOR = "#18191a";
+const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9; // px
+const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif";
+const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6; // px
+const OVERVIEW_HEADER_TEXT_PADDING_TOP = 5; // px
+const OVERVIEW_TIMELINE_STROKES = "#ddd";
+
+const FLAME_GRAPH_BLOCK_BORDER = 1; // px
+const FLAME_GRAPH_BLOCK_TEXT_COLOR = "#000";
+const FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE = 9; // px
+const FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY = "sans-serif";
+const FLAME_GRAPH_BLOCK_TEXT_PADDING_TOP = 1; // px
+const FLAME_GRAPH_BLOCK_TEXT_PADDING_LEFT = 3; // px
+const FLAME_GRAPH_BLOCK_TEXT_PADDING_RIGHT = 3; // px
+
+/**
+ * A flamegraph visualization. This implementation is responsable only with
+ * drawing the graph, using a data source consisting of rectangles and
+ * their corresponding widths.
+ *
+ * Example usage:
+ *   let graph = new FlameGraph(node);
+ *   let src = FlameGraphUtils.createFlameGraphDataFromSamples(samples);
+ *   graph.once("ready", () => {
+ *     graph.setData(src);
+ *   });
+ *
+ * Data source format:
+ *   [
+ *     {
+ *       color: "string",
+ *       blocks: [
+ *         {
+ *           x: number,
+ *           y: number,
+ *           width: number,
+ *           height: number,
+ *           text: "string"
+ *         },
+ *         ...
+ *       ]
+ *     },
+ *     {
+ *       color: "string",
+ *       blocks: [...]
+ *     },
+ *     ...
+ *     {
+ *       color: "string",
+ *       blocks: [...]
+ *     }
+ *   ]
+ *
+ * Use `FlameGraphUtils` to convert profiler data (or any other data source)
+ * into a drawable format.
+ *
+ * @param nsIDOMNode parent
+ *        The parent node holding the graph.
+ * @param number sharpness [optional]
+ *        Defaults to the current device pixel ratio.
+ */
+function FlameGraph(parent, sharpness) {
+  EventEmitter.decorate(this);
+
+  this._parent = parent;
+  this._ready = promise.defer();
+
+  AbstractCanvasGraph.createIframe(GRAPH_SRC, parent, iframe => {
+    this._iframe = iframe;
+    this._window = iframe.contentWindow;
+    this._document = iframe.contentDocument;
+    this._pixelRatio = sharpness || this._window.devicePixelRatio;
+
+    let container = this._container = this._document.getElementById("graph-container");
+    container.className = "flame-graph-widget-container graph-widget-container";
+
+    let canvas = this._canvas = this._document.getElementById("graph-canvas");
+    canvas.className = "flame-graph-widget-canvas graph-widget-canvas";
+
+    let bounds = parent.getBoundingClientRect();
+    bounds.width = this.fixedWidth || bounds.width;
+    bounds.height = this.fixedHeight || bounds.height;
+    iframe.setAttribute("width", bounds.width);
+    iframe.setAttribute("height", bounds.height);
+
+    this._width = canvas.width = bounds.width * this._pixelRatio;
+    this._height = canvas.height = bounds.height * this._pixelRatio;
+    this._ctx = canvas.getContext("2d");
+
+    this._selection = new GraphSelection();
+    this._selectionDragger = new GraphSelectionDragger();
+
+    // Calculating text widths is necessary to trim the text inside the blocks
+    // while the scaling changes (e.g. via scrolling). This is very expensive,
+    // so maintain a cache of string contents to text widths.
+    this._textWidthsCache = {};
+
+    let fontSize = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE * this._pixelRatio;
+    let fontFamily = FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY;
+    this._ctx.font = fontSize + "px " + fontFamily;
+    this._averageCharWidth = this._calcAverageCharWidth();
+    this._overflowCharWidth = this._getTextWidth(this.overflowChar);
+
+    this._onMouseMove = this._onMouseMove.bind(this);
+    this._onMouseDown = this._onMouseDown.bind(this);
+    this._onMouseUp = this._onMouseUp.bind(this);
+    this._onMouseWheel = this._onMouseWheel.bind(this);
+    this._onAnimationFrame = this._onAnimationFrame.bind(this);
+
+    container.addEventListener("mousemove", this._onMouseMove);
+    container.addEventListener("mousedown", this._onMouseDown);
+    container.addEventListener("mouseup", this._onMouseUp);
+    container.addEventListener("MozMousePixelScroll", this._onMouseWheel);
+
+    this._animationId = this._window.requestAnimationFrame(this._onAnimationFrame);
+
+    this._ready.resolve(this);
+    this.emit("ready", this);
+  });
+}
+
+FlameGraph.prototype = {
+  /**
+   * Read-only width and height of the canvas.
+   * @return number
+   */
+  get width() {
+    return this._width;
+  },
+  get height() {
+    return this._height;
+  },
+
+  /**
+   * Returns a promise resolved once this graph is ready to receive data.
+   */
+  ready: function() {
+    return this._ready.promise;
+  },
+
+  /**
+   * Destroys this graph.
+   */
+  destroy: function() {
+    let container = this._container;
+    container.removeEventListener("mousemove", this._onMouseMove);
+    container.removeEventListener("mousedown", this._onMouseDown);
+    container.removeEventListener("mouseup", this._onMouseUp);
+    container.removeEventListener("MozMousePixelScroll", this._onMouseWheel);
+
+    this._window.cancelAnimationFrame(this._animationId);
+    this._iframe.remove();
+
+    this._selection = null;
+    this._selectionDragger = null;
+
+    this._data = null;
+
+    this.emit("destroyed");
+  },
+
+  /**
+   * Rendering options. Subclasses should override these.
+   */
+  overviewHeaderTextColor: OVERVIEW_HEADER_TEXT_COLOR,
+  overviewTimelineStrokes: OVERVIEW_TIMELINE_STROKES,
+  blockTextColor: FLAME_GRAPH_BLOCK_TEXT_COLOR,
+
+  /**
+   * Makes sure the canvas graph is of the specified width or height, and
+   * doesn't flex to fit all the available space.
+   */
+  fixedWidth: null,
+  fixedHeight: null,
+
+  /**
+   * The units used in the overhead ticks. Could be "ms", for example.
+   * Overwrite this with your own localized format.
+   */
+  timelineTickUnits: "",
+
+  /**
+   * Character used when a block's text is overflowing.
+   * Defaults to an ellipsis.
+   */
+  overflowChar: L10N.ellipsis,
+
+  /**
+   * Sets the data source for this graph.
+   *
+   * @param object data
+   *        The data source. See the constructor for more information.
+   */
+  setData: function(data) {
+    this._data = data;
+    this._selection = { start: 0, end: this._width };
+    this._shouldRedraw = true;
+  },
+
+  /**
+   * Same as `setData`, but waits for this graph to finish initializing first.
+   *
+   * @param object data
+   *        The data source. See the constructor for more information.
+   * @return promise
+   *         A promise resolved once the data is set.
+   */
+  setDataWhenReady: Task.async(function*(data) {
+    yield this.ready();
+    this.setData(data);
+  }),
+
+  /**
+   * Gets the start or end of this graph's selection, i.e. the 'data window'.
+   * @return number
+   */
+  getDataWindowStart: function() {
+    return this._selection.start;
+  },
+  getDataWindowEnd: function() {
+    return this._selection.end;
+  },
+
+  /**
+   * The contents of this graph are redrawn only when something changed,
+   * like the data source, or the selection bounds etc. This flag tracks
+   * if the rendering is "dirty" and needs to be refreshed.
+   */
+  _shouldRedraw: false,
+
+  /**
+   * Animation frame callback, invoked on each tick of the refresh driver.
+   */
+  _onAnimationFrame: function() {
+    this._animationId = this._window.requestAnimationFrame(this._onAnimationFrame);
+    this._drawWidget();
+  },
+
+  /**
+   * Redraws the widget when necessary. The actual graph is not refreshed
+   * every time this function is called, only the cliphead, selection etc.
+   */
+  _drawWidget: function() {
+    if (!this._shouldRedraw) {
+      return;
+    }
+    let ctx = this._ctx;
+    let canvasWidth = this._width;
+    let canvasHeight = this._height;
+    ctx.clearRect(0, 0, canvasWidth, canvasHeight);
+
+    let selection = this._selection;
+    let selectionWidth = selection.end - selection.start;
+    let selectionScale = canvasWidth / selectionWidth;
+    this._drawTicks(selection.start, selectionScale);
+    this._drawPyramid(this._data, selection.start, selectionScale);
+
+    this._shouldRedraw = false;
+  },
+
+  /**
+   * Draws the overhead ticks in this graph.
+   *
+   * @param number dataOffset, dataScale
+   *        Offsets and scales the data source by the specified amount.
+   *        This is used for scrolling the visualization.
+   */
+  _drawTicks: function(dataOffset, dataScale) {
+    let ctx = this._ctx;
+    let canvasWidth = this._width;
+    let canvasHeight = this._height;
+    let scaledOffset = dataOffset * dataScale;
+
+    let safeBounds = OVERVIEW_HEADER_SAFE_BOUNDS * this._pixelRatio;
+    let availableWidth = canvasWidth - safeBounds;
+
+    let fontSize = OVERVIEW_HEADER_TEXT_FONT_SIZE * this._pixelRatio;
+    let fontFamily = OVERVIEW_HEADER_TEXT_FONT_FAMILY;
+    let textPaddingLeft = OVERVIEW_HEADER_TEXT_PADDING_LEFT * this._pixelRatio;
+    let textPaddingTop = OVERVIEW_HEADER_TEXT_PADDING_TOP * this._pixelRatio;
+    let tickInterval = this._findOptimalTickInterval(dataScale);
+
+    ctx.textBaseline = "top";
+    ctx.font = fontSize + "px " + fontFamily;
+    ctx.fillStyle = this.overviewHeaderTextColor;
+    ctx.strokeStyle = this.overviewTimelineStrokes;
+    ctx.beginPath();
+
+    for (let x = 0; x < availableWidth + scaledOffset; x += tickInterval) {
+      let lineLeft = x - scaledOffset;
+      let textLeft = lineLeft + textPaddingLeft;
+      let time = Math.round(x / dataScale / this._pixelRatio);
+      let label = time + " " + this.timelineTickUnits;
+      ctx.fillText(label, textLeft, textPaddingTop);
+      ctx.moveTo(lineLeft, 0);
+      ctx.lineTo(lineLeft, canvasHeight);
+    }
+
+    ctx.stroke();
+  },
+
+  /**
+   * Draws the blocks and text in this graph.
+   *
+   * @param object dataSource
+   *        The data source. See the constructor for more information.
+   * @param number dataOffset, dataScale
+   *        Offsets and scales the data source by the specified amount.
+   *        This is used for scrolling the visualization.
+   */
+  _drawPyramid: function(dataSource, dataOffset, dataScale) {
+    let ctx = this._ctx;
+
+    let fontSize = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE * this._pixelRatio;
+    let fontFamily = FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY;
+    let visibleBlocks = this._drawPyramidFill(dataSource, dataOffset, dataScale);
+
+    ctx.textBaseline = "middle";
+    ctx.font = fontSize + "px " + fontFamily;
+    ctx.fillStyle = this.blockTextColor;
+
+    this._drawPyramidText(visibleBlocks, dataOffset, dataScale);
+  },
+
+  /**
+   * Fills all block inside this graph's pyramid.
+   * @see FlameGraph.prototype._drawPyramid
+   */
+  _drawPyramidFill: function(dataSource, dataOffset, dataScale) {
+    let visibleBlocksStore = [];
+    let minVisibleBlockWidth = this._overflowCharWidth;
+
+    for (let { color, blocks } of dataSource) {
+      this._drawBlocksFill(
+        color, blocks, dataOffset, dataScale,
+        visibleBlocksStore, minVisibleBlockWidth);
+    }
+
+    return visibleBlocksStore;
+  },
+
+  /**
+   * Adds the text for all block inside this graph's pyramid.
+   * @see FlameGraph.prototype._drawPyramid
+   */
+  _drawPyramidText: function(blocks, dataOffset, dataScale) {
+    for (let block of blocks) {
+      this._drawBlockText(block, dataOffset, dataScale);
+    }
+  },
+
+  /**
+   * Fills a group of blocks sharing the same style.
+   *
+   * @param string color
+   *        The color used as the block's background.
+   * @param array blocks
+   *        A list of { x, y, width, height } objects visually representing
+   *        all the blocks sharing this particular style.
+   * @param number dataOffset, dataScale
+   *        Offsets and scales the data source by the specified amount.
+   *        This is used for scrolling the visualization.
+   * @param array visibleBlocksStore
+   *        An array to store all the visible blocks into, after drawing them.
+   *        The provided array will be populated.
+   * @param number minVisibleBlockWidth
+   *        The minimum width of the blocks that will be added into
+   *        the `visibleBlocksStore`.
+   */
+  _drawBlocksFill: function(
+    color, blocks, dataOffset, dataScale,
+    visibleBlocksStore, minVisibleBlockWidth)
+  {
+    let ctx = this._ctx;
+    let canvasWidth = this._width;
+    let canvasHeight = this._height;
+    let scaledOffset = dataOffset * dataScale;
+
+    ctx.fillStyle = color;
+    ctx.beginPath();
+
+    for (let block of blocks) {
+      let { x, y, width, height } = block;
+      let rectLeft = x * this._pixelRatio * dataScale - scaledOffset;
+      let rectTop = (y + OVERVIEW_HEADER_HEIGHT) * this._pixelRatio;
+      let rectWidth = width * this._pixelRatio * dataScale;
+      let rectHeight = height * this._pixelRatio;
+
+      if (rectLeft > canvasWidth || // Too far right.
+          rectLeft < -rectWidth ||  // Too far left.
+          rectTop > canvasHeight) { // Too far bottom.
+        continue;
+      }
+
+      // Clamp the blocks position to start at 0. Avoid negative X coords,
+      // to properly place the text inside the blocks.
+      if (rectLeft < 0) {
+        rectWidth += rectLeft;
+        rectLeft = 0;
+      }
+
+      // Avoid drawing blocks that are too narrow.
+      if (rectWidth <= FLAME_GRAPH_BLOCK_BORDER ||
+          rectHeight <= FLAME_GRAPH_BLOCK_BORDER) {
+        continue;
+      }
+
+      ctx.rect(
+        rectLeft, rectTop,
+        rectWidth - FLAME_GRAPH_BLOCK_BORDER,
+        rectHeight - FLAME_GRAPH_BLOCK_BORDER);
+
+      // Populate the visible blocks store with this block if the width
+      // is longer than a given threshold.
+      if (rectWidth > minVisibleBlockWidth) {
+        visibleBlocksStore.push(block);
+      }
+    }
+
+    ctx.fill();
+  },
+
+  /**
+   * Adds text for a single block.
+   *
+   * @param object block
+   *        A single { x, y, width, height, text } object visually representing
+   *        the block containing the text.
+   * @param number dataOffset, dataScale
+   *        Offsets and scales the data source by the specified amount.
+   *        This is used for scrolling the visualization.
+   */
+  _drawBlockText: function(block, dataOffset, dataScale) {
+    let ctx = this._ctx;
+    let scaledOffset = dataOffset * dataScale;
+
+    let { x, y, width, height, text } = block;
+
+    let paddingTop = FLAME_GRAPH_BLOCK_TEXT_PADDING_TOP * this._pixelRatio;
+    let paddingLeft = FLAME_GRAPH_BLOCK_TEXT_PADDING_LEFT * this._pixelRatio;
+    let paddingRight = FLAME_GRAPH_BLOCK_TEXT_PADDING_RIGHT * this._pixelRatio;
+    let totalHorizontalPadding = paddingLeft + paddingRight;
+
+    let rectLeft = x * this._pixelRatio * dataScale - scaledOffset;
+    let rectWidth = width * this._pixelRatio * dataScale;
+
+    // Clamp the blocks position to start at 0. Avoid negative X coords,
+    // to properly place the text inside the blocks.
+    if (rectLeft < 0) {
+      rectWidth += rectLeft;
+      rectLeft = 0;
+    }
+
+    let textLeft = rectLeft + paddingLeft;
+    let textTop = (y + height / 2 + OVERVIEW_HEADER_HEIGHT) * this._pixelRatio + paddingTop;
+    let textAvailableWidth = rectWidth - totalHorizontalPadding;
+
+    // Massage the text to fit inside a given width. This clamps the string
+    // at the end to avoid overflowing.
+    let fittedText = this._getFittedText(text, textAvailableWidth);
+    if (fittedText.length < 1) {
+      return;
+    }
+
+    ctx.fillText(fittedText, textLeft, textTop);
+  },
+
+  /**
+   * Calculating text widths is necessary to trim the text inside the blocks
+   * while the scaling changes (e.g. via scrolling). This is very expensive,
+   * so maintain a cache of string contents to text widths.
+   */
+  _textWidthsCache: null,
+  _overflowCharWidth: null,
+  _averageCharWidth: null,
+
+  /**
+   * Gets the width of the specified text, for the current context state
+   * (font size, family etc.).
+   *
+   * @param string text
+   *        The text to analyze.
+   * @return number
+   *         The text width.
+   */
+  _getTextWidth: function(text) {
+    let cachedWidth = this._textWidthsCache[text];
+    if (cachedWidth) {
+      return cachedWidth;
+    }
+    let metrics = this._ctx.measureText(text);
+    return (this._textWidthsCache[text] = metrics.width);
+  },
+
+  /**
+   * Gets an approximate width of the specified text. This is much faster
+   * than `_getTextWidth`, but inexact.
+   *
+   * @param string text
+   *        The text to analyze.
+   * @return number
+   *         The approximate text width.
+   */
+  _getTextWidthApprox: function(text) {
+    return text.length * this._averageCharWidth;
+  },
+
+  /**
+   * Gets the average letter width in the English alphabet, for the current
+   * context state (font size, family etc.). This provides a close enough
+   * value to use in `_getTextWidthApprox`.
+   *
+   * @return number
+   *         The average letter width.
+   */
+  _calcAverageCharWidth: function() {
+    let letterWidthsSum = 0;
+    let start = 32; // space
+    let end = 123; // "z"
+
+    for (let i = start; i < end; i++) {
+      let char = String.fromCharCode(i);
+      letterWidthsSum += this._getTextWidth(char);
+    }
+
+    return letterWidthsSum / (end - start);
+  },
+
+  /**
+   * Massage a text to fit inside a given width. This clamps the string
+   * at the end to avoid overflowing.
+   *
+   * @param string text
+   *        The text to fit inside the given width.
+   * @param number maxWidth
+   *        The available width for the given text.
+   * @return string
+   *         The fitted text.
+   */
+  _getFittedText: function(text, maxWidth) {
+    let textWidth = this._getTextWidth(text);
+    if (textWidth < maxWidth) {
+      return text;
+    }
+    if (this._overflowCharWidth > maxWidth) {
+      return "";
+    }
+    for (let i = 1, len = text.length; i <= len; i++) {
+      let trimmedText = text.substring(0, len - i);
+      let trimmedWidth = this._getTextWidthApprox(trimmedText) + this._overflowCharWidth;
+      if (trimmedWidth < maxWidth) {
+        return trimmedText + this.overflowChar;
+      }
+    }
+    return "";
+  },
+
+  /**
+   * Listener for the "mousemove" event on the graph's container.
+   */
+  _onMouseMove: function(e) {
+    let offset = this._getContainerOffset();
+    let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+
+    let canvasWidth = this._width;
+    let canvasHeight = this._height;
+
+    let selection = this._selection;
+    let selectionWidth = selection.end - selection.start;
+    let selectionScale = canvasWidth / selectionWidth;
+
+    let dragger = this._selectionDragger;
+    if (dragger.origin != null) {
+      selection.start = dragger.anchor.start + (dragger.origin - mouseX) / selectionScale;
+      selection.end = dragger.anchor.end + (dragger.origin - mouseX) / selectionScale;
+      this._normalizeSelectionBounds();
+      this._shouldRedraw = true;
+    }
+  },
+
+  /**
+   * Listener for the "mousedown" event on the graph's container.
+   */
+  _onMouseDown: function(e) {
+    let offset = this._getContainerOffset();
+    let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+
+    this._selectionDragger.origin = mouseX;
+    this._selectionDragger.anchor.start = this._selection.start;
+    this._selectionDragger.anchor.end = this._selection.end;
+    this._canvas.setAttribute("input", "adjusting-selection-boundary");
+  },
+
+  /**
+   * Listener for the "mouseup" event on the graph's container.
+   */
+  _onMouseUp: function() {
+    this._selectionDragger.origin = null;
+    this._canvas.removeAttribute("input");
+  },
+
+  /**
+   * Listener for the "wheel" event on the graph's container.
+   */
+  _onMouseWheel: function(e) {
+    let offset = this._getContainerOffset();
+    let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+
+    let canvasWidth = this._width;
+    let canvasHeight = this._height;
+
+    let selection = this._selection;
+    let selectionWidth = selection.end - selection.start;
+    let selectionScale = canvasWidth / selectionWidth;
+
+    switch (e.axis) {
+      case e.VERTICAL_AXIS: {
+        let distFromStart = mouseX;
+        let distFromEnd = canvasWidth - mouseX;
+        let vector = e.detail * GRAPH_WHEEL_ZOOM_SENSITIVITY / selectionScale;
+        selection.start -= distFromStart * vector;
+        selection.end += distFromEnd * vector;
+        break;
+      }
+      case e.HORIZONTAL_AXIS: {
+        let vector = e.detail * GRAPH_WHEEL_SCROLL_SENSITIVITY / selectionScale;
+        selection.start += vector;
+        selection.end += vector;
+        break;
+      }
+    }
+
+    this._normalizeSelectionBounds();
+    this._shouldRedraw = true;
+  },
+
+  /**
+   * Makes sure the start and end points of the current selection
+   * are withing the graph's visible bounds, and that they form a selection
+   * wider than the allowed minimum width.
+   */
+  _normalizeSelectionBounds: function() {
+    let canvasWidth = this._width * 2;
+    let canvasHeight = this._height;
+
+    let { start, end } = this._selection;
+    let minSelectionWidth = GRAPH_MIN_SELECTION_WIDTH * this._pixelRatio;
+
+    if (start < 0) {
+      start = 0;
+    }
+    if (end < 0) {
+      start = 0;
+      end = minSelectionWidth;
+    }
+    if (end > canvasWidth) {
+      end = canvasWidth;
+    }
+    if (start > canvasWidth) {
+      end = canvasWidth;
+      start = canvasWidth - minSelectionWidth;
+    }
+    if (end - start < minSelectionWidth) {
+      let midPoint = (start + end) / 2;
+      start = midPoint - minSelectionWidth / 2;
+      end = midPoint + minSelectionWidth / 2;
+    }
+
+    this._selection.start = start;
+    this._selection.end = end;
+  },
+
+  /**
+   *
+   * Finds the optimal tick interval between time markers in this graph.
+   *
+   * @param number dataScale
+   * @return number
+   */
+  _findOptimalTickInterval: function(dataScale) {
+    let timingStep = TIMELINE_TICKS_MULTIPLE;
+    let spacingMin = TIMELINE_TICKS_SPACING_MIN * this._pixelRatio;
+
+    if (dataScale > spacingMin) {
+      return dataScale;
+    }
+
+    while (true) {
+      let scaledStep = dataScale * timingStep;
+      if (scaledStep < spacingMin) {
+        timingStep <<= 1;
+        continue;
+      }
+      return scaledStep;
+    }
+  },
+
+  /**
+   * Gets the offset of this graph's container relative to the owner window.
+   *
+   * @return object
+   *         The { left, top } offset.
+   */
+  _getContainerOffset: function() {
+    let node = this._canvas;
+    let x = 0;
+    let y = 0;
+
+    while ((node = node.offsetParent)) {
+      x += node.offsetLeft;
+      y += node.offsetTop;
+    }
+
+    return { left: x, top: y };
+  }
+};
+
+/**
+ * A collection of utility functions converting various data sources
+ * into a format drawable by the FlameGraph.
+ */
+let FlameGraphUtils = {
+  // TODO bug 1077459
+};
--- a/browser/devtools/shared/widgets/Graphs.jsm
+++ b/browser/devtools/shared/widgets/Graphs.jsm
@@ -2,20 +2,24 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const Cu = Components.utils;
 
 Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
 const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
 const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
-const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
 
 this.EXPORTED_SYMBOLS = [
+  "GraphCursor",
+  "GraphSelection",
+  "GraphSelectionDragger",
+  "GraphSelectionResizer",
   "AbstractCanvasGraph",
   "LineGraphWidget",
   "BarGraphWidget",
   "CanvasGraphUtils"
 ];
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 const GRAPH_SRC = "chrome://browser/content/devtools/graphs-frame.xhtml";
@@ -88,38 +92,33 @@ const BAR_GRAPH_REGION_STRIPES_COLOR = "
 const BAR_GRAPH_HIGHLIGHTS_MASK_BACKGROUND = "rgba(255,255,255,0.75)";
 const BAR_GRAPH_HIGHLIGHTS_MASK_STRIPES = "rgba(255,255,255,0.5)";
 
 const BAR_GRAPH_LEGEND_MOUSEOVER_DEBOUNCE = 50; // ms
 
 /**
  * Small data primitives for all graphs.
  */
-this.GraphCursor = function() {};
-this.GraphSelection = function() {};
-this.GraphSelectionDragger = function() {};
-this.GraphSelectionResizer = function() {};
-
-GraphCursor.prototype = {
-  x: null,
-  y: null
+this.GraphCursor = function() {
+  this.x = null;
+  this.y = null;
 };
 
-GraphSelection.prototype = {
-  start: null,
-  end: null
+this.GraphSelection = function() {
+  this.start = null;
+  this.end = null;
 };
 
-GraphSelectionDragger.prototype = {
-  origin: null,
-  anchor: new GraphSelection()
+this.GraphSelectionDragger = function() {
+  this.origin = null;
+  this.anchor = new GraphSelection();
 };
 
-GraphSelectionResizer.prototype = {
-  margin: null
+this.GraphSelectionResizer = function() {
+  this.margin = null;
 };
 
 /**
  * Base class for all graphs using a canvas to render the data source. Handles
  * frame creation, data source, selection bounds, cursor position, etc.
  *
  * Language:
  *   - The "data" represents the values used when building the graph.
@@ -240,16 +239,21 @@ AbstractCanvasGraph.prototype = {
     container.removeEventListener("mouseout", this._onMouseOut);
 
     let ownerWindow = this._parent.ownerDocument.defaultView;
     ownerWindow.removeEventListener("resize", this._onResize);
 
     this._window.cancelAnimationFrame(this._animationId);
     this._iframe.remove();
 
+    this._cursor = null;
+    this._selection = null;
+    this._selectionDragger = null;
+    this._selectionResizer = null;
+
     this._data = null;
     this._mask = null;
     this._maskArgs = null;
     this._regions = null;
 
     this._cachedBackgroundImage = null;
     this._cachedGraphImage = null;
     this._cachedMaskImage = null;
@@ -887,16 +891,19 @@ AbstractCanvasGraph.prototype = {
    * @return boolean
    */
   _isHoveringRegion: function() {
     return !!this.getHoveredRegion();
   },
 
   /**
    * Gets the offset of this graph's container relative to the owner window.
+   *
+   * @return object
+   *         The { left, top } offset.
    */
   _getContainerOffset: function() {
     let node = this._canvas;
     let x = 0;
     let y = 0;
 
     while (node = node.offsetParent) {
       x += node.offsetLeft;