mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-02 07:05:24 +00:00
1261 lines
41 KiB
JavaScript
1261 lines
41 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 { Task } = require("resource://gre/modules/Task.jsm");
|
|
const { ViewHelpers } = require("resource://devtools/client/shared/widgets/ViewHelpers.jsm");
|
|
const { setNamedTimeout, clearNamedTimeout } = require("resource://devtools/client/shared/widgets/ViewHelpers.jsm");
|
|
|
|
loader.lazyRequireGetter(this, "promise");
|
|
loader.lazyRequireGetter(this, "EventEmitter",
|
|
"devtools/shared/event-emitter");
|
|
|
|
loader.lazyRequireGetter(this, "getColor",
|
|
"devtools/client/shared/theme", true);
|
|
|
|
loader.lazyRequireGetter(this, "CATEGORY_MAPPINGS",
|
|
"devtools/client/performance/modules/global", true);
|
|
loader.lazyRequireGetter(this, "FrameUtils",
|
|
"devtools/client/performance/modules/logic/frame-utils");
|
|
|
|
loader.lazyRequireGetter(this, "AbstractCanvasGraph",
|
|
"devtools/client/shared/widgets/Graphs", true);
|
|
loader.lazyRequireGetter(this, "GraphArea",
|
|
"devtools/client/shared/widgets/Graphs", true);
|
|
loader.lazyRequireGetter(this, "GraphAreaDragger",
|
|
"devtools/client/shared/widgets/Graphs", true);
|
|
|
|
const HTML_NS = "http://www.w3.org/1999/xhtml";
|
|
const GRAPH_SRC = "chrome://devtools/content/shared/widgets/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 GRAPH_HORIZONTAL_PAN_THRESHOLD = 10; // px
|
|
const GRAPH_VERTICAL_PAN_THRESHOLD = 30; // px
|
|
|
|
const FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS = 100;
|
|
const TIMELINE_TICKS_MULTIPLE = 5; // ms
|
|
const TIMELINE_TICKS_SPACING_MIN = 75; // px
|
|
|
|
const OVERVIEW_HEADER_HEIGHT = 16; // px
|
|
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_HEADER_TIMELINE_STROKE_COLOR = "rgba(128, 128, 128, 0.5)";
|
|
|
|
const FLAME_GRAPH_BLOCK_HEIGHT = 15; // px
|
|
const FLAME_GRAPH_BLOCK_BORDER = 1; // px
|
|
const FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE = 10; // px
|
|
const FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY = "message-box, Helvetica Neue, Helvetica, 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
|
|
|
|
const PALLETTE_SIZE = 20; // Large enough number for a diverse pallette.
|
|
const PALLETTE_HUE_OFFSET = Math.random() * 90;
|
|
const PALLETTE_HUE_RANGE = 270;
|
|
const PALLETTE_SATURATION = 100;
|
|
const PALLETTE_BRIGHTNESS = 55;
|
|
const PALLETTE_OPACITY = 0.35;
|
|
|
|
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 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.createFlameGraphDataFromThread(thread);
|
|
* 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();
|
|
|
|
this.setTheme();
|
|
|
|
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 GraphArea();
|
|
this._selection = new GraphArea();
|
|
this._selectionDragger = new GraphAreaDragger();
|
|
this._verticalOffset = 0;
|
|
this._verticalOffsetDragger = new GraphAreaDragger(0);
|
|
|
|
// 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: Task.async(function*() {
|
|
yield this.ready();
|
|
|
|
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;
|
|
if (ownerWindow) {
|
|
ownerWindow.removeEventListener("resize", this._onResize);
|
|
}
|
|
|
|
this._window.cancelAnimationFrame(this._animationId);
|
|
this._iframe.remove();
|
|
|
|
this._bounds = null;
|
|
this._selection = null;
|
|
this._selectionDragger = null;
|
|
this._verticalOffset = null;
|
|
this._verticalOffsetDragger = null;
|
|
this._textWidthsCache = null;
|
|
|
|
this._data = null;
|
|
|
|
this.emit("destroyed");
|
|
}),
|
|
|
|
/**
|
|
* 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,
|
|
|
|
/**
|
|
* How much preliminar drag is necessary to determine the panning direction.
|
|
*/
|
|
horizontalPanThreshold: GRAPH_HORIZONTAL_PAN_THRESHOLD,
|
|
verticalPanThreshold: GRAPH_VERTICAL_PAN_THRESHOLD,
|
|
|
|
/**
|
|
* 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 and vertical offset (i.e. the 'view range').
|
|
* @return number
|
|
*/
|
|
setViewRange: function({ startTime, endTime }, verticalOffset = 0) {
|
|
this._selection.start = startTime * this._pixelRatio;
|
|
this._selection.end = endTime * this._pixelRatio;
|
|
this._verticalOffset = verticalOffset * 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 and vertical offset (i.e. the 'view range').
|
|
* @return number
|
|
*/
|
|
getViewRange: function() {
|
|
return {
|
|
startTime: this._selection.start / this._pixelRatio,
|
|
endTime: this._selection.end / this._pixelRatio,
|
|
verticalOffset: this._verticalOffset / this._pixelRatio
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Updates this graph to reflect the new dimensions of the parent node.
|
|
*
|
|
* @param boolean options.force
|
|
* Force redraw everything.
|
|
*/
|
|
refresh: function(options={}) {
|
|
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,
|
|
// except if force=true.
|
|
if (!options.force &&
|
|
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");
|
|
},
|
|
|
|
/**
|
|
* Sets the theme via `theme` to either "light" or "dark",
|
|
* and updates the internal styling to match. Requires a redraw
|
|
* to see the effects.
|
|
*/
|
|
setTheme: function (theme) {
|
|
theme = theme || "light";
|
|
this.overviewHeaderBackgroundColor = getColor("body-background", theme);
|
|
this.overviewHeaderTextColor = getColor("body-color", theme);
|
|
// Hard to get a color that is readable across both themes for the text on the flames
|
|
this.blockTextColor = getColor(theme === "dark" ? "selection-color" : "body-color", theme);
|
|
},
|
|
|
|
/**
|
|
* 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, this._verticalOffset, selection.start, selectionScale);
|
|
this._drawHeader(selection.start, selectionScale);
|
|
|
|
this._shouldRedraw = false;
|
|
},
|
|
|
|
/**
|
|
* Draws the overhead header, with time markers and 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.
|
|
*/
|
|
_drawHeader: function(dataOffset, dataScale) {
|
|
let ctx = this._ctx;
|
|
let canvasWidth = this._width;
|
|
let headerHeight = OVERVIEW_HEADER_HEIGHT * this._pixelRatio;
|
|
|
|
ctx.fillStyle = this.overviewHeaderBackgroundColor;
|
|
ctx.fillRect(0, 0, canvasWidth, headerHeight);
|
|
|
|
this._drawTicks(dataOffset, dataScale, { from: 0, to: headerHeight, renderText: true });
|
|
},
|
|
|
|
/**
|
|
* Draws the overhead ticks in this graph in the flame graph area.
|
|
*
|
|
* @param number dataOffset, dataScale, from, to, renderText
|
|
* Offsets and scales the data source by the specified amount.
|
|
* from and to determine the Y position of how far the stroke
|
|
* should be drawn.
|
|
* This is used when scrolling the visualization.
|
|
*/
|
|
_drawTicks: function(dataOffset, dataScale, options) {
|
|
let { from, to, renderText } = options || {};
|
|
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 = OVERVIEW_HEADER_TIMELINE_STROKE_COLOR;
|
|
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;
|
|
if (renderText) {
|
|
ctx.fillText(label, textLeft, textPaddingTop);
|
|
}
|
|
ctx.moveTo(lineLeft, from || 0);
|
|
ctx.lineTo(lineLeft, to || 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 verticalOffset
|
|
* Offsets the drawing vertically by the specified amount.
|
|
* @param number dataOffset, dataScale
|
|
* Offsets and scales the data source by the specified amount.
|
|
* This is used for scrolling the visualization.
|
|
*/
|
|
_drawPyramid: function(dataSource, verticalOffset, 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 visibleBlocksInfo = this._drawPyramidFill(dataSource, verticalOffset, dataOffset, dataScale);
|
|
|
|
ctx.textBaseline = "middle";
|
|
ctx.font = fontSize + "px " + fontFamily;
|
|
ctx.fillStyle = this.blockTextColor;
|
|
|
|
this._drawPyramidText(visibleBlocksInfo, verticalOffset, dataOffset, dataScale);
|
|
},
|
|
|
|
/**
|
|
* Fills all block inside this graph's pyramid.
|
|
* @see FlameGraph.prototype._drawPyramid
|
|
*/
|
|
_drawPyramidFill: function(dataSource, verticalOffset, dataOffset, dataScale) {
|
|
let visibleBlocksInfoStore = [];
|
|
let minVisibleBlockWidth = this._overflowCharWidth;
|
|
|
|
for (let { color, blocks } of dataSource) {
|
|
this._drawBlocksFill(
|
|
color, blocks, verticalOffset, dataOffset, dataScale,
|
|
visibleBlocksInfoStore, minVisibleBlockWidth);
|
|
}
|
|
|
|
return visibleBlocksInfoStore;
|
|
},
|
|
|
|
/**
|
|
* Adds the text for all block inside this graph's pyramid.
|
|
* @see FlameGraph.prototype._drawPyramid
|
|
*/
|
|
_drawPyramidText: function(blocksInfo, verticalOffset, dataOffset, dataScale) {
|
|
for (let { block, rect } of blocksInfo) {
|
|
this._drawBlockText(block, rect, verticalOffset, 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 verticalOffset
|
|
* Offsets the drawing vertically by the specified amount.
|
|
* @param number dataOffset, dataScale
|
|
* Offsets and scales the data source by the specified amount.
|
|
* This is used for scrolling the visualization.
|
|
* @param array visibleBlocksInfoStore
|
|
* An array to store all the visible blocks into, along with the
|
|
* final baked coordinates and dimensions, after drawing them.
|
|
* The provided array will be populated.
|
|
* @param number minVisibleBlockWidth
|
|
* The minimum width of the blocks that will be added into
|
|
* the `visibleBlocksInfoStore`.
|
|
*/
|
|
_drawBlocksFill: function(
|
|
color, blocks, verticalOffset, dataOffset, dataScale,
|
|
visibleBlocksInfoStore, 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 - verticalOffset + 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.
|
|
rectTop < -rectHeight) { // Too far top.
|
|
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) {
|
|
visibleBlocksInfoStore.push({
|
|
block: block,
|
|
rect: { rectLeft, rectTop, rectWidth, rectHeight }
|
|
});
|
|
}
|
|
}
|
|
|
|
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 object rect
|
|
* A single { rectLeft, rectTop, rectWidth, rectHeight } object
|
|
* representing the final baked coordinates of the drawn rectangle.
|
|
* Think of them as screen-space values, vs. object-space values. These
|
|
* differ from the scalars in `block` when the graph is scaled/panned.
|
|
* @param number verticalOffset
|
|
* Offsets the drawing vertically by the specified amount.
|
|
* @param number dataOffset, dataScale
|
|
* Offsets and scales the data source by the specified amount.
|
|
* This is used for scrolling the visualization.
|
|
*/
|
|
_drawBlockText: function(block, rect, verticalOffset, dataOffset, dataScale) {
|
|
let ctx = this._ctx;
|
|
let scaledOffset = dataOffset * dataScale;
|
|
|
|
let { x, y, width, height, text } = block;
|
|
let { rectLeft, rectTop, rectWidth, rectHeight } = rect;
|
|
|
|
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;
|
|
|
|
// 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 = rectTop + rectHeight / 2 + 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 {mouseX, mouseY} = this._getRelativeEventCoordinates(e);
|
|
|
|
let canvasWidth = this._width;
|
|
let canvasHeight = this._height;
|
|
|
|
let selection = this._selection;
|
|
let selectionWidth = selection.end - selection.start;
|
|
let selectionScale = canvasWidth / selectionWidth;
|
|
|
|
let horizDrag = this._selectionDragger;
|
|
let vertDrag = this._verticalOffsetDragger;
|
|
|
|
// Avoid dragging both horizontally and vertically at the same time,
|
|
// as this doesn't feel natural. Based on a minimum distance, enable either
|
|
// one, and remember the drag direction to offset the mouse coords later.
|
|
if (!this._horizontalDragEnabled && !this._verticalDragEnabled) {
|
|
let horizDiff = Math.abs(horizDrag.origin - mouseX);
|
|
if (horizDiff > this.horizontalPanThreshold) {
|
|
this._horizontalDragDirection = Math.sign(horizDrag.origin - mouseX);
|
|
this._horizontalDragEnabled = true;
|
|
}
|
|
let vertDiff = Math.abs(vertDrag.origin - mouseY);
|
|
if (vertDiff > this.verticalPanThreshold) {
|
|
this._verticalDragDirection = Math.sign(vertDrag.origin - mouseY);
|
|
this._verticalDragEnabled = true;
|
|
}
|
|
}
|
|
|
|
if (horizDrag.origin != null && this._horizontalDragEnabled) {
|
|
let relativeX = mouseX + this._horizontalDragDirection * this.horizontalPanThreshold;
|
|
selection.start = horizDrag.anchor.start + (horizDrag.origin - relativeX) / selectionScale;
|
|
selection.end = horizDrag.anchor.end + (horizDrag.origin - relativeX) / selectionScale;
|
|
this._normalizeSelectionBounds();
|
|
this._shouldRedraw = true;
|
|
this.emit("selecting");
|
|
}
|
|
|
|
if (vertDrag.origin != null && this._verticalDragEnabled) {
|
|
let relativeY = mouseY + this._verticalDragDirection * this.verticalPanThreshold;
|
|
this._verticalOffset = vertDrag.anchor + (vertDrag.origin - relativeY) / this._pixelRatio;
|
|
this._normalizeVerticalOffset();
|
|
this._shouldRedraw = true;
|
|
this.emit("panning-vertically");
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Listener for the "mousedown" event on the graph's container.
|
|
*/
|
|
_onMouseDown: function(e) {
|
|
let {mouseX, mouseY} = this._getRelativeEventCoordinates(e);
|
|
|
|
this._selectionDragger.origin = mouseX;
|
|
this._selectionDragger.anchor.start = this._selection.start;
|
|
this._selectionDragger.anchor.end = this._selection.end;
|
|
|
|
this._verticalOffsetDragger.origin = mouseY;
|
|
this._verticalOffsetDragger.anchor = this._verticalOffset;
|
|
|
|
this._horizontalDragEnabled = false;
|
|
this._verticalDragEnabled = false;
|
|
|
|
this._canvas.setAttribute("input", "adjusting-view-area");
|
|
},
|
|
|
|
/**
|
|
* Listener for the "mouseup" event on the graph's container.
|
|
*/
|
|
_onMouseUp: function() {
|
|
this._selectionDragger.origin = null;
|
|
this._verticalOffsetDragger.origin = null;
|
|
this._horizontalDragEnabled = false;
|
|
this._horizontalDragDirection = 0;
|
|
this._verticalDragEnabled = false;
|
|
this._verticalDragDirection = 0;
|
|
this._canvas.removeAttribute("input");
|
|
},
|
|
|
|
/**
|
|
* Listener for the "wheel" event on the graph's container.
|
|
*/
|
|
_onMouseWheel: function(e) {
|
|
let {mouseX} = this._getRelativeEventCoordinates(e);
|
|
|
|
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;
|
|
},
|
|
|
|
/**
|
|
* Makes sure that the current vertical offset is within the allowed
|
|
* panning range.
|
|
*/
|
|
_normalizeVerticalOffset: function() {
|
|
this._verticalOffset = Math.max(this._verticalOffset, 0);
|
|
},
|
|
|
|
/**
|
|
*
|
|
* 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;
|
|
let maxIters = FIND_OPTIMAL_TICK_INTERVAL_MAX_ITERS;
|
|
let numIters = 0;
|
|
|
|
if (dataScale > spacingMin) {
|
|
return dataScale;
|
|
}
|
|
|
|
while (true) {
|
|
let scaledStep = dataScale * timingStep;
|
|
if (++numIters > maxIters) {
|
|
return scaledStep;
|
|
}
|
|
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 };
|
|
},
|
|
|
|
/**
|
|
* Given a MouseEvent, make it relative to this._canvas.
|
|
* @return object {mouseX,mouseY}
|
|
*/
|
|
_getRelativeEventCoordinates: function(e) {
|
|
// For ease of testing, testX and testY can be passed in as the event
|
|
// object.
|
|
if ("testX" in e && "testY" in e) {
|
|
return {
|
|
mouseX: e.testX * this._pixelRatio,
|
|
mouseY: e.testY * this._pixelRatio
|
|
};
|
|
}
|
|
|
|
let offset = this._getContainerOffset();
|
|
let mouseX = (e.clientX - offset.left) * this._pixelRatio;
|
|
let mouseY = (e.clientY - offset.top) * this._pixelRatio;
|
|
|
|
return {mouseX,mouseY};
|
|
},
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* A collection of utility functions converting various data sources
|
|
* into a format drawable by the FlameGraph.
|
|
*/
|
|
var FlameGraphUtils = {
|
|
_cache: new WeakMap(),
|
|
|
|
/**
|
|
* Create data suitable for use with FlameGraph from a profile's samples.
|
|
* Iterate the profile's samples and keep a moving window of stack traces.
|
|
*
|
|
* @param object thread
|
|
* The raw thread object received from the backend.
|
|
* @param object options
|
|
* Additional supported options,
|
|
* - boolean contentOnly [optional]
|
|
* - boolean invertTree [optional]
|
|
* - boolean flattenRecursion [optional]
|
|
* - string showIdleBlocks [optional]
|
|
* @return object
|
|
* Data source usable by FlameGraph.
|
|
*/
|
|
createFlameGraphDataFromThread: function(thread, options = {}, out = []) {
|
|
let cached = this._cache.get(thread);
|
|
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 = Array.from({ length: PALLETTE_SIZE }, () => []);
|
|
|
|
// 2. Populate the buckets by iterating over every frame in every sample.
|
|
|
|
let { samples, stackTable, frameTable, stringTable } = thread;
|
|
|
|
const SAMPLE_STACK_SLOT = samples.schema.stack;
|
|
const SAMPLE_TIME_SLOT = samples.schema.time;
|
|
|
|
const STACK_PREFIX_SLOT = stackTable.schema.prefix;
|
|
const STACK_FRAME_SLOT = stackTable.schema.frame;
|
|
|
|
const getOrAddInflatedFrame = FrameUtils.getOrAddInflatedFrame;
|
|
|
|
let inflatedFrameCache = FrameUtils.getInflatedFrameCache(frameTable);
|
|
let labelCache = Object.create(null);
|
|
|
|
let samplesData = samples.data;
|
|
let stacksData = stackTable.data;
|
|
|
|
let flattenRecursion = options.flattenRecursion;
|
|
|
|
// Reused objects.
|
|
let mutableFrameKeyOptions = {
|
|
contentOnly: options.contentOnly,
|
|
isRoot: false,
|
|
isLeaf: false,
|
|
isMetaCategoryOut: false
|
|
};
|
|
|
|
// Take the timestamp of the first sample as prevTime. 0 is incorrect due
|
|
// to circular buffer wraparound. If wraparound happens, then the first
|
|
// sample will have an incorrect, large duration.
|
|
let prevTime = samplesData.length > 0 ? samplesData[0][SAMPLE_TIME_SLOT] : 0;
|
|
let prevFrames = [];
|
|
let sampleFrames = [];
|
|
let sampleFrameKeys = [];
|
|
|
|
for (let i = 1; i < samplesData.length; i++) {
|
|
let sample = samplesData[i];
|
|
let time = sample[SAMPLE_TIME_SLOT];
|
|
|
|
let stackIndex = sample[SAMPLE_STACK_SLOT];
|
|
let prevFrameKey;
|
|
|
|
let stackDepth = 0;
|
|
|
|
// Inflate the stack and keep a moving window of call stacks.
|
|
//
|
|
// For reference, see the similar block comment in
|
|
// ThreadNode.prototype._buildInverted.
|
|
//
|
|
// In a similar fashion to _buildInverted, frames are inflated on the
|
|
// fly while stackwalking the stackTable trie. The exact same frame key
|
|
// is computed in both _buildInverted and here.
|
|
//
|
|
// Unlike _buildInverted, which builds a call tree directly, the flame
|
|
// graph inflates the stack into an array, as it maintains a moving
|
|
// window of stacks over time.
|
|
//
|
|
// Like _buildInverted, the various filtering functions are also inlined
|
|
// into stack inflation loop.
|
|
while (stackIndex !== null) {
|
|
let stackEntry = stacksData[stackIndex];
|
|
let frameIndex = stackEntry[STACK_FRAME_SLOT];
|
|
|
|
// Fetch the stack prefix (i.e. older frames) index.
|
|
stackIndex = stackEntry[STACK_PREFIX_SLOT];
|
|
|
|
// Inflate the frame.
|
|
let inflatedFrame = getOrAddInflatedFrame(inflatedFrameCache, frameIndex,
|
|
frameTable, stringTable);
|
|
|
|
mutableFrameKeyOptions.isRoot = stackIndex === null;
|
|
mutableFrameKeyOptions.isLeaf = stackDepth === 0;
|
|
let frameKey = inflatedFrame.getFrameKey(mutableFrameKeyOptions);
|
|
|
|
// If not skipping the frame, add it to the current level. The (root)
|
|
// node isn't useful for flame graphs.
|
|
if (frameKey !== "" && frameKey !== "(root)") {
|
|
// If the frame is a meta category, use the category label.
|
|
if (mutableFrameKeyOptions.isMetaCategoryOut) {
|
|
frameKey = CATEGORY_MAPPINGS[frameKey].label;
|
|
}
|
|
|
|
sampleFrames[stackDepth] = inflatedFrame;
|
|
sampleFrameKeys[stackDepth] = frameKey;
|
|
|
|
// If we shouldn't flatten the current frame into the previous one,
|
|
// increment the stack depth.
|
|
if (!flattenRecursion || frameKey !== prevFrameKey) {
|
|
stackDepth++;
|
|
}
|
|
|
|
prevFrameKey = frameKey;
|
|
}
|
|
}
|
|
|
|
// Uninvert frames in place if needed.
|
|
if (!options.invertTree) {
|
|
sampleFrames.length = stackDepth;
|
|
sampleFrames.reverse();
|
|
sampleFrameKeys.length = stackDepth;
|
|
sampleFrameKeys.reverse();
|
|
}
|
|
|
|
// If no frames are available, add a pseudo "idle" block in between.
|
|
let isIdleFrame = false;
|
|
if (options.showIdleBlocks && stackDepth === 0) {
|
|
sampleFrames[0] = null;
|
|
sampleFrameKeys[0] = options.showIdleBlocks;
|
|
stackDepth = 1;
|
|
isIdleFrame = true;
|
|
}
|
|
|
|
// Put each frame in a bucket.
|
|
for (let frameIndex = 0; frameIndex < stackDepth; frameIndex++) {
|
|
let key = sampleFrameKeys[frameIndex];
|
|
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.frameKey === key) {
|
|
prevFrame.width = (time - prevFrame.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(key);
|
|
let bucket = buckets[hash % PALLETTE_SIZE];
|
|
|
|
let label;
|
|
if (isIdleFrame) {
|
|
label = key;
|
|
} else {
|
|
label = labelCache[key];
|
|
if (!label) {
|
|
label = labelCache[key] = this._formatLabel(key, sampleFrames[frameIndex]);
|
|
}
|
|
}
|
|
|
|
bucket.push(prevFrames[frameIndex] = {
|
|
startTime: prevTime,
|
|
frameKey: key,
|
|
x: prevTime,
|
|
y: frameIndex * FLAME_GRAPH_BLOCK_HEIGHT,
|
|
width: time - prevTime,
|
|
height: FLAME_GRAPH_BLOCK_HEIGHT,
|
|
text: label
|
|
});
|
|
}
|
|
}
|
|
|
|
// Previous frames at stack depths greater than the current sample's
|
|
// maximum need to be nullified. It's nonsensical to reuse them.
|
|
prevFrames.length = stackDepth;
|
|
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 i = 0; i < buckets.length; i++) {
|
|
out.push({ color: COLOR_PALLETTE[i], blocks: buckets[i] });
|
|
}
|
|
|
|
this._cache.set(thread, out);
|
|
return out;
|
|
},
|
|
|
|
/**
|
|
* Clears the cached flame graph data created for the given source.
|
|
* @param any source
|
|
*/
|
|
removeFromCache: function(source) {
|
|
this._cache.delete(source);
|
|
},
|
|
|
|
/**
|
|
* 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;
|
|
},
|
|
|
|
/**
|
|
* Takes a frame key and a frame, and returns a string that should be
|
|
* displayed in its flame block.
|
|
*
|
|
* @param string key
|
|
* @param object frame
|
|
* @return string
|
|
*/
|
|
_formatLabel: function (key, frame) {
|
|
let { functionName, fileName, line } = FrameUtils.parseLocation(key, frame.line);
|
|
let label = functionName;
|
|
|
|
if (fileName) {
|
|
label += ` (${fileName}${line != null ? (":" + line) : ""})`;
|
|
}
|
|
|
|
return label;
|
|
}
|
|
};
|
|
|
|
exports.FlameGraph = FlameGraph;
|
|
exports.FlameGraphUtils = FlameGraphUtils;
|
|
exports.PALLETTE_SIZE = PALLETTE_SIZE;
|
|
exports.FLAME_GRAPH_BLOCK_HEIGHT = FLAME_GRAPH_BLOCK_HEIGHT;
|
|
exports.FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE = FLAME_GRAPH_BLOCK_TEXT_FONT_SIZE;
|
|
exports.FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY = FLAME_GRAPH_BLOCK_TEXT_FONT_FAMILY;
|