Bug 1007202 - Create a framerate widget, r=pbrosset,rcampbell
authorVictor Porof <vporof@mozilla.com>
Thu, 29 May 2014 09:54:00 -0400
changeset 185552 f286ff16e223c6c2f8e26f231288714e10da4de6
parent 185551 7e063d8bf10b64b8236b2d6077b3c8d93bdab664
child 185553 61bda59ea4b64a869561a795c9b606f9e4b6724b
push id7042
push uservporof@mozilla.com
push dateThu, 29 May 2014 13:54:09 +0000
treeherderfx-team@f286ff16e223 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbrosset, rcampbell
bugs1007202
milestone32.0a1
Bug 1007202 - Create a framerate widget, r=pbrosset,rcampbell
browser/devtools/jar.mn
browser/devtools/shared/test/browser.ini
browser/devtools/shared/test/browser_graphs-01.js
browser/devtools/shared/test/browser_graphs-02.js
browser/devtools/shared/test/browser_graphs-03.js
browser/devtools/shared/test/browser_graphs-04.js
browser/devtools/shared/test/browser_graphs-05.js
browser/devtools/shared/test/browser_graphs-06.js
browser/devtools/shared/test/browser_graphs-07.js
browser/devtools/shared/test/browser_graphs-08.js
browser/devtools/shared/test/browser_graphs-09.js
browser/devtools/shared/test/browser_graphs-10.js
browser/devtools/shared/test/head.js
browser/devtools/shared/widgets/Graphs.jsm
browser/devtools/shared/widgets/graphs-frame.xhtml
browser/themes/shared/devtools/widgets.inc.css
toolkit/devtools/server/actors/framerate.js
--- a/browser/devtools/jar.mn
+++ b/browser/devtools/jar.mn
@@ -116,14 +116,14 @@ browser.jar:
     content/browser/devtools/app-manager/device.js                     (app-manager/content/device.js)
     content/browser/devtools/app-manager/device.xhtml                  (app-manager/content/device.xhtml)
     content/browser/devtools/app-manager/projects.js                   (app-manager/content/projects.js)
     content/browser/devtools/app-manager/projects.xhtml                (app-manager/content/projects.xhtml)
     content/browser/devtools/app-manager/index.xul                     (app-manager/content/index.xul)
     content/browser/devtools/app-manager/index.js                      (app-manager/content/index.js)
     content/browser/devtools/app-manager/help.xhtml                    (app-manager/content/help.xhtml)
     content/browser/devtools/app-manager/manifest-editor.js            (app-manager/content/manifest-editor.js)
+    content/browser/devtools/graphs-frame.xhtml                        (shared/widgets/graphs-frame.xhtml)
     content/browser/devtools/spectrum-frame.xhtml                      (shared/widgets/spectrum-frame.xhtml)
     content/browser/devtools/spectrum.css                              (shared/widgets/spectrum.css)
     content/browser/devtools/eyedropper.xul                            (eyedropper/eyedropper.xul)
     content/browser/devtools/eyedropper/crosshairs.css                 (eyedropper/crosshairs.css)
     content/browser/devtools/eyedropper/nocursor.css                   (eyedropper/nocursor.css)
-
--- a/browser/devtools/shared/test/browser.ini
+++ b/browser/devtools/shared/test/browser.ini
@@ -6,16 +6,26 @@ support-files =
   browser_layoutHelpers_iframe.html
   browser_templater_basic.html
   browser_toolbar_basic.html
   browser_toolbar_webconsole_errors_count.html
   head.js
   leakhunt.js
 
 [browser_css_color.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-07.js]
+[browser_graphs-08.js]
+[browser_graphs-09.js]
+[browser_graphs-10.js]
 [browser_layoutHelpers.js]
 [browser_observableobject.js]
 [browser_outputparser.js]
 [browser_require_basic.js]
 [browser_telemetry_button_paintflashing.js]
 [browser_telemetry_button_responsive.js]
 [browser_telemetry_button_scratchpad.js]
 [browser_telemetry_button_tilt.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_graphs-01.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets works properly.
+
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.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 LineGraphWidget(doc.body, "fps");
+  yield graph.once("ready");
+
+  testGraph(host, graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(host, graph) {
+  ok(graph._container.classList.contains("line-graph-widget-container"),
+    "The correct graph container was created.");
+  ok(graph._canvas.classList.contains("line-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._cursor.x === null,
+    "The graph's cursor X coordinate is initially null.");
+  ok(graph._cursor.y === null,
+    "The graph's cursor Y coordinate is initially null.");
+
+  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.");
+
+  ok(graph._selectionResizer.margin === null,
+    "The graph's resizer margin value is initially null.");
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_graphs-02.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets can properly add data and regions.
+
+const TEST_DATA = {"112":48,"213":59,"313":60,"413":59,"530":59,"646":58,"747":60,"863":48,"980":37,"1097":30,"1213":29,"1330":23,"1430":10,"1534":17,"1645":20,"1746":22,"1846":39,"1963":26,"2080":27,"2197":35,"2312":47,"2412":53,"2514":60,"2630":37,"2730":36,"2830":37,"2946":36,"3046":40,"3163":47,"3280":41,"3380":35,"3480":27,"3580":39,"3680":42,"3780":49,"3880":55,"3980":60,"4080":60,"4180":60};
+const TEST_REGIONS = [{ start: 320, end: 460 }, { start: 780, end: 860 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.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();
+  let graph = new LineGraphWidget(doc.body, "fps");
+  yield graph.once("ready");
+
+  testGraph(graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(graph) {
+  let thrown1;
+  try {
+    graph.setRegions(TEST_REGIONS);
+  } catch (e) {
+    thrown1 = true;
+  }
+  ok(thrown1, "Setting regions before setting data shouldn't work.");
+
+  graph.setData(TEST_DATA);
+  graph.setRegions(TEST_REGIONS);
+
+  let thrown2;
+  try {
+    graph.setRegions(TEST_REGIONS);
+  } catch (e) {
+    thrown2 = true;
+  }
+  ok(thrown2, "Setting regions twice shouldn't work.");
+
+  ok(graph.hasData(), "The graph should now have the data source set.");
+  ok(graph.hasRegions(), "The graph should now have the regions set.");
+
+  is(graph.dataScaleX,
+     graph.width / 4180, // last key in TEST_DATA
+    "The data scale on the X axis is correct.");
+
+  is(graph.dataScaleY,
+     graph.height / 60 * 0.85, // max value in TEST_DATA * GRAPH_DAMPEN_VALUES
+    "The data scale on the Y axis is correct.");
+
+  for (let i = 0; i < TEST_REGIONS.length; i++) {
+    let original = TEST_REGIONS[i];
+    let normalized = graph._regions[i];
+
+    is(original.start * graph.dataScaleX, normalized.start,
+      "The region's start value was properly normalized.");
+    is(original.end * graph.dataScaleX, normalized.end,
+      "The region's end value was properly normalized.");
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_graphs-03.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets can handle clients getting/setting the
+// selection or cursor.
+
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.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();
+  let graph = new LineGraphWidget(doc.body, "fps");
+  yield graph.once("ready");
+
+  yield testSelection(graph);
+  yield testCursor(graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function* testSelection(graph) {
+  ok(graph.getSelection().start === null,
+    "The graph's selection should initially have a null start value.");
+  ok(graph.getSelection().end === null,
+    "The graph's selection should initially have a null end value.");
+  ok(!graph.hasSelection(),
+    "There shouldn't initially be any selection.");
+
+  let selected = graph.once("selecting");
+  graph.setSelection({ start: 100, end: 200 });
+
+  yield selected;
+  ok(true, "A 'selecting' event has been fired.");
+
+  ok(graph.hasSelection(),
+    "There should now be a selection.");
+  is(graph.getSelection().start, 100,
+    "The graph's selection now has an updated start value.");
+  is(graph.getSelection().end, 200,
+    "The graph's selection now has an updated end value.");
+
+  let thrown;
+  try {
+    graph.setSelection({ start: null, end: null });
+  } catch(e) {
+    thrown = true;
+  }
+  ok(thrown, "Setting a null selection shouldn't work.");
+
+  ok(graph.hasSelection(),
+    "There should still be a selection.");
+
+  let deselected = graph.once("deselecting");
+  graph.dropSelection();
+
+  yield deselected;
+  ok(true, "A 'deselecting' event has been fired.");
+
+  ok(!graph.hasSelection(),
+    "There shouldn't be any selection anymore.");
+  ok(graph.getSelection().start === null,
+    "The graph's selection now has a null start value.");
+  ok(graph.getSelection().end === null,
+    "The graph's selection now has a null end value.");
+}
+
+function* testCursor(graph) {
+  ok(graph.getCursor().x === null,
+    "The graph's cursor should initially have a null X value.");
+  ok(graph.getCursor().y === null,
+    "The graph's cursor should initially have a null Y value.");
+  ok(!graph.hasCursor(),
+    "There shouldn't initially be any cursor.");
+
+  graph.setCursor({ x: 100, y: 50 });
+
+  ok(graph.hasCursor(),
+    "There should now be a cursor.");
+  is(graph.getCursor().x, 100,
+    "The graph's cursor now has an updated start value.");
+  is(graph.getCursor().y, 50,
+    "The graph's cursor now has an updated end value.");
+
+  let thrown;
+  try {
+    graph.setCursor({ x: null, y: null });
+  } catch(e) {
+    thrown = true;
+  }
+  ok(thrown, "Setting a null cursor shouldn't work.");
+
+  ok(graph.hasCursor(),
+    "There should still be a cursor.");
+
+  graph.dropCursor();
+
+  ok(!graph.hasCursor(),
+    "There shouldn't be any cursor anymore.");
+  ok(graph.getCursor().x === null,
+    "The graph's cursor now has a null start value.");
+  ok(graph.getCursor().y === null,
+    "The graph's cursor now has a null end value.");
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_graphs-04.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets can correctly compare selections and cursors.
+
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.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();
+  let graph = new LineGraphWidget(doc.body, "fps");
+  yield graph.once("ready");
+
+  testGraph(graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(graph) {
+  ok(!graph.hasSelection(),
+    "There shouldn't initially be any selection.");
+  is(graph.getSelectionWidth(), 0,
+    "The selection width should be 0 when there's no selection.");
+
+  graph.setSelection({ start: 100, end: 200 });
+
+  ok(graph.hasSelection(),
+    "There should now be a selection.");
+  is(graph.getSelectionWidth(), 100,
+    "The selection width should now be 100.");
+
+  ok(graph.isSelectionDifferent({ start: 100, end: 201 }),
+    "The selection was correctly reported to be different (1).");
+  ok(graph.isSelectionDifferent({ start: 101, end: 200 }),
+    "The selection was correctly reported to be different (2).");
+  ok(graph.isSelectionDifferent({ start: null, end: null }),
+    "The selection was correctly reported to be different (3).");
+  ok(graph.isSelectionDifferent(null),
+    "The selection was correctly reported to be different (4).");
+
+  ok(!graph.isSelectionDifferent({ start: 100, end: 200 }),
+    "The selection was incorrectly reported to be different (1).");
+  ok(!graph.isSelectionDifferent(graph.getSelection()),
+    "The selection was incorrectly reported to be different (2).");
+
+  graph.setCursor({ x: 100, y: 50 });
+
+  ok(graph.isCursorDifferent({ x: 100, y: 51 }),
+    "The cursor was correctly reported to be different (1).");
+  ok(graph.isCursorDifferent({ x: 101, y: 50 }),
+    "The cursor was correctly reported to be different (2).");
+  ok(graph.isCursorDifferent({ x: null, y: null }),
+    "The cursor was correctly reported to be different (3).");
+  ok(graph.isCursorDifferent(null),
+    "The cursor was correctly reported to be different (4).");
+
+  ok(!graph.isCursorDifferent({ x: 100, y: 50 }),
+    "The cursor was incorrectly reported to be different (1).");
+  ok(!graph.isCursorDifferent(graph.getCursor()),
+    "The cursor was incorrectly reported to be different (2).");
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_graphs-05.js
@@ -0,0 +1,135 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that graph widgets can correctly determine which regions are hovered.
+
+const TEST_DATA = {"112":48,"213":59,"313":60,"413":59,"530":59,"646":58,"747":60,"863":48,"980":37,"1097":30,"1213":29,"1330":23,"1430":10,"1534":17,"1645":20,"1746":22,"1846":39,"1963":26,"2080":27,"2197":35,"2312":47,"2412":53,"2514":60,"2630":37,"2730":36,"2830":37,"2946":36,"3046":40,"3163":47,"3280":41,"3380":35,"3480":27,"3580":39,"3680":42,"3780":49,"3880":55,"3980":60,"4080":60,"4180":60};
+const TEST_REGIONS = [{ start: 320, end: 460 }, { start: 780, end: 860 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.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();
+  let graph = new LineGraphWidget(doc.body, "fps");
+  yield graph.once("ready");
+
+  testGraph(graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(graph) {
+  ok(!graph.getHoveredRegion(),
+    "There should be no hovered region yet because there's no regions.");
+
+  ok(!graph._isHoveringStartBoundary(),
+    "The graph start boundary should not be hovered.");
+  ok(!graph._isHoveringEndBoundary(),
+    "The graph end boundary should not be hovered.");
+  ok(!graph._isHoveringSelectionContents(),
+    "The graph contents should not be hovered.");
+  ok(!graph._isHoveringSelectionContentsOrBoundaries(),
+    "The graph contents or boundaries should not be hovered.");
+
+  graph.setData(TEST_DATA);
+  graph.setRegions(TEST_REGIONS);
+
+  ok(!graph.getHoveredRegion(),
+    "There should be no hovered region yet because there's no cursor.");
+
+  graph.setCursor({ x: TEST_REGIONS[0].start * graph.dataScaleX - 1, y: 0 });
+  ok(!graph.getHoveredRegion(),
+    "There shouldn't be any hovered region yet.");
+
+  graph.setCursor({ x: TEST_REGIONS[0].start * graph.dataScaleX + 1, y: 0 });
+  ok(graph.getHoveredRegion(),
+    "There should be a hovered region now.");
+  is(graph.getHoveredRegion().start, 320 * graph.dataScaleX,
+    "The reported hovered region is correct (1).");
+  is(graph.getHoveredRegion().end, 460 * graph.dataScaleX,
+    "The reported hovered region is correct (2).");
+
+  graph.setSelection({ start: 100, end: 200 });
+
+  info("Setting cursor over the left boundary.");
+  graph.setCursor({ x: 100, y: 0 });
+
+  ok(graph._isHoveringStartBoundary(),
+    "The graph start boundary should be hovered.");
+  ok(!graph._isHoveringEndBoundary(),
+    "The graph end boundary should not be hovered.");
+  ok(!graph._isHoveringSelectionContents(),
+    "The graph contents should not be hovered.");
+  ok(graph._isHoveringSelectionContentsOrBoundaries(),
+    "The graph contents or boundaries should be hovered.");
+
+  info("Setting cursor near the left boundary.");
+  graph.setCursor({ x: 105, y: 0 });
+
+  ok(graph._isHoveringStartBoundary(),
+    "The graph start boundary should be hovered.");
+  ok(!graph._isHoveringEndBoundary(),
+    "The graph end boundary should not be hovered.");
+  ok(graph._isHoveringSelectionContents(),
+    "The graph contents should be hovered.");
+  ok(graph._isHoveringSelectionContentsOrBoundaries(),
+    "The graph contents or boundaries should be hovered.");
+
+  info("Setting cursor over the selection.");
+  graph.setCursor({ x: 150, y: 0 });
+
+  ok(!graph._isHoveringStartBoundary(),
+    "The graph start boundary should not be hovered.");
+  ok(!graph._isHoveringEndBoundary(),
+    "The graph end boundary should not be hovered.");
+  ok(graph._isHoveringSelectionContents(),
+    "The graph contents should be hovered.");
+  ok(graph._isHoveringSelectionContentsOrBoundaries(),
+    "The graph contents or boundaries should be hovered.");
+
+  info("Setting cursor near the right boundary.");
+  graph.setCursor({ x: 195, y: 0 });
+
+  ok(!graph._isHoveringStartBoundary(),
+    "The graph start boundary should not be hovered.");
+  ok(graph._isHoveringEndBoundary(),
+    "The graph end boundary should be hovered.");
+  ok(graph._isHoveringSelectionContents(),
+    "The graph contents should be hovered.");
+  ok(graph._isHoveringSelectionContentsOrBoundaries(),
+    "The graph contents or boundaries should be hovered.");
+
+  info("Setting cursor over the right boundary.");
+  graph.setCursor({ x: 200, y: 0 });
+
+  ok(!graph._isHoveringStartBoundary(),
+    "The graph start boundary should not be hovered.");
+  ok(graph._isHoveringEndBoundary(),
+    "The graph end boundary should be hovered.");
+  ok(!graph._isHoveringSelectionContents(),
+    "The graph contents should not be hovered.");
+  ok(graph._isHoveringSelectionContentsOrBoundaries(),
+    "The graph contents or boundaries should be hovered.");
+
+  info("Setting away from the selection.");
+  graph.setCursor({ x: 300, y: 0 });
+
+  ok(!graph._isHoveringStartBoundary(),
+    "The graph start boundary should not be hovered.");
+  ok(!graph._isHoveringEndBoundary(),
+    "The graph end boundary should not be hovered.");
+  ok(!graph._isHoveringSelectionContents(),
+    "The graph contents should not be hovered.");
+  ok(!graph._isHoveringSelectionContentsOrBoundaries(),
+    "The graph contents or boundaries should not be hovered.");
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_graphs-06.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if clicking on regions adds a selection spanning that region.
+
+const TEST_DATA = {"112":48,"213":59,"313":60,"413":59,"530":59,"646":58,"747":60,"863":48,"980":37,"1097":30,"1213":29,"1330":23,"1430":10,"1534":17,"1645":20,"1746":22,"1846":39,"1963":26,"2080":27,"2197":35,"2312":47,"2412":53,"2514":60,"2630":37,"2730":36,"2830":37,"2946":36,"3046":40,"3163":47,"3280":41,"3380":35,"3480":27,"3580":39,"3680":42,"3780":49,"3880":55,"3980":60,"4080":60,"4180":60};
+const TEST_REGIONS = [{ start: 320, end: 460 }, { start: 780, end: 860 }];
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.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();
+  let graph = new LineGraphWidget(doc.body, "fps");
+  yield graph.once("ready");
+
+  testGraph(graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(graph) {
+  graph.setData(TEST_DATA);
+  graph.setRegions(TEST_REGIONS);
+
+  click(graph, (graph._regions[0].start + graph._regions[0].end) / 2);
+  is(graph.getSelection().start, graph._regions[0].start,
+    "The first region is now selected (1).");
+  is(graph.getSelection().end, graph._regions[0].end,
+    "The first region is now selected (2).");
+
+  click(graph, (graph._regions[1].start + graph._regions[1].end) / 2);
+  is(graph.getSelection().start, graph._regions[1].start,
+    "The second region is now selected (1).");
+  is(graph.getSelection().end, graph._regions[1].end,
+    "The second region is now selected (2).");
+}
+
+// EventUtils just doesn't work!
+
+function click(graph, x, y = 1) {
+  x /= window.devicePixelRatio;
+  y /= window.devicePixelRatio;
+  graph._onMouseMove({ clientX: x, clientY: y });
+  graph._onMouseDown({ clientX: x, clientY: y });
+  graph._onMouseUp({ clientX: x, clientY: y });
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_graphs-07.js
@@ -0,0 +1,204 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if selecting, resizing, moving selections and zooming in/out works.
+
+const TEST_DATA = {"112":48,"213":59,"313":60,"413":59,"530":59,"646":58,"747":60,"863":48,"980":37,"1097":30,"1213":29,"1330":23,"1430":10,"1534":17,"1645":20,"1746":22,"1846":39,"1963":26,"2080":27,"2197":35,"2312":47,"2412":53,"2514":60,"2630":37,"2730":36,"2830":37,"2946":36,"3046":40,"3163":47,"3280":41,"3380":35,"3480":27,"3580":39,"3680":42,"3780":49,"3880":55,"3980":60,"4080":60,"4180":60};
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.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();
+  let graph = new LineGraphWidget(doc.body, "fps");
+  yield graph.once("ready");
+
+  testGraph(graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(graph) {
+  graph.setData(TEST_DATA);
+
+  info("Making a selection.");
+
+  dragStart(graph, 300);
+  ok(graph.hasSelectionInProgress(),
+    "The selection should start (1).");
+  is(graph.getSelection().start, 300,
+    "The current selection start value is correct (1).");
+  is(graph.getSelection().end, 300,
+    "The current selection end value is correct (1).");
+
+  hover(graph, 400);
+  ok(graph.hasSelectionInProgress(),
+    "The selection should still be in progress (2).");
+  is(graph.getSelection().start, 300,
+    "The current selection start value is correct (2).");
+  is(graph.getSelection().end, 400,
+    "The current selection end value is correct (2).");
+
+  dragStop(graph, 500);
+  ok(!graph.hasSelectionInProgress(),
+    "The selection should have stopped (3).");
+  is(graph.getSelection().start, 300,
+    "The current selection start value is correct (3).");
+  is(graph.getSelection().end, 500,
+    "The current selection end value is correct (3).");
+
+  info("Making a new selection.");
+
+  dragStart(graph, 200);
+  ok(graph.hasSelectionInProgress(),
+    "The selection should start (4).");
+  is(graph.getSelection().start, 200,
+    "The current selection start value is correct (4).");
+  is(graph.getSelection().end, 200,
+    "The current selection end value is correct (4).");
+
+  hover(graph, 300);
+  ok(graph.hasSelectionInProgress(),
+    "The selection should still be in progress (5).");
+  is(graph.getSelection().start, 200,
+    "The current selection start value is correct (5).");
+  is(graph.getSelection().end, 300,
+    "The current selection end value is correct (5).");
+
+  dragStop(graph, 400);
+  ok(!graph.hasSelectionInProgress(),
+    "The selection should have stopped (6).");
+  is(graph.getSelection().start, 200,
+    "The current selection start value is correct (6).");
+  is(graph.getSelection().end, 400,
+    "The current selection end value is correct (6).");
+
+  info("Resizing by dragging the end handlebar.");
+
+  dragStart(graph, 400);
+  is(graph.getSelection().start, 200,
+    "The current selection start value is correct (7).");
+  is(graph.getSelection().end, 400,
+    "The current selection end value is correct (7).");
+
+  dragStop(graph, 600);
+  is(graph.getSelection().start, 200,
+    "The current selection start value is correct (8).");
+  is(graph.getSelection().end, 600,
+    "The current selection end value is correct (8).");
+
+  info("Resizing by dragging the start handlebar.");
+
+  dragStart(graph, 200);
+  is(graph.getSelection().start, 200,
+    "The current selection start value is correct (9).");
+  is(graph.getSelection().end, 600,
+    "The current selection end value is correct (9).");
+
+  dragStop(graph, 100);
+  is(graph.getSelection().start, 100,
+    "The current selection start value is correct (10).");
+  is(graph.getSelection().end, 600,
+    "The current selection end value is correct (10).");
+
+  info("Moving by dragging the selection.");
+
+  dragStart(graph, 300);
+  hover(graph, 400);
+  is(graph.getSelection().start, 200,
+    "The current selection start value is correct (11).");
+  is(graph.getSelection().end, 700,
+    "The current selection end value is correct (11).");
+
+  dragStop(graph, 500);
+  is(graph.getSelection().start, 300,
+    "The current selection start value is correct (12).");
+  is(graph.getSelection().end, 800,
+    "The current selection end value is correct (12).");
+
+  info("Zooming in by scrolling inside the selection.");
+
+  scroll(graph, -1000, 600);
+  is(graph.getSelection().start, 525,
+    "The current selection start value is correct (13).");
+  is(graph.getSelection().end, 650,
+    "The current selection end value is correct (13).");
+
+  info("Zooming out by scrolling inside the selection.");
+
+  scroll(graph, 1000, 600);
+  is(graph.getSelection().start, 468.75,
+    "The current selection start value is correct (14).");
+  is(graph.getSelection().end, 687.5,
+    "The current selection end value is correct (14).");
+
+  info("Sliding left by scrolling outside the selection.");
+
+  scroll(graph, 100, 900);
+  is(graph.getSelection().start, 458.75,
+    "The current selection start value is correct (15).");
+  is(graph.getSelection().end, 677.5,
+    "The current selection end value is correct (15).");
+
+  info("Sliding right by scrolling outside the selection.");
+
+  scroll(graph, -100, 900);
+  is(graph.getSelection().start, 468.75,
+    "The current selection start value is correct (16).");
+  is(graph.getSelection().end, 687.5,
+    "The current selection end value is correct (16).");
+
+  info("Zooming out a lot.");
+
+  scroll(graph, Number.MAX_SAFE_INTEGER, 500);
+  is(graph.getSelection().start, 1,
+    "The current selection start value is correct (17).");
+  is(graph.getSelection().end, graph.width - 1,
+    "The current selection end value is correct (17).");
+}
+
+// EventUtils just doesn't work!
+
+function hover(graph, x, y = 1) {
+  x /= window.devicePixelRatio;
+  y /= window.devicePixelRatio;
+  graph._onMouseMove({ clientX: x, clientY: y });
+}
+
+function click(graph, x, y = 1) {
+  x /= window.devicePixelRatio;
+  y /= window.devicePixelRatio;
+  graph._onMouseMove({ clientX: x, clientY: y });
+  graph._onMouseDown({ clientX: x, clientY: y });
+  graph._onMouseUp({ 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 });
+}
+
+function scroll(graph, wheel, x, y = 1) {
+  x /= window.devicePixelRatio;
+  y /= window.devicePixelRatio;
+  graph._onMouseMove({ clientX: x, clientY: y });
+  graph._onMouseWheel({ clientX: x, clientY: y, detail: wheel });
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_graphs-08.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests if a selection is dropped when clicking outside of it.
+
+const TEST_DATA = {"112":48,"213":59,"313":60,"413":59,"530":59,"646":58,"747":60,"863":48,"980":37,"1097":30,"1213":29,"1330":23,"1430":10,"1534":17,"1645":20,"1746":22,"1846":39,"1963":26,"2080":27,"2197":35,"2312":47,"2412":53,"2514":60,"2630":37,"2730":36,"2830":37,"2946":36,"3046":40,"3163":47,"3280":41,"3380":35,"3480":27,"3580":39,"3680":42,"3780":49,"3880":55,"3980":60,"4080":60,"4180":60};
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.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();
+  let graph = new LineGraphWidget(doc.body, "fps");
+  yield graph.once("ready");
+
+  testGraph(graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(graph) {
+  graph.setData(TEST_DATA);
+
+  dragStart(graph, 300);
+  dragStop(graph, 500);
+  ok(graph.hasSelection(),
+    "A selection should be available.");
+  is(graph.getSelection().start, 300,
+    "The current selection start value is correct.");
+  is(graph.getSelection().end, 500,
+    "The current selection end value is correct.");
+
+  click(graph, 600);
+  ok(!graph.hasSelection(),
+    "The selection should be dropped.");
+}
+
+// EventUtils just doesn't work!
+
+function click(graph, x, y = 1) {
+  x /= window.devicePixelRatio;
+  y /= window.devicePixelRatio;
+  graph._onMouseMove({ clientX: x, clientY: y });
+  graph._onMouseDown({ clientX: x, clientY: y });
+  graph._onMouseUp({ 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 });
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_graphs-09.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that line graphs properly create the gutter and tooltips.
+
+const TEST_DATA = {"112":48,"213":59,"313":60,"413":59,"530":59,"646":58,"747":60,"863":48,"980":37,"1097":30,"1213":29,"1330":23,"1430":10,"1534":17,"1645":20,"1746":22,"1846":39,"1963":26,"2080":27,"2197":35,"2312":47,"2412":53,"2514":60,"2630":37,"2730":36,"2830":37,"2946":36,"3046":40,"3163":47,"3280":41,"3380":35,"3480":27,"3580":39,"3680":42,"3780":49,"3880":55,"3980":60,"4080":60,"4180":60};
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.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();
+  let graph = new LineGraphWidget(doc.body, "fps");
+  yield graph.once("ready");
+
+  testGraph(graph);
+
+  graph.destroy();
+  host.destroy();
+}
+
+function testGraph(graph) {
+  graph.setData(TEST_DATA);
+
+  is(graph._maxTooltip.querySelector("[text=info]").textContent, "max",
+    "The maximum tooltip displays the correct info.");
+  is(graph._avgTooltip.querySelector("[text=info]").textContent, "avg",
+    "The average tooltip displays the correct info.");
+  is(graph._minTooltip.querySelector("[text=info]").textContent, "min",
+    "The minimum tooltip displays the correct info.");
+
+  is(graph._maxTooltip.querySelector("[text=value]").textContent, "60",
+    "The maximum tooltip displays the correct value.");
+  is(graph._avgTooltip.querySelector("[text=value]").textContent, "41",
+    "The average tooltip displays the correct value.");
+  is(graph._minTooltip.querySelector("[text=value]").textContent, "10",
+    "The minimum tooltip displays the correct value.");
+
+  is(graph._maxTooltip.querySelector("[text=metric]").textContent, "fps",
+    "The maximum tooltip displays the correct metric.");
+  is(graph._avgTooltip.querySelector("[text=metric]").textContent, "fps",
+    "The average tooltip displays the correct metric.");
+  is(graph._minTooltip.querySelector("[text=metric]").textContent, "fps",
+    "The minimum tooltip displays the correct metric.");
+
+  is(parseInt(graph._maxTooltip.style.top), 22,
+    "The maximum tooltip is positioned correctly.");
+  is(parseInt(graph._avgTooltip.style.top), 61,
+    "The average tooltip is positioned correctly.");
+  is(parseInt(graph._minTooltip.style.top), 128,
+    "The minimum tooltip is positioned correctly.");
+
+  is(parseInt(graph._maxGutterLine.style.top), 22,
+    "The maximum gutter line is positioned correctly.");
+  is(parseInt(graph._avgGutterLine.style.top), 61,
+    "The average gutter line is positioned correctly.");
+  is(parseInt(graph._minGutterLine.style.top), 128,
+    "The minimum gutter line is positioned correctly.");
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/browser_graphs-10.js
@@ -0,0 +1,142 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that line graph properly handles resizing.
+
+const TEST_DATA = {"112":48,"213":59,"313":60,"413":59,"530":59,"646":58,"747":60,"863":48,"980":37,"1097":30,"1213":29,"1330":23,"1430":10,"1534":17,"1645":20,"1746":22,"1846":39,"1963":26,"2080":27,"2197":35,"2312":47,"2412":53,"2514":60,"2630":37,"2730":36,"2830":37,"2946":36,"3046":40,"3163":47,"3280":41,"3380":35,"3480":27,"3580":39,"3680":42,"3780":49,"3880":55,"3980":60,"4080":60,"4180":60};
+let {LineGraphWidget} = Cu.import("resource:///modules/devtools/Graphs.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("window");
+  doc.body.setAttribute("style", "position: fixed; width: 100%; height: 100%; margin: 0;");
+
+  let graph = new LineGraphWidget(doc.body, "fps");
+  yield graph.once("ready");
+
+  let refreshCount = 0;
+  graph.on("refresh", () => refreshCount++);
+
+  yield testGraph(host, graph);
+
+  is(refreshCount, 2, "The graph should've been refreshed 2 times.");
+
+  graph.destroy();
+  host.destroy();
+}
+
+function* testGraph(host, graph) {
+  graph.setData(TEST_DATA);
+  let initialBounds = host.frame.getBoundingClientRect();
+
+  host._window.resizeBy(-100, -100);
+  yield graph.once("refresh");
+  let newBounds = host.frame.getBoundingClientRect();
+
+  is(initialBounds.width - newBounds.width, 100,
+    "The window was properly resized (1).");
+  is(initialBounds.height - newBounds.height, 100,
+    "The window was properly resized (2).");
+
+  is(graph.width, newBounds.width * window.devicePixelRatio,
+    "The graph has the correct width (1).");
+  is(graph.height, newBounds.height * window.devicePixelRatio,
+    "The graph has the correct height (1).");
+
+  info("Making a selection.");
+
+  dragStart(graph, 300);
+  ok(graph.hasSelectionInProgress(),
+    "The selection should start (1).");
+  is(graph.getSelection().start, 300,
+    "The current selection start value is correct (1).");
+  is(graph.getSelection().end, 300,
+    "The current selection end value is correct (1).");
+
+  hover(graph, 400);
+  ok(graph.hasSelectionInProgress(),
+    "The selection should still be in progress (2).");
+  is(graph.getSelection().start, 300,
+    "The current selection start value is correct (2).");
+  is(graph.getSelection().end, 400,
+    "The current selection end value is correct (2).");
+
+  dragStop(graph, 500);
+  ok(!graph.hasSelectionInProgress(),
+    "The selection should have stopped (3).");
+  is(graph.getSelection().start, 300,
+    "The current selection start value is correct (3).");
+  is(graph.getSelection().end, 500,
+    "The current selection end value is correct (3).");
+
+  host._window.resizeBy(100, 100);
+  yield graph.once("refresh");
+  let newerBounds = host.frame.getBoundingClientRect();
+
+  is(initialBounds.width - newerBounds.width, 0,
+    "The window was properly resized (3).");
+  is(initialBounds.height - newerBounds.height, 0,
+    "The window was properly resized (4).");
+
+  is(graph.width, newerBounds.width * window.devicePixelRatio,
+    "The graph has the correct width (2).");
+  is(graph.height, newerBounds.height * window.devicePixelRatio,
+    "The graph has the correct height (2).");
+
+  info("Making a new selection.");
+
+  dragStart(graph, 200);
+  ok(graph.hasSelectionInProgress(),
+    "The selection should start (4).");
+  is(graph.getSelection().start, 200,
+    "The current selection start value is correct (4).");
+  is(graph.getSelection().end, 200,
+    "The current selection end value is correct (4).");
+
+  hover(graph, 300);
+  ok(graph.hasSelectionInProgress(),
+    "The selection should still be in progress (5).");
+  is(graph.getSelection().start, 200,
+    "The current selection start value is correct (5).");
+  is(graph.getSelection().end, 300,
+    "The current selection end value is correct (5).");
+
+  dragStop(graph, 400);
+  ok(!graph.hasSelectionInProgress(),
+    "The selection should have stopped (6).");
+  is(graph.getSelection().start, 200,
+    "The current selection start value is correct (6).");
+  is(graph.getSelection().end, 400,
+    "The current selection end value is correct (6).");
+}
+
+// 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 });
+}
--- a/browser/devtools/shared/test/head.js
+++ b/browser/devtools/shared/test/head.js
@@ -1,15 +1,15 @@
 /* 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/. */
 
 let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+let {console} = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
 let TargetFactory = devtools.TargetFactory;
-let {console} = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
 
 gDevTools.testing = true;
 SimpleTest.registerCleanupFunction(() => {
   gDevTools.testing = false;
 });
 
 /**
  * Open a new tab at a URL and call a callback on load
@@ -27,16 +27,22 @@ function addTab(aURL, aCallback)
   function onTabLoad() {
     browser.removeEventListener("load", onTabLoad, true);
     aCallback(browser, tab, browser.contentDocument);
   }
 
   browser.addEventListener("load", onTabLoad, true);
 }
 
+function promiseTab(aURL) {
+  let deferred = Promise.defer();
+  addTab(aURL, deferred.resolve);
+  return deferred.promise;
+}
+
 registerCleanupFunction(function tearDown() {
   while (gBrowser.tabs.length > 1) {
     gBrowser.removeCurrentTab();
   }
 
   console = undefined;
 });
 
@@ -120,8 +126,21 @@ function waitForValue(aOptions)
 
 function oneTimeObserve(name, callback) {
   var func = function() {
     Services.obs.removeObserver(func, name);
     callback();
   };
   Services.obs.addObserver(func, name, false);
 }
+
+function* createHost(type = "bottom", src = "data:text/html;charset=utf-8,") {
+  let host = new Hosts[type](gBrowser.selectedTab);
+  let iframe = yield host.create();
+
+  let loaded = Promise.defer();
+  let domHelper = new DOMHelpers(iframe.contentWindow);
+  iframe.setAttribute("src", src);
+  domHelper.onceDOMReady(loaded.resolve);
+  yield loaded.promise;
+
+  return [host, iframe.contentWindow, iframe.contentDocument];
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/widgets/Graphs.jsm
@@ -0,0 +1,1229 @@
+/* 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://gre/modules/devtools/event-emitter.js");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+
+this.EXPORTED_SYMBOLS = ["LineGraphWidget"];
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const GRAPH_SRC = "chrome://browser/content/devtools/graphs-frame.xhtml";
+
+// Generic constants.
+
+const GRAPH_DAMPEN_VALUES = 0.85;
+const GRAPH_RESIZE_EVENTS_DRAIN = 20; // ms
+const GRAPH_WHEEL_ZOOM_SENSITIVITY = 0.00075;
+const GRAPH_WHEEL_SCROLL_SENSITIVITY = 0.1;
+const GRAPH_WHEEL_MIN_SELECTION_WIDTH = 10; // px
+
+const GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH = 4; // px
+const GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD = 10; // px
+const GRAPH_MAX_SELECTION_LEFT_PADDING = 1;
+const GRAPH_MAX_SELECTION_RIGHT_PADDING = 1;
+
+const GRAPH_REGION_LINE_WIDTH = 1; // px
+const GRAPH_REGION_LINE_COLOR = "rgba(237,38,85,0.8)";
+
+const GRAPH_STRIPE_PATTERN_WIDTH = 16; // px
+const GRAPH_STRIPE_PATTERN_HEIGHT = 16; // px
+const GRAPH_STRIPE_PATTERN_LINE_WIDTH = 4; // px
+const GRAPH_STRIPE_PATTERN_LINE_SPACING = 8; // px
+
+// Line graph constants.
+
+const LINE_GRAPH_MIN_SQUARED_DISTANCE_BETWEEN_POINTS = 400; // 20 px
+const LINE_GRAPH_STROKE_WIDTH = 2; // px
+const LINE_GRAPH_STROKE_COLOR = "rgba(255,255,255,0.9)";
+const LINE_GRAPH_HELPER_LINES_DASH = [5]; // px
+const LINE_GRAPH_HELPER_LINES_WIDTH = 1; // px
+const LINE_GRAPH_MAXIMUM_LINE_COLOR = "rgba(255,255,255,0.4)";
+const LINE_GRAPH_AVERAGE_LINE_COLOR = "rgba(255,255,255,0.7)";
+const LINE_GRAPH_MINIMUM_LINE_COLOR = "rgba(255,255,255,0.9)";
+const LINE_GRAPH_BACKGROUND_GRADIENT_START = "rgba(255,255,255,0.25)";
+const LINE_GRAPH_BACKGROUND_GRADIENT_END = "rgba(255,255,255,0.0)";
+
+const LINE_GRAPH_CLIPHEAD_LINE_COLOR = "#fff";
+const LINE_GRAPH_SELECTION_LINE_COLOR = "#fff";
+const LINE_GRAPH_SELECTION_BACKGROUND_COLOR = "rgba(44,187,15,0.25)";
+const LINE_GRAPH_SELECTION_STRIPES_COLOR = "rgba(255,255,255,0.1)";
+const LINE_GRAPH_REGION_BACKGROUND_COLOR = "transparent";
+const LINE_GRAPH_REGION_STRIPES_COLOR = "rgba(237,38,85,0.2)";
+
+const LINE_GRAPH_TOOLTIP_SAFE_BOUNDS = 10; // px
+
+/**
+ * Small data primitives for all graphs.
+ */
+this.GraphCursor = function() {}
+this.GraphSelection = function() {}
+this.GraphSelectionDragger = function() {}
+this.GraphSelectionResizer = function() {}
+
+GraphCursor.prototype = {
+  x: null,
+  y: null
+};
+
+GraphSelection.prototype = {
+  start: null,
+  end: null
+};
+
+GraphSelectionDragger.prototype = {
+  origin: null,
+  anchor: new GraphSelection()
+};
+
+GraphSelectionResizer.prototype = {
+  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.
+ *     Its specific format is defined by the inheriting classes.
+ *
+ *   - A "cursor" is the cliphead position across the X axis of the graph.
+ *
+ *   - A "selection" is defined by a "start" and an "end" value and
+ *     represents the selected bounds in the graph.
+ *
+ *   - A "region" is a highlighted area in the graph, also defined by a
+ *     "start" and an "end" value, but distinct from the "selection". It is
+ *     simply used to highlight important regions in the data.
+ *
+ * Instances of this class are EventEmitters with the following events:
+ *   - "ready": when the container iframe and canvas are created.
+ *   - "selecting": when the selection is set or changed.
+ *   - "deselecting": when the selection is dropped.
+ *
+ * @param nsIDOMNode parent
+ *        The parent node holding the graph.
+ * @param string name
+ *        The graph type, used for setting the correct class names.
+ *        Currently supported: "line-graph" only.
+ * @param number sharpness [optional]
+ *        Defaults to the current device pixel ratio.
+ */
+this.AbstractCanvasGraph = function(parent, name, sharpness) {
+  EventEmitter.decorate(this);
+
+  this._parent = parent;
+  this._uid = "canvas-graph-" + Date.now();
+
+  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 = name + "-widget-container";
+
+    let canvas = this._canvas = this._document.getElementById("graph-canvas");
+    canvas.className = name + "-widget-canvas graph-widget-canvas";
+
+    let bounds = parent.getBoundingClientRect();
+    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._ctx.mozImageSmoothingEnabled = false;
+
+    this._cursor = new GraphCursor();
+    this._selection = new GraphSelection();
+    this._selectionDragger = new GraphSelectionDragger();
+    this._selectionResizer = new GraphSelectionResizer();
+
+    this._onAnimationFrame = this._onAnimationFrame.bind(this);
+    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._onMouseOut = this._onMouseOut.bind(this);
+    this._onResize = this._onResize.bind(this);
+    this.refresh = this.refresh.bind(this);
+
+    container.addEventListener("mousemove", this._onMouseMove);
+    container.addEventListener("mousedown", this._onMouseDown);
+    container.addEventListener("mouseup", this._onMouseUp);
+    container.addEventListener("MozMousePixelScroll", this._onMouseWheel);
+    container.addEventListener("mouseout", this._onMouseOut);
+
+    let ownerWindow = this._parent.ownerDocument.defaultView;
+    ownerWindow.addEventListener("resize", this._onResize);
+
+    this._animationId = this._window.requestAnimationFrame(this._onAnimationFrame);
+
+    this.emit("ready", this);
+  });
+}
+
+AbstractCanvasGraph.prototype = {
+  /**
+   * Read-only width and height of the canvas.
+   * @return number
+   */
+  get width() {
+    return this._width;
+  },
+  get height() {
+    return this._height;
+  },
+
+  /**
+   * 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);
+    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._data = null;
+    this._regions = null;
+    this._cachedGraphImage = null;
+    gCachedStripePattern.clear();
+  },
+
+  /**
+   * Rendering options. Subclasses should override these.
+   */
+  clipheadLineWidth: 1,
+  clipheadLineColor: "transparent",
+  selectionLineWidth: 1,
+  selectionLineColor: "transparent",
+  selectionBackgroundColor: "transparent",
+  selectionStripesColor: "transparent",
+  regionBackgroundColor: "transparent",
+  regionStripesColor: "transparent",
+
+  /**
+   * Builds and caches a graph image, based on the data source supplied
+   * in `setData`. The graph image is not rebuilt on each frame, but
+   * only when the data source changes.
+   */
+  buildGraphImage: function() {
+    throw "This method needs to be implemented by inheriting classes.";
+  },
+
+  /**
+   * When setting the data source, the coordinates and values may be
+   * stretched or squeezed on the X/Y axis, to fit into the available space.
+   */
+  dataScaleX: 1,
+  dataScaleY: 1,
+
+  /**
+   * Sets the data source for this graph.
+   *
+   * @param object data
+   *        The data source. The actual format is specified by subclasses.
+   */
+  setData: function(data) {
+    this._data = data;
+    this._cachedGraphImage = this.buildGraphImage();
+    this._shouldRedraw = true;
+  },
+
+  /**
+   * Adds regions to this graph.
+   *
+   * See the "Language" section in the constructor documentation
+   * for details about what "regions" represent.
+   *
+   * @param array regions
+   *        A list of { start, end } values.
+   */
+  setRegions: function(regions) {
+    if (!this._cachedGraphImage) {
+      throw "Can't highlighted regions on a graph with no data displayed.";
+    }
+    if (this._regions) {
+      throw "Regions were already highlighted on the graph.";
+    }
+    this._regions = regions.map(e => ({
+      start: e.start * this.dataScaleX,
+      end: e.end * this.dataScaleX
+    }));
+    this._bakeRegions(this._regions, this._cachedGraphImage);
+    this._shouldRedraw = true;
+  },
+
+  /**
+   * Gets whether or not this graph has a data source.
+   * @return boolean
+   */
+  hasData: function() {
+    return !!this._data;
+  },
+
+  /**
+   * Gets whether or not this graph has any regions.
+   * @return boolean
+   */
+  hasRegions: function() {
+    return !!this._regions;
+  },
+
+  /**
+   * Sets the selection bounds.
+   * Use `dropSelection` to remove the selection.
+   *
+   * If the bounds aren't different, no "selection" event is emitted.
+   *
+   * See the "Language" section in the constructor documentation
+   * for details about what a "selection" represents.
+   *
+   * @param object selection
+   *        The selection's { start, end } values.
+   */
+  setSelection: function(selection) {
+    if (!selection || selection.start == null || selection.end == null) {
+      throw "Invalid selection coordinates";
+    }
+    if (!this.isSelectionDifferent(selection)) {
+      return;
+    }
+    this._selection.start = selection.start;
+    this._selection.end = selection.end;
+    this._shouldRedraw = true;
+    this.emit("selecting");
+  },
+
+  /**
+   * Gets the selection bounds.
+   * If there's no selection, the bounds have null values.
+   *
+   * @return object
+   *         The selection's { start, end } values.
+   */
+  getSelection: function() {
+    if (this.hasSelection()) {
+      return { start: this._selection.start, end: this._selection.end };
+    }
+    if (this.hasSelectionInProgress()) {
+      return { start: this._selection.start, end: this._cursor.x };
+    }
+    return { start: null, end: null };
+  },
+
+  /**
+   * Removes the selection.
+   */
+  dropSelection: function() {
+    this._selection.start = null;
+    this._selection.end = null;
+    this._shouldRedraw = true;
+    this.emit("deselecting");
+  },
+
+  /**
+   * Gets whether or not this graph has a selection.
+   * @return boolean
+   */
+  hasSelection: function() {
+    return this._selection.start != null && this._selection.end != null;
+  },
+
+  /**
+   * Gets whether or not a selection is currently being made, for example
+   * via a click+drag operation.
+   * @return boolean
+   */
+  hasSelectionInProgress: function() {
+    return this._selection.start != null && this._selection.end == null;
+  },
+
+  /**
+   * Sets the selection bounds.
+   * Use `dropCursor` to hide the cursor.
+   *
+   * @param object cursor
+   *        The cursor's { x, y } position.
+   */
+  setCursor: function(cursor) {
+    if (!cursor || cursor.x == null || cursor.y == null) {
+      throw "Invalid cursor coordinates";
+    }
+    if (!this.isCursorDifferent(cursor)) {
+      return;
+    }
+    this._cursor.x = cursor.x;
+    this._cursor.y = cursor.y;
+    this._shouldRedraw = true;
+  },
+
+  /**
+   * Gets the cursor position.
+   * If there's no cursor, the position has null values.
+   *
+   * @return object
+   *         The cursor's { x, y } values.
+   */
+  getCursor: function() {
+    return { x: this._cursor.x, y: this._cursor.y };
+  },
+
+  /**
+   * Hides the cursor.
+   */
+  dropCursor: function() {
+    this._cursor.x = null;
+    this._cursor.y = null;
+    this._shouldRedraw = true;
+  },
+
+  /**
+   * Gets whether or not this graph has a visible cursor.
+   * @return boolean
+   */
+  hasCursor: function() {
+    return this._cursor.x != null;
+  },
+
+  /**
+   * Specifies if this graph's selection is different from another one.
+   *
+   * @param object other
+   *        The other graph's selection, as { start, end } values.
+   */
+  isSelectionDifferent: function(other) {
+    if (!other) return true;
+    let current = this.getSelection();
+    return current.start != other.start || current.end != other.end;
+  },
+
+  /**
+   * Specifies if this graph's cursor is different from another one.
+   *
+   * @param object other
+   *        The other graph's position, as { x, y } values.
+   */
+  isCursorDifferent: function(other) {
+    if (!other) return true;
+    let current = this.getCursor();
+    return current.x != other.x || current.y != other.y;
+  },
+
+  /**
+   * Gets the width of the current selection.
+   * If no selection is available, 0 is returned.
+   *
+   * @return number
+   *         The selection width.
+   */
+  getSelectionWidth: function() {
+    let selection = this.getSelection();
+    return Math.abs(selection.start - selection.end);
+  },
+
+  /**
+   * Gets the currently hovered region, if any.
+   * If no region is currently hovered, null is returned.
+   *
+   * @return object
+   *         The hovered region, as { start, end } values.
+   */
+  getHoveredRegion: function() {
+    if (!this.hasRegions() || !this.hasCursor()) {
+      return null;
+    }
+    let { x } = this._cursor;
+    return this._regions.find(({ start, end }) =>
+      (start < end && start < x && end > x) ||
+      (start > end && end < x && start > x));
+  },
+
+  /**
+   * Updates this graph to reflect the new dimensions of the parent node.
+   */
+  refresh: function() {
+    let bounds = this._parent.getBoundingClientRect();
+    this._iframe.setAttribute("width", bounds.width);
+    this._iframe.setAttribute("height", bounds.height);
+    this._width = this._canvas.width = bounds.width * this._pixelRatio;
+    this._height = this._canvas.height = bounds.height * this._pixelRatio;
+
+    if (this._data) {
+      this._cachedGraphImage = this.buildGraphImage();
+    }
+    if (this._regions) {
+      this._bakeRegions(this._regions, this._cachedGraphImage);
+    }
+
+    this._shouldRedraw = true;
+    this.emit("refresh");
+  },
+
+  /**
+   * 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;
+    ctx.clearRect(0, 0, this._width, this._height);
+
+    if (this.hasCursor()) {
+      this._drawCliphead();
+    }
+    if (this.hasSelection() || this.hasSelectionInProgress()) {
+      this._drawSelection();
+    }
+    if (this.hasData()) {
+      ctx.drawImage(this._cachedGraphImage, 0, 0, this._width, this._height);
+    }
+
+    this._shouldRedraw = false;
+  },
+
+  /**
+   * Draws the cliphead, if available and necessary.
+   */
+  _drawCliphead: function() {
+    if (this._isHoveringSelectionContentsOrBoundaries() || this._isHoveringRegion()) {
+      return;
+    }
+
+    let ctx = this._ctx;
+    ctx.lineWidth = this.clipheadLineWidth;
+    ctx.strokeStyle = this.clipheadLineColor;
+    ctx.beginPath();
+    ctx.moveTo(this._cursor.x, 0);
+    ctx.lineTo(this._cursor.x, this._height);
+    ctx.stroke();
+  },
+
+  /**
+   * Draws the selection, if available and necessary.
+   */
+  _drawSelection: function() {
+    let { start, end } = this.getSelection();
+    let input = this._canvas.getAttribute("input");
+
+    let ctx = this._ctx;
+    ctx.strokeStyle = this.selectionLineColor;
+
+    // Fill selection.
+
+    let pattern = AbstractCanvasGraph.getStripePattern({
+      ownerDocument: this._document,
+      backgroundColor: this.selectionBackgroundColor,
+      stripesColor: this.selectionStripesColor
+    });
+    ctx.fillStyle = pattern;
+    ctx.fillRect(start, 0, end - start, this._height);
+
+    // Draw left boundary.
+
+    if (input == "hovering-selection-start-boundary") {
+      ctx.lineWidth = GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH;
+    } else {
+      ctx.lineWidth = this.clipheadLineWidth;
+    }
+    ctx.beginPath();
+    ctx.moveTo(start, 0);
+    ctx.lineTo(start, this._height);
+    ctx.stroke();
+
+    // Draw right boundary.
+
+    if (input == "hovering-selection-end-boundary") {
+      ctx.lineWidth = GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH;
+    } else {
+      ctx.lineWidth = this.clipheadLineWidth;
+    }
+    ctx.beginPath();
+    ctx.moveTo(end, this._height);
+    ctx.lineTo(end, 0);
+    ctx.stroke();
+  },
+
+  /**
+   * Draws regions into the cached graph image, created via `buildGraphImage`.
+   * Called when new regions are set.
+   */
+  _bakeRegions: function(regions, destination) {
+    let ctx = destination.getContext("2d");
+
+    let pattern = AbstractCanvasGraph.getStripePattern({
+      ownerDocument: this._document,
+      backgroundColor: this.regionBackgroundColor,
+      stripesColor: this.regionStripesColor
+    });
+    ctx.fillStyle = pattern;
+    ctx.strokeStyle = GRAPH_REGION_LINE_COLOR;
+    ctx.lineWidth = GRAPH_REGION_LINE_WIDTH;
+
+    let y = -GRAPH_REGION_LINE_WIDTH;
+    let height = this._height + GRAPH_REGION_LINE_WIDTH;
+
+    for (let { start, end } of regions) {
+      let x = start;
+      let width = end - start;
+      ctx.fillRect(x, y, width, height);
+      ctx.strokeRect(x, y, width, height);
+    }
+  },
+
+  /**
+   * Checks whether the start handle of the selection is hovered.
+   * @return boolean
+   */
+  _isHoveringStartBoundary: function() {
+    if (!this.hasSelection() || !this.hasCursor()) {
+      return;
+    }
+    let { x } = this._cursor;
+    let { start } = this._selection;
+    let threshold = GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD * this._pixelRatio;
+    return Math.abs(start - x) < threshold;
+  },
+
+  /**
+   * Checks whether the end handle of the selection is hovered.
+   * @return boolean
+   */
+  _isHoveringEndBoundary: function() {
+    if (!this.hasSelection() || !this.hasCursor()) {
+      return;
+    }
+    let { x } = this._cursor;
+    let { end } = this._selection;
+    let threshold = GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD * this._pixelRatio;
+    return Math.abs(end - x) < threshold;
+  },
+
+  /**
+   * Checks whether the selection is hovered.
+   * @return boolean
+   */
+  _isHoveringSelectionContents: function() {
+    if (!this.hasSelection() || !this.hasCursor()) {
+      return;
+    }
+    let { x } = this._cursor;
+    let { start, end } = this._selection;
+    return (start < end && start < x && end > x) ||
+           (start > end && end < x && start > x);
+  },
+
+  /**
+   * Checks whether the selection or its handles are hovered.
+   * @return boolean
+   */
+  _isHoveringSelectionContentsOrBoundaries: function() {
+    return this._isHoveringSelectionContents() ||
+           this._isHoveringStartBoundary() ||
+           this._isHoveringEndBoundary();
+  },
+
+  /**
+   * Checks whether a region is hovered.
+   * @return boolean
+   */
+  _isHoveringRegion: function() {
+    return !!this.getHoveredRegion();
+  },
+
+  /**
+   * Gets the offset of this graph's container relative to the owner window.
+   */
+  _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 };
+  },
+
+  /**
+   * 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 mouseY = (e.clientY - offset.top) * this._pixelRatio;
+    this._cursor.x = mouseX;
+    this._cursor.y = mouseY;
+
+    let resizer = this._selectionResizer;
+    if (resizer.margin != null) {
+      this._selection[resizer.margin] = mouseX;
+      this._shouldRedraw = true;
+      this.emit("selecting");
+      return;
+    }
+
+    let dragger = this._selectionDragger;
+    if (dragger.origin != null) {
+      this._selection.start = dragger.anchor.start - dragger.origin + mouseX;
+      this._selection.end = dragger.anchor.end - dragger.origin + mouseX;
+      this._shouldRedraw = true;
+      this.emit("selecting");
+      return;
+    }
+
+    if (this.hasSelectionInProgress()) {
+      this._shouldRedraw = true;
+      this.emit("selecting");
+      return;
+    }
+
+    if (this.hasSelection()) {
+      if (this._isHoveringStartBoundary()) {
+        this._canvas.setAttribute("input", "hovering-selection-start-boundary");
+        this._shouldRedraw = true;
+        return;
+      }
+      if (this._isHoveringEndBoundary()) {
+        this._canvas.setAttribute("input", "hovering-selection-end-boundary");
+        this._shouldRedraw = true;
+        return;
+      }
+      if (this._isHoveringSelectionContents()) {
+        this._canvas.setAttribute("input", "hovering-selection-contents");
+        this._shouldRedraw = true;
+        return;
+      }
+    }
+
+    let region = this.getHoveredRegion();
+    if (region) {
+      this._canvas.setAttribute("input", "hovering-region");
+    } else {
+      this._canvas.setAttribute("input", "hovering-background");
+    }
+
+    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;
+
+    switch (this._canvas.getAttribute("input")) {
+      case "hovering-background":
+      case "hovering-region":
+        this._selection.start = mouseX;
+        this._selection.end = null;
+        this.emit("selecting");
+        break;
+
+      case "hovering-selection-start-boundary":
+        this._selectionResizer.margin = "start";
+        break;
+
+      case "hovering-selection-end-boundary":
+        this._selectionResizer.margin = "end";
+        break;
+
+      case "hovering-selection-contents":
+        this._selectionDragger.origin = mouseX;
+        this._selectionDragger.anchor.start = this._selection.start;
+        this._selectionDragger.anchor.end = this._selection.end;
+        this._canvas.setAttribute("input", "dragging-selection-contents");
+        break;
+    }
+
+    this._shouldRedraw = true;
+  },
+
+  /**
+   * Listener for the "mouseup" event on the graph's container.
+   */
+  _onMouseUp: function(e) {
+    let offset = this._getContainerOffset();
+    let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+
+    switch (this._canvas.getAttribute("input")) {
+      case "hovering-background":
+      case "hovering-region":
+        if (this.getSelectionWidth() < 1) {
+          let region = this.getHoveredRegion();
+          if (region) {
+            this._selection.start = region.start;
+            this._selection.end = region.end;
+            this.emit("selecting");
+          } else {
+            this._selection.start = null;
+            this._selection.end = null;
+            this.emit("deselecting");
+          }
+        } else {
+          this._selection.end = mouseX;
+          this.emit("selecting");
+        }
+        break;
+
+      case "hovering-selection-start-boundary":
+      case "hovering-selection-end-boundary":
+        this._selectionResizer.margin = null;
+        break;
+
+      case "dragging-selection-contents":
+        this._selectionDragger.origin = null;
+        this._canvas.setAttribute("input", "hovering-selection-contents");
+        break;
+    }
+
+    this._shouldRedraw = true;
+  },
+
+  /**
+   * Listener for the "wheel" event on the graph's container.
+   */
+  _onMouseWheel: function(e) {
+    if (!this.hasSelection()) {
+      return;
+    }
+
+    let offset = this._getContainerOffset();
+    let mouseX = (e.clientX - offset.left) * this._pixelRatio;
+    let focusX = mouseX;
+
+    let selection = this._selection;
+    let vector = 0;
+
+    // If the selection is hovered, "zoom" towards or away the cursor,
+    // by shrinking or growing the selection.
+    if (this._isHoveringSelectionContentsOrBoundaries()) {
+      let distStart = selection.start - focusX;
+      let distEnd = selection.end - focusX;
+      vector = e.detail * GRAPH_WHEEL_ZOOM_SENSITIVITY;
+      selection.start = selection.start + distStart * vector;
+      selection.end = selection.end + distEnd * vector;
+    }
+    // Otherwise, simply pan the selection towards the left or right.
+    else {
+      let direction = 0;
+      if (focusX > selection.end) {
+        direction = Math.sign(focusX - selection.end);
+      } else if (focusX < selection.start) {
+        direction = Math.sign(focusX - selection.start);
+      }
+      vector = direction * e.detail * GRAPH_WHEEL_SCROLL_SENSITIVITY;
+      selection.start -= vector;
+      selection.end -= vector;
+    }
+
+    // Make sure the selection bounds are still comfortably inside the
+    // graph's bounds when zooming out, to keep the margin handles accessible.
+
+    let minStart = GRAPH_MAX_SELECTION_LEFT_PADDING;
+    let maxEnd = this._width - GRAPH_MAX_SELECTION_RIGHT_PADDING;
+    if (selection.start < minStart) {
+      selection.start = minStart;
+    }
+    if (selection.start > maxEnd) {
+      selection.start = maxEnd;
+    }
+    if (selection.end < minStart) {
+      selection.end = minStart;
+    }
+    if (selection.end > maxEnd) {
+      selection.end = maxEnd;
+    }
+
+    // Make sure the selection doesn't get too narrow when zooming in.
+
+    let thickness = Math.abs(selection.start - selection.end);
+    if (thickness < GRAPH_WHEEL_MIN_SELECTION_WIDTH) {
+      let midPoint = (selection.start + selection.end) / 2;
+      selection.start = midPoint - GRAPH_WHEEL_MIN_SELECTION_WIDTH / 2;
+      selection.end = midPoint + GRAPH_WHEEL_MIN_SELECTION_WIDTH / 2;
+    }
+
+    this._shouldRedraw = true;
+    this.emit("selecting");
+  },
+
+  /**
+   * Listener for the "mouseout" event on the graph's container.
+   */
+  _onMouseOut: function() {
+    if (this.hasSelectionInProgress()) {
+      this.dropSelection();
+    }
+
+    this._cursor.x = null;
+    this._cursor.y = null;
+    this._selectionResizer.margin = null;
+    this._selectionDragger.origin = null;
+
+    this._canvas.removeAttribute("input");
+    this._shouldRedraw = true;
+  },
+
+  /**
+   * Listener for the "resize" event on the graph's parent node.
+   */
+  _onResize: function() {
+    if (this._cachedGraphImage) {
+      setNamedTimeout(this._uid, GRAPH_RESIZE_EVENTS_DRAIN, this.refresh);
+    }
+  }
+};
+
+/**
+ * A basic line graph, plotting values on a curve and adding helper lines
+ * and tooltips for maximum, average and minimum values.
+ *
+ * @param nsIDOMNode parent
+ *        The parent node holding the graph.
+ * @param string metric [optional]
+ *        The metric displayed in the graph, e.g. "fps" or "bananas".
+ */
+this.LineGraphWidget = function(parent, metric, ...args) {
+  AbstractCanvasGraph.apply(this, [parent, "line-graph", ...args]);
+
+  this.once("ready", () => {
+    this._gutter = this._createGutter();
+    this._maxGutterLine = this._createGutterLine("maximum");
+    this._avgGutterLine = this._createGutterLine("average");
+    this._minGutterLine = this._createGutterLine("minimum");
+    this._maxTooltip = this._createTooltip("maximum", "start", "max", metric);
+    this._avgTooltip = this._createTooltip("average", "end", "avg", metric);
+    this._minTooltip = this._createTooltip("minimum", "start", "min", metric);
+  });
+}
+
+LineGraphWidget.prototype = Heritage.extend(AbstractCanvasGraph.prototype, {
+  clipheadLineColor: LINE_GRAPH_CLIPHEAD_LINE_COLOR,
+  selectionLineColor: LINE_GRAPH_SELECTION_LINE_COLOR,
+  selectionBackgroundColor: LINE_GRAPH_SELECTION_BACKGROUND_COLOR,
+  selectionStripesColor: LINE_GRAPH_SELECTION_STRIPES_COLOR,
+  regionBackgroundColor: LINE_GRAPH_REGION_BACKGROUND_COLOR,
+  regionStripesColor: LINE_GRAPH_REGION_STRIPES_COLOR,
+
+  /**
+   * Renders the graph on a canvas.
+   * @see AbstractCanvasGraph.prototype.buildGraphImage
+   */
+  buildGraphImage: function() {
+    let canvas = this._document.createElementNS(HTML_NS, "canvas");
+    let ctx = canvas.getContext("2d");
+    let width = canvas.width = this._width;
+    let height = canvas.height = this._height;
+
+    let maxValue = Number.MIN_SAFE_INTEGER;
+    let minValue = Number.MAX_SAFE_INTEGER;
+    let sumValues = 0;
+    let totalTicks = 0;
+    let firstTick;
+    let lastTick;
+
+    for (let [tick, value] of Iterator(this._data)) {
+      maxValue = Math.max(value, maxValue);
+      minValue = Math.min(value, minValue);
+      sumValues += value;
+      totalTicks++;
+
+      if (!firstTick) {
+        firstTick = tick;
+      } else {
+        lastTick = tick;
+      }
+    }
+
+    let dataScaleX = this.dataScaleX = width / lastTick;
+    let dataScaleY = this.dataScaleY = height / maxValue * GRAPH_DAMPEN_VALUES;
+
+    /**
+     * Calculates the squared distance between two 2D points.
+     */
+    function distSquared(x0, y0, x1, y1) {
+      let xs = x1 - x0;
+      let ys = y1 - y0;
+      return xs * xs + ys * ys;
+    }
+
+    // Draw the graph.
+
+    let gradient = ctx.createLinearGradient(0, height / 2, 0, height);
+    gradient.addColorStop(0, LINE_GRAPH_BACKGROUND_GRADIENT_START);
+    gradient.addColorStop(1, LINE_GRAPH_BACKGROUND_GRADIENT_END);
+    ctx.fillStyle = gradient;
+    ctx.strokeStyle = LINE_GRAPH_STROKE_COLOR;
+    ctx.lineWidth = LINE_GRAPH_STROKE_WIDTH;
+    ctx.setLineDash([]);
+    ctx.beginPath();
+
+    let prevX = 0;
+    let prevY = 0;
+
+    for (let [tick, value] of Iterator(this._data)) {
+      let currX = tick * dataScaleX;
+      let currY = height - value * dataScaleY;
+
+      if (tick == firstTick) {
+        ctx.moveTo(-LINE_GRAPH_STROKE_WIDTH, height);
+        ctx.lineTo(-LINE_GRAPH_STROKE_WIDTH, currY);
+      }
+
+      let distance = distSquared(prevX, prevY, currX, currY);
+      if (distance > LINE_GRAPH_MIN_SQUARED_DISTANCE_BETWEEN_POINTS) {
+        ctx.lineTo(currX, currY);
+        prevX = currX;
+        prevY = currY;
+      }
+
+      if (tick == lastTick) {
+        ctx.lineTo(width + LINE_GRAPH_STROKE_WIDTH, currY);
+        ctx.lineTo(width + LINE_GRAPH_STROKE_WIDTH, height);
+      }
+    }
+
+    ctx.fill();
+    ctx.stroke();
+
+    // Draw the maximum value horizontal line.
+
+    ctx.strokeStyle = LINE_GRAPH_MAXIMUM_LINE_COLOR;
+    ctx.lineWidth = LINE_GRAPH_HELPER_LINES_WIDTH;
+    ctx.setLineDash(LINE_GRAPH_HELPER_LINES_DASH);
+    ctx.beginPath();
+    let maximumY = height - maxValue * dataScaleY - ctx.lineWidth;
+    ctx.moveTo(0, maximumY);
+    ctx.lineTo(width, maximumY);
+    ctx.stroke();
+
+    // Draw the average value horizontal line.
+
+    ctx.strokeStyle = LINE_GRAPH_AVERAGE_LINE_COLOR;
+    ctx.lineWidth = LINE_GRAPH_HELPER_LINES_WIDTH;
+    ctx.setLineDash(LINE_GRAPH_HELPER_LINES_DASH);
+    ctx.beginPath();
+    let avgValue = sumValues / totalTicks;
+    let averageY = height - avgValue * dataScaleY - ctx.lineWidth;
+    ctx.moveTo(0, averageY);
+    ctx.lineTo(width, averageY);
+    ctx.stroke();
+
+    // Draw the minimum value horizontal line.
+
+    ctx.strokeStyle = LINE_GRAPH_MINIMUM_LINE_COLOR;
+    ctx.lineWidth = LINE_GRAPH_HELPER_LINES_WIDTH;
+    ctx.setLineDash(LINE_GRAPH_HELPER_LINES_DASH);
+    ctx.beginPath();
+    let minimumY = height - minValue * dataScaleY - ctx.lineWidth;
+    ctx.moveTo(0, minimumY);
+    ctx.lineTo(width, minimumY);
+    ctx.stroke();
+
+    // Update the tooltips text and gutter lines.
+
+    this._maxTooltip.querySelector("[text=value]").textContent = maxValue|0;
+    this._avgTooltip.querySelector("[text=value]").textContent = avgValue|0;
+    this._minTooltip.querySelector("[text=value]").textContent = minValue|0;
+
+    /**
+     * Maps a value from one range to another.
+     */
+    function map(value, istart, istop, ostart, ostop) {
+      return ostart + (ostop - ostart) * ((value - istart) / (istop - istart));
+    }
+
+    /**
+     * Constrains a value to a range.
+     */
+    function clamp(value, min, max) {
+      if (value < min) return min;
+      if (value > max) return max;
+      return value;
+    }
+
+    let bottom = height / this._pixelRatio;
+    let maxPosY = map(maxValue * GRAPH_DAMPEN_VALUES, 0, maxValue, bottom, 0);
+    let avgPosY = map(avgValue * GRAPH_DAMPEN_VALUES, 0, maxValue, bottom, 0);
+    let minPosY = map(minValue * GRAPH_DAMPEN_VALUES, 0, maxValue, bottom, 0);
+
+    let safeTop = LINE_GRAPH_TOOLTIP_SAFE_BOUNDS;
+    let safeBottom = bottom - LINE_GRAPH_TOOLTIP_SAFE_BOUNDS;
+
+    this._maxTooltip.style.top = clamp(maxPosY, safeTop, safeBottom) + "px";
+    this._avgTooltip.style.top = clamp(avgPosY, safeTop, safeBottom) + "px";
+    this._minTooltip.style.top = clamp(minPosY, safeTop, safeBottom) + "px";
+    this._maxGutterLine.style.top = clamp(maxPosY, safeTop, safeBottom) + "px";
+    this._avgGutterLine.style.top = clamp(avgPosY, safeTop, safeBottom) + "px";
+    this._minGutterLine.style.top = clamp(minPosY, safeTop, safeBottom) + "px";
+
+    return canvas;
+  },
+
+  /**
+   * Creates the gutter node when constructing this graph.
+   * @return nsIDOMNode
+   */
+  _createGutter: function() {
+    let gutter = this._document.createElementNS(HTML_NS, "div");
+    gutter.className = "line-graph-widget-gutter";
+    this._container.appendChild(gutter);
+
+    return gutter;
+  },
+
+  /**
+   * Creates the gutter line nodes when constructing this graph.
+   * @return nsIDOMNode
+   */
+  _createGutterLine: function(type) {
+    let line = this._document.createElementNS(HTML_NS, "div");
+    line.className = "line-graph-widget-gutter-line";
+    line.setAttribute("type", type);
+    this._gutter.appendChild(line);
+
+    return line;
+  },
+
+  /**
+   * Creates the tooltip nodes when constructing this graph.
+   * @return nsIDOMNode
+   */
+  _createTooltip: function(type, arrow, info, metric) {
+    let tooltip = this._document.createElementNS(HTML_NS, "div");
+    tooltip.className = "line-graph-widget-tooltip";
+    tooltip.setAttribute("type", type);
+    tooltip.setAttribute("arrow", arrow);
+
+    let infoNode = this._document.createElementNS(HTML_NS, "span");
+    infoNode.textContent = info;
+    infoNode.setAttribute("text", "info");
+
+    let valueNode = this._document.createElementNS(HTML_NS, "span");
+    valueNode.textContent = 0;
+    valueNode.setAttribute("text", "value");
+
+    let metricNode = this._document.createElementNS(HTML_NS, "span");
+    metricNode.textContent = metric;
+    metricNode.setAttribute("text", "metric");
+
+    tooltip.appendChild(infoNode);
+    tooltip.appendChild(valueNode);
+    tooltip.appendChild(metricNode);
+    this._container.appendChild(tooltip);
+
+    return tooltip;
+  }
+});
+
+// Helper functions.
+
+/**
+ * Creates an iframe element with the provided source URL, appends it to
+ * the specified node and invokes the callback once the content is loaded.
+ *
+ * @param string url
+ *        The desired source URL for the iframe.
+ * @param nsIDOMNode parent
+ *        The desired parent node for the iframe.
+ * @param function callback
+ *        Invoked once the content is loaded, with the iframe as an argument.
+ */
+AbstractCanvasGraph.createIframe = function(url, parent, callback) {
+  let iframe = parent.ownerDocument.createElementNS(HTML_NS, "iframe");
+
+  iframe.addEventListener("DOMContentLoaded", function onLoad() {
+    iframe.removeEventListener("DOMContentLoaded", onLoad);
+    callback(iframe);
+  });
+
+  iframe.setAttribute("frameborder", "0");
+  iframe.src = url;
+
+  parent.appendChild(iframe);
+};
+
+/**
+ * Gets a striped pattern used as a background in selections and regions.
+ *
+ * @param object data
+ *        The following properties are required:
+ *          - ownerDocument: the nsIDocumentElement owning the canvas
+ *          - backgroundColor: a string representing the fill style
+ *          - stripesColor: a string representing the stroke style
+ * @return nsIDOMCanvasPattern
+ *         The custom striped pattern.
+ */
+AbstractCanvasGraph.getStripePattern = function(data) {
+  let { ownerDocument, backgroundColor, stripesColor } = data;
+  let id = [backgroundColor, stripesColor].join(",");
+
+  if (gCachedStripePattern.has(id)) {
+    return gCachedStripePattern.get(id);
+  }
+
+  let canvas = ownerDocument.createElementNS(HTML_NS, "canvas");
+  let ctx = canvas.getContext("2d");
+  let width = canvas.width = GRAPH_STRIPE_PATTERN_WIDTH;
+  let height = canvas.height = GRAPH_STRIPE_PATTERN_HEIGHT;
+
+  ctx.fillStyle = backgroundColor;
+  ctx.fillRect(0, 0, width, height);
+
+  ctx.strokeStyle = stripesColor;
+  ctx.lineWidth = GRAPH_STRIPE_PATTERN_LINE_WIDTH;
+  ctx.lineCap = "square";
+  ctx.beginPath();
+
+  for (let i = -height; i <= height; i += GRAPH_STRIPE_PATTERN_LINE_SPACING) {
+    ctx.moveTo(width, i);
+    ctx.lineTo(0, i + height);
+  }
+
+  ctx.stroke();
+
+  let pattern = ctx.createPattern(canvas, "repeat");
+  gCachedStripePattern.set(id, pattern);
+  return pattern;
+};
+
+/**
+ * Cache used by `AbstractCanvasGraph.getStripePattern`.
+ */
+const gCachedStripePattern = new Map();
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/widgets/graphs-frame.xhtml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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/. -->
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+  <link rel="stylesheet" href="chrome://browser/skin/devtools/common.css" type="text/css"/>
+  <link rel="stylesheet" href="chrome://browser/skin/devtools/widgets.css" ype="text/css"/>
+  <script type="application/javascript;version=1.8" src="theme-switching.js"/>
+  <style>
+    body {
+      overflow: hidden;
+      margin: 0;
+      padding: 0;
+    }
+  </style>
+</head>
+<body role="application">
+  <div id="graph-container">
+    <canvas id="graph-canvas"></canvas>
+  </div>
+</body>
+</html>
--- a/browser/themes/shared/devtools/widgets.inc.css
+++ b/browser/themes/shared/devtools/widgets.inc.css
@@ -903,16 +903,164 @@
 .arrow[open] {
   -moz-appearance: treetwistyopen;
 }
 
 .arrow[invisible] {
   visibility: hidden;
 }
 
+/* Canvas graphs */
+
+.graph-widget-canvas {
+  width: 100%;
+  height: 100%;
+}
+
+.graph-widget-canvas[input=hovering-background] {
+  cursor: text;
+}
+
+.graph-widget-canvas[input=hovering-region] {
+  cursor: pointer;
+}
+
+.graph-widget-canvas[input=hovering-selection-start-boundary],
+.graph-widget-canvas[input=hovering-selection-end-boundary],
+.graph-widget-canvas[input=adjusting-selection-boundary] {
+  cursor: col-resize;
+}
+
+.graph-widget-canvas[input=hovering-selection-contents] {
+  cursor: grab;
+}
+
+.graph-widget-canvas[input=dragging-selection-contents] {
+  cursor: grabbing;
+}
+
+.graph-widget-canvas ~ * {
+  pointer-events: none;
+}
+
+/* Line graph widget */
+
+.line-graph-widget-container {
+  position: relative;
+}
+
+.line-graph-widget-canvas {
+  background: #0088cc;
+}
+
+.line-graph-widget-gutter {
+  position: absolute;
+  background: rgba(255,255,255,0.75);
+  width: 10px;
+  height: 100%;
+  top: 0;
+  left: 0;
+  border-right: 1px solid rgba(255,255,255,0.25);
+}
+
+.line-graph-widget-gutter-line {
+  position: absolute;
+  width: 100%;
+  border-top: 1px solid;
+  transform: translateY(-1px);
+}
+
+.line-graph-widget-gutter-line[type=maximum] {
+  border-color: #2cbb0f;
+}
+
+.line-graph-widget-gutter-line[type=minimum] {
+  border-color: #ed2655;
+}
+
+.line-graph-widget-gutter-line[type=average] {
+  border-color: #d97e00;
+}
+
+.line-graph-widget-tooltip {
+  position: absolute;
+  background: rgba(255,255,255,0.75);
+  box-shadow: 0 2px 1px rgba(0,0,0,0.1);
+  border-radius: 2px;
+  line-height: 15px;
+  -moz-padding-start: 6px;
+  -moz-padding-end: 6px;
+  transform: translateY(-50%);
+  font-size: 80%;
+  z-index: 1;
+}
+
+.line-graph-widget-tooltip::before {
+  content: "";
+  position: absolute;
+  border-top: 3px solid transparent;
+  border-bottom: 3px solid transparent;
+  top: calc(50% - 3px);
+}
+
+.line-graph-widget-tooltip[arrow=start]::before {
+  -moz-border-end: 3px solid rgba(255,255,255,0.75);
+  left: -3px;
+}
+
+.line-graph-widget-tooltip[arrow=end]::before {
+  -moz-border-start: 3px solid rgba(255,255,255,0.75);
+  right: -3px;
+}
+
+.line-graph-widget-tooltip[type=maximum] {
+  left: calc(10px + 6px);
+}
+
+.line-graph-widget-tooltip[type=minimum] {
+  left: calc(10px + 6px);
+}
+
+.line-graph-widget-tooltip[type=average] {
+  right: 6px;
+}
+
+.line-graph-widget-tooltip > [text=info] {
+  color: #18191a;
+}
+
+.line-graph-widget-tooltip > [text=value] {
+  -moz-margin-start: 3px;
+}
+
+.line-graph-widget-tooltip > [text=metric] {
+  -moz-margin-start: 1px;
+  color: #667380;
+}
+
+.line-graph-widget-tooltip > [text=value],
+.line-graph-widget-tooltip > [text=metric] {
+  text-shadow: 1px  0px rgba(255,255,255,0.6),
+              -1px  0px rgba(255,255,255,0.6),
+               0px -1px rgba(255,255,255,0.6),
+               0px  1px rgba(255,255,255,0.6);
+}
+
+.line-graph-widget-tooltip[type=maximum] > [text=value] {
+  color: #2cbb0f;
+}
+
+.line-graph-widget-tooltip[type=minimum] > [text=value] {
+  color: #ed2655;
+}
+
+.line-graph-widget-tooltip[type=average] > [text=value] {
+  color: #d97e00;
+}
+
 /* Charts */
 
 .generic-chart-container {
   /* Hack: force hardware acceleration */
   transform: translateZ(1px);
 }
 
 .theme-dark .generic-chart-container {
--- a/toolkit/devtools/server/actors/framerate.js
+++ b/toolkit/devtools/server/actors/framerate.js
@@ -77,17 +77,17 @@ let FramerateActor = exports.FramerateAc
 
     let pivotTick = 0;
     let lastTick = ticks[totalTicks - 1];
 
     for (let bucketTime = resolution; bucketTime < lastTick; bucketTime += resolution) {
       let frameCount = 0;
       while (ticks[pivotTick++] < bucketTime) frameCount++;
 
-      let framerate = 1000 / (bucketTime / frameCount);
+      let framerate = 1000 / (resolution / frameCount);
       timeline[bucketTime] = framerate;
     }
 
     return timeline;
   }, {
     request: { resolution: Arg(0, "nullable:number") },
     response: { timeline: RetVal("json") }
   }),