Files
tubestation/browser/devtools/shared/widgets/FlameGraph.jsm

1024 lines
32 KiB
JavaScript

/* 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;
}
};