summaryrefslogtreecommitdiff
path: root/toolkit/devtools/shared/widgets/FlameGraph.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/devtools/shared/widgets/FlameGraph.jsm')
-rw-r--r--toolkit/devtools/shared/widgets/FlameGraph.jsm1023
1 files changed, 1023 insertions, 0 deletions
diff --git a/toolkit/devtools/shared/widgets/FlameGraph.jsm b/toolkit/devtools/shared/widgets/FlameGraph.jsm
new file mode 100644
index 000000000..208e2e3d2
--- /dev/null
+++ b/toolkit/devtools/shared/widgets/FlameGraph.jsm
@@ -0,0 +1,1023 @@
+/* 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_RESIZE_EVENTS_DRAIN = 100; // ms
+
+const GRAPH_WHEEL_ZOOM_SENSITIVITY = 0.00035;
+const GRAPH_WHEEL_SCROLL_SENSITIVITY = 0.5;
+const GRAPH_MIN_SELECTION_WIDTH = 0.001; // ms
+
+const TIMELINE_TICKS_MULTIPLE = 5; // ms
+const TIMELINE_TICKS_SPACING_MIN = 75; // px
+
+const OVERVIEW_HEADER_HEIGHT = 16; // 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 = 8; // px
+const FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY = "sans-serif";
+const FLAME_GRAPH_BLOCK_TEXT_PADDING_TOP = 0; // 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);
+ * graph.once("ready", () => {
+ * let data = FlameGraphUtils.createFlameGraphDataFromSamples(samples);
+ * let bounds = { startTime, endTime };
+ * graph.setData({ data, bounds });
+ * });
+ *
+ * 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._bounds = new GraphSelection();
+ 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._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._onResize = this._onResize.bind(this);
+ this.refresh = this.refresh.bind(this);
+
+ this._window.addEventListener("mousemove", this._onMouseMove);
+ this._window.addEventListener("mousedown", this._onMouseDown);
+ this._window.addEventListener("mouseup", this._onMouseUp);
+ this._window.addEventListener("MozMousePixelScroll", this._onMouseWheel);
+
+ let ownerWindow = this._parent.ownerDocument.defaultView;
+ ownerWindow.addEventListener("resize", this._onResize);
+
+ 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() {
+ this._window.removeEventListener("mousemove", this._onMouseMove);
+ this._window.removeEventListener("mousedown", this._onMouseDown);
+ this._window.removeEventListener("mouseup", this._onMouseUp);
+ this._window.removeEventListener("MozMousePixelScroll", this._onMouseWheel);
+
+ let ownerWindow = this._parent.ownerDocument.defaultView;
+ ownerWindow.removeEventListener("resize", this._onResize);
+
+ this._window.cancelAnimationFrame(this._animationId);
+ this._iframe.remove();
+
+ this._bounds = null;
+ this._selection = null;
+ this._selectionDragger = null;
+ this._textWidthsCache = 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
+ * An object containing the following properties:
+ * - data: the data source; see the constructor for more info
+ * - bounds: the minimum/maximum { start, end }, in ms or px
+ * - visible: optional, the shown { start, end }, in ms or px
+ */
+ setData: function({ data, bounds, visible }) {
+ this._data = data;
+ this.setOuterBounds(bounds);
+ this.setViewRange(visible || bounds);
+ },
+
+ /**
+ * 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 whether or not this graph has a data source.
+ * @return boolean
+ */
+ hasData: function() {
+ return !!this._data;
+ },
+
+ /**
+ * Sets the maximum selection (i.e. the 'graph bounds').
+ * @param object { start, end }
+ */
+ setOuterBounds: function({ startTime, endTime }) {
+ this._bounds.start = startTime * this._pixelRatio;
+ this._bounds.end = endTime * this._pixelRatio;
+ this._shouldRedraw = true;
+ },
+
+ /**
+ * Sets the selection (i.e. the 'view range') bounds.
+ * @return number
+ */
+ setViewRange: function({ startTime, endTime }) {
+ this._selection.start = startTime * this._pixelRatio;
+ this._selection.end = endTime * this._pixelRatio;
+ this._shouldRedraw = true;
+ },
+
+ /**
+ * Gets the maximum selection (i.e. the 'graph bounds').
+ * @return number
+ */
+ getOuterBounds: function() {
+ return {
+ startTime: this._bounds.start / this._pixelRatio,
+ endTime: this._bounds.end / this._pixelRatio
+ };
+ },
+
+ /**
+ * Gets the current selection (i.e. the 'view range').
+ * @return number
+ */
+ getViewRange: function() {
+ return {
+ startTime: this._selection.start / this._pixelRatio,
+ endTime: this._selection.end / this._pixelRatio
+ };
+ },
+
+ /**
+ * Updates this graph to reflect the new dimensions of the parent node.
+ */
+ refresh: function() {
+ let bounds = this._parent.getBoundingClientRect();
+ let newWidth = this.fixedWidth || bounds.width;
+ let newHeight = this.fixedHeight || bounds.height;
+
+ // Prevent redrawing everything if the graph's width & height won't change.
+ if (this._width == newWidth * this._pixelRatio &&
+ this._height == newHeight * this._pixelRatio) {
+ this.emit("refresh-cancelled");
+ return;
+ }
+
+ bounds.width = newWidth;
+ bounds.height = newHeight;
+ 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;
+
+ 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;
+ 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 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 = -scaledOffset % tickInterval; x < canvasWidth; x += tickInterval) {
+ let lineLeft = x;
+ let textLeft = lineLeft + textPaddingLeft;
+ let time = Math.round((x / dataScale + dataOffset) / 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;
+ this.emit("selecting");
+ }
+ },
+
+ /**
+ * 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;
+ this.emit("selecting");
+ },
+
+ /**
+ * 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 boundsStart = this._bounds.start;
+ let boundsEnd = this._bounds.end;
+ let selectionStart = this._selection.start;
+ let selectionEnd = this._selection.end;
+
+ if (selectionStart < boundsStart) {
+ selectionStart = boundsStart;
+ }
+ if (selectionEnd < boundsStart) {
+ selectionStart = boundsStart;
+ selectionEnd = GRAPH_MIN_SELECTION_WIDTH;
+ }
+ if (selectionEnd > boundsEnd) {
+ selectionEnd = boundsEnd;
+ }
+ if (selectionStart > boundsEnd) {
+ selectionEnd = boundsEnd;
+ selectionStart = boundsEnd - GRAPH_MIN_SELECTION_WIDTH;
+ }
+ if (selectionEnd - selectionStart < GRAPH_MIN_SELECTION_WIDTH) {
+ let midPoint = (selectionStart + selectionEnd) / 2;
+ selectionStart = midPoint - GRAPH_MIN_SELECTION_WIDTH / 2;
+ selectionEnd = midPoint + GRAPH_MIN_SELECTION_WIDTH / 2;
+ }
+
+ this._selection.start = selectionStart;
+ this._selection.end = selectionEnd;
+ },
+
+ /**
+ *
+ * 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 };
+ },
+
+ /**
+ * Listener for the "resize" event on the graph's parent node.
+ */
+ _onResize: function() {
+ if (this.hasData()) {
+ setNamedTimeout(this._uid, GRAPH_RESIZE_EVENTS_DRAIN, this.refresh);
+ }
+ }
+};
+
+const FLAME_GRAPH_BLOCK_HEIGHT = 11; // px
+
+const PALLETTE_SIZE = 10;
+const PALLETTE_HUE_OFFSET = Math.random() * 90;
+const PALLETTE_HUE_RANGE = 270;
+const PALLETTE_SATURATION = 60;
+const PALLETTE_BRIGHTNESS = 75;
+const PALLETTE_OPACITY = 0.7;
+
+const COLOR_PALLETTE = Array.from(Array(PALLETTE_SIZE)).map((_, i) => "hsla" +
+ "(" + ((PALLETTE_HUE_OFFSET + (i / PALLETTE_SIZE * PALLETTE_HUE_RANGE))|0 % 360) +
+ "," + PALLETTE_SATURATION + "%" +
+ "," + PALLETTE_BRIGHTNESS + "%" +
+ "," + PALLETTE_OPACITY +
+ ")"
+);
+
+/**
+ * A collection of utility functions converting various data sources
+ * into a format drawable by the FlameGraph.
+ */
+let FlameGraphUtils = {
+ _cache: new WeakMap(),
+
+ /**
+ * Converts a list of samples from the profiler data to something that's
+ * drawable by a FlameGraph widget.
+ *
+ * The outputted data will be cached, so the next time this method is called
+ * the previous output is returned. If this is undesirable, or should the
+ * options change, use `removeFromCache`.
+ *
+ * @param array samples
+ * A list of { time, frames: [{ location }] } objects.
+ * @param object options [optional]
+ * Additional options supported by this operation:
+ * - invertStack: specifies if the frames array in every sample
+ * should be reversed
+ * - flattenRecursion: specifies if identical consecutive frames
+ * should be omitted from the output
+ * - filterFrames: predicate used for filtering all frames, passing
+ * in each frame, its index and the sample array
+ * - showIdleBlocks: adds "idle" blocks when no frames are available
+ * using the provided localized text
+ * @param array out [optional]
+ * An output storage to reuse for storing the flame graph data.
+ * @return array
+ * The flame graph data.
+ */
+ createFlameGraphDataFromSamples: function(samples, options = {}, out = []) {
+ let cached = this._cache.get(samples);
+ if (cached) {
+ return cached;
+ }
+
+ // 1. Create a map of colors to arrays, representing buckets of
+ // blocks inside the flame graph pyramid sharing the same style.
+
+ let buckets = new Map();
+
+ for (let color of COLOR_PALLETTE) {
+ buckets.set(color, []);
+ }
+
+ // 2. Populate the buckets by iterating over every frame in every sample.
+
+ let prevTime = 0;
+ let prevFrames = [];
+
+ for (let { frames, time } of samples) {
+ let frameIndex = 0;
+
+ // Flatten recursion if preferred, by removing consecutive frames
+ // sharing the same location.
+ if (options.flattenRecursion) {
+ frames = frames.filter(this._isConsecutiveDuplicate);
+ }
+
+ // Apply a provided filter function. This can be used, for example, to
+ // filter out platform frames if only content-related function calls
+ // should be taken into consideration.
+ if (options.filterFrames) {
+ frames = frames.filter(options.filterFrames);
+ }
+
+ // Invert the stack if preferred, reversing the frames array in place.
+ if (options.invertStack) {
+ frames.reverse();
+ }
+
+ // If no frames are available, add a pseudo "idle" block in between.
+ if (options.showIdleBlocks && frames.length == 0) {
+ frames = [{ location: options.showIdleBlocks || "" }];
+ }
+
+ for (let { location } of frames) {
+ let prevFrame = prevFrames[frameIndex];
+
+ // Frames at the same location and the same depth will be reused.
+ // If there is a block already created, change its width.
+ if (prevFrame && prevFrame.srcData.rawLocation == location) {
+ prevFrame.width = (time - prevFrame.srcData.startTime);
+ }
+ // Otherwise, create a new block for this frame at this depth,
+ // using a simple location based salt for picking a color.
+ else {
+ let hash = this._getStringHash(location);
+ let color = COLOR_PALLETTE[hash % PALLETTE_SIZE];
+ let bucket = buckets.get(color);
+
+ bucket.push(prevFrames[frameIndex] = {
+ srcData: { startTime: prevTime, rawLocation: location },
+ x: prevTime,
+ y: frameIndex * FLAME_GRAPH_BLOCK_HEIGHT,
+ width: time - prevTime,
+ height: FLAME_GRAPH_BLOCK_HEIGHT,
+ text: location
+ });
+ }
+
+ frameIndex++;
+ }
+
+ // Previous frames at stack depths greater than the current sample's
+ // maximum need to be nullified. It's nonsensical to reuse them.
+ prevFrames.length = frameIndex;
+ prevTime = time;
+ }
+
+ // 3. Convert the buckets into a data source usable by the FlameGraph.
+ // This is a simple conversion from a Map to an Array.
+
+ for (let [color, blocks] of buckets) {
+ out.push({ color, blocks });
+ }
+
+ this._cache.set(samples, out);
+ return out;
+ },
+
+ /**
+ * Clears the cached flame graph data created for the given source.
+ * @param any source
+ */
+ removeFromCache: function(source) {
+ this._cache.delete(source);
+ },
+
+ /**
+ * Checks if the provided frame is the same as the next one in a sample.
+ *
+ * @param object e
+ * An object containing a { location } property.
+ * @param number index
+ * The index of the object in the parent array.
+ * @param array array
+ * The parent array.
+ * @return boolean
+ * True if the next frame shares the same location, false otherwise.
+ */
+ _isConsecutiveDuplicate: function(e, index, array) {
+ return index < array.length - 1 && e.location != array[index + 1].location;
+ },
+
+ /**
+ * Very dumb hashing of a string. Used to pick colors from a pallette.
+ *
+ * @param string input
+ * @return number
+ */
+ _getStringHash: function(input) {
+ const STRING_HASH_PRIME1 = 7;
+ const STRING_HASH_PRIME2 = 31;
+
+ let hash = STRING_HASH_PRIME1;
+
+ for (let i = 0, len = input.length; i < len; i++) {
+ hash *= STRING_HASH_PRIME2;
+ hash += input.charCodeAt(i);
+
+ if (hash > Number.MAX_SAFE_INTEGER / STRING_HASH_PRIME2) {
+ return hash;
+ }
+ }
+
+ return hash;
+ }
+};