gecko-dev/devtools/client/shared/widgets/Graphs.js

1405 lines
43 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 { Cc, Ci, Cu, Cr } = require("chrome");
const { Task } = Cu.import("resource://gre/modules/Task.jsm", {});
const { Heritage, setNamedTimeout, clearNamedTimeout } = require("resource://devtools/client/shared/widgets/ViewHelpers.jsm");
const { getCurrentZoom } = require("devtools/shared/layout/utils");
loader.lazyRequireGetter(this, "promise");
loader.lazyRequireGetter(this, "EventEmitter",
"devtools/shared/event-emitter");
loader.lazyImporter(this, "DevToolsWorker",
"resource://devtools/shared/worker/worker.js");
const HTML_NS = "http://www.w3.org/1999/xhtml";
const GRAPH_SRC = "chrome://devtools/content/shared/widgets/graphs-frame.xhtml";
const WORKER_URL =
"resource://devtools/client/shared/widgets/GraphsWorker.js";
// Generic constants.
const GRAPH_RESIZE_EVENTS_DRAIN = 100; // ms
const GRAPH_WHEEL_ZOOM_SENSITIVITY = 0.00075;
const GRAPH_WHEEL_SCROLL_SENSITIVITY = 0.1;
const GRAPH_WHEEL_MIN_SELECTION_WIDTH = 10; // px
const GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH = 4; // px
const GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD = 10; // px
const GRAPH_MAX_SELECTION_LEFT_PADDING = 1;
const GRAPH_MAX_SELECTION_RIGHT_PADDING = 1;
const GRAPH_REGION_LINE_WIDTH = 1; // px
const GRAPH_REGION_LINE_COLOR = "rgba(237,38,85,0.8)";
const GRAPH_STRIPE_PATTERN_WIDTH = 16; // px
const GRAPH_STRIPE_PATTERN_HEIGHT = 16; // px
const GRAPH_STRIPE_PATTERN_LINE_WIDTH = 2; // px
const GRAPH_STRIPE_PATTERN_LINE_SPACING = 4; // px
/**
* Small data primitives for all graphs.
*/
this.GraphCursor = function() {
this.x = null;
this.y = null;
};
this.GraphArea = function() {
this.start = null;
this.end = null;
};
this.GraphAreaDragger = function(anchor = new GraphArea()) {
this.origin = null;
this.anchor = anchor;
};
this.GraphAreaResizer = function() {
this.margin = null;
};
/**
* Base class for all graphs using a canvas to render the data source. Handles
* frame creation, data source, selection bounds, cursor position, etc.
*
* Language:
* - The "data" represents the values used when building the graph.
* Its specific format is defined by the inheriting classes.
*
* - A "cursor" is the cliphead position across the X axis of the graph.
*
* - A "selection" is defined by a "start" and an "end" value and
* represents the selected bounds in the graph.
*
* - A "region" is a highlighted area in the graph, also defined by a
* "start" and an "end" value, but distinct from the "selection". It is
* simply used to highlight important regions in the data.
*
* Instances of this class are EventEmitters with the following events:
* - "ready": when the container iframe and canvas are created.
* - "selecting": when the selection is set or changed.
* - "deselecting": when the selection is dropped.
*
* @param nsIDOMNode parent
* The parent node holding the graph.
* @param string name
* The graph type, used for setting the correct class names.
* Currently supported: "line-graph" only.
* @param number sharpness [optional]
* Defaults to the current device pixel ratio.
*/
this.AbstractCanvasGraph = function(parent, name, sharpness) {
EventEmitter.decorate(this);
this._parent = parent;
this._ready = promise.defer();
this._uid = "canvas-graph-" + Date.now();
this._renderTargets = new Map();
AbstractCanvasGraph.createIframe(GRAPH_SRC, parent, iframe => {
this._iframe = iframe;
this._window = iframe.contentWindow;
this._topWindow = this._window.top;
this._document = iframe.contentDocument;
this._pixelRatio = sharpness || this._window.devicePixelRatio;
let container = this._container = this._document.getElementById("graph-container");
container.className = name + "-widget-container graph-widget-container";
let canvas = this._canvas = this._document.getElementById("graph-canvas");
canvas.className = name + "-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._ctx.mozImageSmoothingEnabled = false;
this._cursor = new GraphCursor();
this._selection = new GraphArea();
this._selectionDragger = new GraphAreaDragger();
this._selectionResizer = new GraphAreaResizer();
this._isMouseActive = false;
this._onAnimationFrame = this._onAnimationFrame.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onMouseDown = this._onMouseDown.bind(this);
this._onMouseUp = this._onMouseUp.bind(this);
this._onMouseWheel = this._onMouseWheel.bind(this);
this._onMouseOut = this._onMouseOut.bind(this);
this._onResize = this._onResize.bind(this);
this.refresh = this.refresh.bind(this);
this._window.addEventListener("mousemove", this._onMouseMove);
this._window.addEventListener("mousedown", this._onMouseDown);
this._window.addEventListener("MozMousePixelScroll", this._onMouseWheel);
this._window.addEventListener("mouseout", this._onMouseOut);
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);
});
};
AbstractCanvasGraph.prototype = {
/**
* Read-only width and height of the canvas.
* @return number
*/
get width() {
return this._width;
},
get height() {
return this._height;
},
/**
* Return true if the mouse is actively messing with the selection, false
* otherwise.
*/
get isMouseActive() {
return this._isMouseActive;
},
/**
* 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._topWindow.removeEventListener("mousemove", this._onMouseMove);
this._topWindow.removeEventListener("mouseup", this._onMouseUp);
this._window.removeEventListener("mousemove", this._onMouseMove);
this._window.removeEventListener("mousedown", this._onMouseDown);
this._window.removeEventListener("MozMousePixelScroll", this._onMouseWheel);
this._window.removeEventListener("mouseout", this._onMouseOut);
let ownerWindow = this._parent.ownerDocument.defaultView;
if (ownerWindow) {
ownerWindow.removeEventListener("resize", this._onResize);
}
this._window.cancelAnimationFrame(this._animationId);
this._iframe.remove();
this._cursor = null;
this._selection = null;
this._selectionDragger = null;
this._selectionResizer = null;
this._data = null;
this._mask = null;
this._maskArgs = null;
this._regions = null;
this._cachedBackgroundImage = null;
this._cachedGraphImage = null;
this._cachedMaskImage = null;
this._renderTargets.clear();
gCachedStripePattern.clear();
this.emit("destroyed");
}),
/**
* Rendering options. Subclasses should override these.
*/
clipheadLineWidth: 1,
clipheadLineColor: "transparent",
selectionLineWidth: 1,
selectionLineColor: "transparent",
selectionBackgroundColor: "transparent",
selectionStripesColor: "transparent",
regionBackgroundColor: "transparent",
regionStripesColor: "transparent",
/**
* 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,
/**
* Optionally builds and caches a background image for this graph.
* Inheriting classes may override this method.
*/
buildBackgroundImage: function() {
return null;
},
/**
* Builds and caches a graph image, based on the data source supplied
* in `setData`. The graph image is not rebuilt on each frame, but
* only when the data source changes.
*/
buildGraphImage: function() {
throw "This method needs to be implemented by inheriting classes.";
},
/**
* Optionally builds and caches a mask image for this graph, composited
* over the data image created via `buildGraphImage`. Inheriting classes
* may override this method.
*/
buildMaskImage: function() {
return null;
},
/**
* When setting the data source, the coordinates and values may be
* stretched or squeezed on the X/Y axis, to fit into the available space.
*/
dataScaleX: 1,
dataScaleY: 1,
/**
* Sets the data source for this graph.
*
* @param object data
* The data source. The actual format is specified by subclasses.
*/
setData: function(data) {
this._data = data;
this._cachedBackgroundImage = this.buildBackgroundImage();
this._cachedGraphImage = this.buildGraphImage();
this._shouldRedraw = true;
},
/**
* Same as `setData`, but waits for this graph to finish initializing first.
*
* @param object data
* The data source. The actual format is specified by subclasses.
* @return promise
* A promise resolved once the data is set.
*/
setDataWhenReady: Task.async(function*(data) {
yield this.ready();
this.setData(data);
}),
/**
* Adds a mask to this graph.
*
* @param any mask, options
* See `buildMaskImage` in inheriting classes for the required args.
*/
setMask: function(mask, ...options) {
this._mask = mask;
this._maskArgs = [mask, ...options];
this._cachedMaskImage = this.buildMaskImage.apply(this, this._maskArgs);
this._shouldRedraw = true;
},
/**
* Adds regions to this graph.
*
* See the "Language" section in the constructor documentation
* for details about what "regions" represent.
*
* @param array regions
* A list of { start, end } values.
*/
setRegions: function(regions) {
if (!this._cachedGraphImage) {
throw "Can't highlight regions on a graph with no data displayed.";
}
if (this._regions) {
throw "Regions were already highlighted on the graph.";
}
this._regions = regions.map(e => ({
start: e.start * this.dataScaleX,
end: e.end * this.dataScaleX
}));
this._bakeRegions(this._regions, this._cachedGraphImage);
this._shouldRedraw = true;
},
/**
* Gets whether or not this graph has a data source.
* @return boolean
*/
hasData: function() {
return !!this._data;
},
/**
* Gets whether or not this graph has any mask applied.
* @return boolean
*/
hasMask: function() {
return !!this._mask;
},
/**
* Gets whether or not this graph has any regions.
* @return boolean
*/
hasRegions: function() {
return !!this._regions;
},
/**
* Sets the selection bounds.
* Use `dropSelection` to remove the selection.
*
* If the bounds aren't different, no "selection" event is emitted.
*
* See the "Language" section in the constructor documentation
* for details about what a "selection" represents.
*
* @param object selection
* The selection's { start, end } values.
*/
setSelection: function(selection) {
if (!selection || selection.start == null || selection.end == null) {
throw "Invalid selection coordinates";
}
if (!this.isSelectionDifferent(selection)) {
return;
}
this._selection.start = selection.start;
this._selection.end = selection.end;
this._shouldRedraw = true;
this.emit("selecting");
},
/**
* Gets the selection bounds.
* If there's no selection, the bounds have null values.
*
* @return object
* The selection's { start, end } values.
*/
getSelection: function() {
if (this.hasSelection()) {
return { start: this._selection.start, end: this._selection.end };
}
if (this.hasSelectionInProgress()) {
return { start: this._selection.start, end: this._cursor.x };
}
return { start: null, end: null };
},
/**
* Sets the selection bounds, scaled to correlate with the data source ranges,
* such that a [0, max width] selection maps to [first value, last value].
*
* @param object selection
* The selection's { start, end } values.
* @param object { mapStart, mapEnd } mapping [optional]
* Invoked when retrieving the numbers in the data source representing
* the first and last values, on the X axis.
*/
setMappedSelection: function(selection, mapping = {}) {
if (!this.hasData()) {
throw "A data source is necessary for retrieving a mapped selection.";
}
if (!selection || selection.start == null || selection.end == null) {
throw "Invalid selection coordinates";
}
let { mapStart, mapEnd } = mapping;
let startTime = (mapStart || (e => e.delta))(this._data[0]);
let endTime = (mapEnd || (e => e.delta))(this._data[this._data.length - 1]);
// The selection's start and end values are not guaranteed to be ascending.
// Also make sure that the selection bounds fit inside the data bounds.
let min = Math.max(Math.min(selection.start, selection.end), startTime);
let max = Math.min(Math.max(selection.start, selection.end), endTime);
min = map(min, startTime, endTime, 0, this._width);
max = map(max, startTime, endTime, 0, this._width);
this.setSelection({ start: min, end: max });
},
/**
* Gets the selection bounds, scaled to correlate with the data source ranges,
* such that a [0, max width] selection maps to [first value, last value].
*
* @param object { mapStart, mapEnd } mapping [optional]
* Invoked when retrieving the numbers in the data source representing
* the first and last values, on the X axis.
* @return object
* The mapped selection's { min, max } values.
*/
getMappedSelection: function(mapping = {}) {
if (!this.hasData()) {
throw "A data source is necessary for retrieving a mapped selection.";
}
if (!this.hasSelection() && !this.hasSelectionInProgress()) {
return { min: null, max: null };
}
let { mapStart, mapEnd } = mapping;
let startTime = (mapStart || (e => e.delta))(this._data[0]);
let endTime = (mapEnd || (e => e.delta))(this._data[this._data.length - 1]);
// The selection's start and end values are not guaranteed to be ascending.
// This can happen, for example, when click & dragging from right to left.
// Also make sure that the selection bounds fit inside the canvas bounds.
let selection = this.getSelection();
let min = Math.max(Math.min(selection.start, selection.end), 0);
let max = Math.min(Math.max(selection.start, selection.end), this._width);
min = map(min, 0, this._width, startTime, endTime);
max = map(max, 0, this._width, startTime, endTime);
return { min: min, max: max };
},
/**
* Removes the selection.
*/
dropSelection: function() {
if (!this.hasSelection() && !this.hasSelectionInProgress()) {
return;
}
this._selection.start = null;
this._selection.end = null;
this._shouldRedraw = true;
this.emit("deselecting");
},
/**
* Gets whether or not this graph has a selection.
* @return boolean
*/
hasSelection: function() {
return this._selection &&
this._selection.start != null && this._selection.end != null;
},
/**
* Gets whether or not a selection is currently being made, for example
* via a click+drag operation.
* @return boolean
*/
hasSelectionInProgress: function() {
return this._selection &&
this._selection.start != null && this._selection.end == null;
},
/**
* Specifies whether or not mouse selection is allowed.
* @type boolean
*/
selectionEnabled: true,
/**
* Sets the selection bounds.
* Use `dropCursor` to hide the cursor.
*
* @param object cursor
* The cursor's { x, y } position.
*/
setCursor: function(cursor) {
if (!cursor || cursor.x == null || cursor.y == null) {
throw "Invalid cursor coordinates";
}
if (!this.isCursorDifferent(cursor)) {
return;
}
this._cursor.x = cursor.x;
this._cursor.y = cursor.y;
this._shouldRedraw = true;
},
/**
* Gets the cursor position.
* If there's no cursor, the position has null values.
*
* @return object
* The cursor's { x, y } values.
*/
getCursor: function() {
return { x: this._cursor.x, y: this._cursor.y };
},
/**
* Hides the cursor.
*/
dropCursor: function() {
if (!this.hasCursor()) {
return;
}
this._cursor.x = null;
this._cursor.y = null;
this._shouldRedraw = true;
},
/**
* Gets whether or not this graph has a visible cursor.
* @return boolean
*/
hasCursor: function() {
return this._cursor && this._cursor.x != null;
},
/**
* Specifies if this graph's selection is different from another one.
*
* @param object other
* The other graph's selection, as { start, end } values.
*/
isSelectionDifferent: function(other) {
if (!other) return true;
let current = this.getSelection();
return current.start != other.start || current.end != other.end;
},
/**
* Specifies if this graph's cursor is different from another one.
*
* @param object other
* The other graph's position, as { x, y } values.
*/
isCursorDifferent: function(other) {
if (!other) return true;
let current = this.getCursor();
return current.x != other.x || current.y != other.y;
},
/**
* Gets the width of the current selection.
* If no selection is available, 0 is returned.
*
* @return number
* The selection width.
*/
getSelectionWidth: function() {
let selection = this.getSelection();
return Math.abs(selection.start - selection.end);
},
/**
* Gets the currently hovered region, if any.
* If no region is currently hovered, null is returned.
*
* @return object
* The hovered region, as { start, end } values.
*/
getHoveredRegion: function() {
if (!this.hasRegions() || !this.hasCursor()) {
return null;
}
let { x } = this._cursor;
return this._regions.find(({ start, end }) =>
(start < end && start < x && end > x) ||
(start > end && end < x && start > x));
},
/**
* Updates this graph to reflect the new dimensions of the parent node.
*
* @param boolean options.force
* Force redrawing 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;
}
// Handle a changed size by mapping the old selection to the new width
if (this._width && newWidth && this.hasSelection()) {
let ratio = this._width / (newWidth * this._pixelRatio);
this._selection.start = Math.round(this._selection.start / ratio);
this._selection.end = Math.round(this._selection.end / ratio);
}
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;
if (this.hasData()) {
this._cachedBackgroundImage = this.buildBackgroundImage();
this._cachedGraphImage = this.buildGraphImage();
}
if (this.hasMask()) {
this._cachedMaskImage = this.buildMaskImage.apply(this, this._maskArgs);
}
if (this.hasRegions()) {
this._bakeRegions(this._regions, this._cachedGraphImage);
}
this._shouldRedraw = true;
this.emit("refresh");
},
/**
* Gets a canvas with the specified name, for this graph.
*
* If it doesn't exist yet, it will be created, otherwise the cached instance
* will be cleared and returned.
*
* @param string name
* The canvas name.
* @param number width, height [optional]
* A custom width and height for the canvas. Defaults to this graph's
* container canvas width and height.
*/
_getNamedCanvas: function(name, width = this._width, height = this._height) {
let cachedRenderTarget = this._renderTargets.get(name);
if (cachedRenderTarget) {
let { canvas, ctx } = cachedRenderTarget;
canvas.width = width;
canvas.height = height;
ctx.clearRect(0, 0, width, height);
return cachedRenderTarget;
}
let canvas = this._document.createElementNS(HTML_NS, "canvas");
let ctx = canvas.getContext("2d");
canvas.width = width;
canvas.height = height;
let renderTarget = { canvas: canvas, ctx: ctx };
this._renderTargets.set(name, renderTarget);
return renderTarget;
},
/**
* The contents of this graph are redrawn only when something changed,
* like the data source, or the selection bounds etc. This flag tracks
* if the rendering is "dirty" and needs to be refreshed.
*/
_shouldRedraw: false,
/**
* Animation frame callback, invoked on each tick of the refresh driver.
*/
_onAnimationFrame: function() {
this._animationId = this._window.requestAnimationFrame(this._onAnimationFrame);
this._drawWidget();
},
/**
* Redraws the widget when necessary. The actual graph is not refreshed
* every time this function is called, only the cliphead, selection etc.
*/
_drawWidget: function() {
if (!this._shouldRedraw) {
return;
}
let ctx = this._ctx;
ctx.clearRect(0, 0, this._width, this._height);
if (this._cachedGraphImage) {
ctx.drawImage(this._cachedGraphImage, 0, 0, this._width, this._height);
}
if (this._cachedMaskImage) {
ctx.globalCompositeOperation = "destination-out";
ctx.drawImage(this._cachedMaskImage, 0, 0, this._width, this._height);
}
if (this._cachedBackgroundImage) {
ctx.globalCompositeOperation = "destination-over";
ctx.drawImage(this._cachedBackgroundImage, 0, 0, this._width, this._height);
}
// Revert to the original global composition operation.
if (this._cachedMaskImage || this._cachedBackgroundImage) {
ctx.globalCompositeOperation = "source-over";
}
if (this.hasCursor()) {
this._drawCliphead();
}
if (this.hasSelection() || this.hasSelectionInProgress()) {
this._drawSelection();
}
this._shouldRedraw = false;
},
/**
* Draws the cliphead, if available and necessary.
*/
_drawCliphead: function() {
if (this._isHoveringSelectionContentsOrBoundaries() || this._isHoveringRegion()) {
return;
}
let ctx = this._ctx;
ctx.lineWidth = this.clipheadLineWidth;
ctx.strokeStyle = this.clipheadLineColor;
ctx.beginPath();
ctx.moveTo(this._cursor.x, 0);
ctx.lineTo(this._cursor.x, this._height);
ctx.stroke();
},
/**
* Draws the selection, if available and necessary.
*/
_drawSelection: function() {
let { start, end } = this.getSelection();
let input = this._canvas.getAttribute("input");
let ctx = this._ctx;
ctx.strokeStyle = this.selectionLineColor;
// Fill selection.
let pattern = AbstractCanvasGraph.getStripePattern({
ownerDocument: this._document,
backgroundColor: this.selectionBackgroundColor,
stripesColor: this.selectionStripesColor
});
ctx.fillStyle = pattern;
let rectStart = Math.min(this._width, Math.max(0, start));
let rectEnd = Math.min(this._width, Math.max(0, end));
ctx.fillRect(rectStart, 0, rectEnd - rectStart, this._height);
// Draw left boundary.
if (input == "hovering-selection-start-boundary") {
ctx.lineWidth = GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH;
} else {
ctx.lineWidth = this.clipheadLineWidth;
}
ctx.beginPath();
ctx.moveTo(start, 0);
ctx.lineTo(start, this._height);
ctx.stroke();
// Draw right boundary.
if (input == "hovering-selection-end-boundary") {
ctx.lineWidth = GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH;
} else {
ctx.lineWidth = this.clipheadLineWidth;
}
ctx.beginPath();
ctx.moveTo(end, this._height);
ctx.lineTo(end, 0);
ctx.stroke();
},
/**
* Draws regions into the cached graph image, created via `buildGraphImage`.
* Called when new regions are set.
*/
_bakeRegions: function(regions, destination) {
let ctx = destination.getContext("2d");
let pattern = AbstractCanvasGraph.getStripePattern({
ownerDocument: this._document,
backgroundColor: this.regionBackgroundColor,
stripesColor: this.regionStripesColor
});
ctx.fillStyle = pattern;
ctx.strokeStyle = GRAPH_REGION_LINE_COLOR;
ctx.lineWidth = GRAPH_REGION_LINE_WIDTH;
let y = -GRAPH_REGION_LINE_WIDTH;
let height = this._height + GRAPH_REGION_LINE_WIDTH;
for (let { start, end } of regions) {
let x = start;
let width = end - start;
ctx.fillRect(x, y, width, height);
ctx.strokeRect(x, y, width, height);
}
},
/**
* Checks whether the start handle of the selection is hovered.
* @return boolean
*/
_isHoveringStartBoundary: function() {
if (!this.hasSelection() || !this.hasCursor()) {
return;
}
let { x } = this._cursor;
let { start } = this._selection;
let threshold = GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD * this._pixelRatio;
return Math.abs(start - x) < threshold;
},
/**
* Checks whether the end handle of the selection is hovered.
* @return boolean
*/
_isHoveringEndBoundary: function() {
if (!this.hasSelection() || !this.hasCursor()) {
return;
}
let { x } = this._cursor;
let { end } = this._selection;
let threshold = GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD * this._pixelRatio;
return Math.abs(end - x) < threshold;
},
/**
* Checks whether the selection is hovered.
* @return boolean
*/
_isHoveringSelectionContents: function() {
if (!this.hasSelection() || !this.hasCursor()) {
return;
}
let { x } = this._cursor;
let { start, end } = this._selection;
return (start < end && start < x && end > x) ||
(start > end && end < x && start > x);
},
/**
* Checks whether the selection or its handles are hovered.
* @return boolean
*/
_isHoveringSelectionContentsOrBoundaries: function() {
return this._isHoveringSelectionContents() ||
this._isHoveringStartBoundary() ||
this._isHoveringEndBoundary();
},
/**
* Checks whether a region is hovered.
* @return boolean
*/
_isHoveringRegion: function() {
return !!this.getHoveredRegion();
},
/**
* 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 so, just return this.
if ("testX" in e && "testY" in e) {
return {
mouseX: e.testX * this._pixelRatio,
mouseY: e.testY * this._pixelRatio
};
}
// This method is concerned with converting mouse event coordinates from
// "screen space" to "local space" (in other words, relative to this
// canvas's position, thus (0,0) would correspond to the upper left corner).
// We can't simply use `clientX` and `clientY` because the given MouseEvent
// object may be generated from events coming from other DOM nodes.
// Therefore, we need to get a bounding box relative to the top document and
// do some simple math to convert screen coords into local coords.
// However, `getBoxQuads` may be a very costly operation depending on the
// complexity of the "outside world" DOM, so cache the results until we
// suspect they might change (e.g. on a resize).
// It'd sure be nice if we could use `getBoundsWithoutFlushing`, but it's
// not taking the document zoom factor into consideration consistently.
if (!this._boundingBox || this._maybeDirtyBoundingBox) {
let topDocument = this._topWindow.document;
let boxQuad = this._canvas.getBoxQuads({ relativeTo: topDocument })[0];
this._boundingBox = boxQuad;
this._maybeDirtyBoundingBox = false;
}
let bb = this._boundingBox;
let x = (e.screenX - this._topWindow.screenX) - bb.p1.x;
let y = (e.screenY - this._topWindow.screenY) - bb.p1.y;
// Don't allow the event coordinates to be bigger than the canvas
// or less than 0.
let maxX = bb.p2.x - bb.p1.x;
let maxY = bb.p3.y - bb.p1.y;
let mouseX = Math.max(0, Math.min(x, maxX)) * this._pixelRatio;
let mouseY = Math.max(0, Math.min(x, maxY)) * this._pixelRatio;
// The coordinates need to be modified with the current zoom level
// to prevent them from being wrong.
let zoom = getCurrentZoom(this._canvas);
mouseX /= zoom;
mouseY /= zoom;
return {mouseX,mouseY};
},
/**
* Listener for the "mousemove" event on the graph's container.
*/
_onMouseMove: function(e) {
let resizer = this._selectionResizer;
let dragger = this._selectionDragger;
// Need to stop propagation here, since this function can be bound
// to both this._window and this._topWindow. It's only attached to
// this._topWindow during a drag event. Null check here since tests
// don't pass this method into the event object.
if (e.stopPropagation && this._isMouseActive) {
e.stopPropagation();
}
// If a mouseup happened outside the window and the current operation
// is causing the selection to change, then end it.
if (e.buttons == 0 && (this.hasSelectionInProgress() ||
resizer.margin != null ||
dragger.origin != null)) {
return this._onMouseUp();
}
let {mouseX,mouseY} = this._getRelativeEventCoordinates(e);
this._cursor.x = mouseX;
this._cursor.y = mouseY;
if (resizer.margin != null) {
this._selection[resizer.margin] = mouseX;
this._shouldRedraw = true;
this.emit("selecting");
return;
}
if (dragger.origin != null) {
this._selection.start = dragger.anchor.start - dragger.origin + mouseX;
this._selection.end = dragger.anchor.end - dragger.origin + mouseX;
this._shouldRedraw = true;
this.emit("selecting");
return;
}
if (this.hasSelectionInProgress()) {
this._shouldRedraw = true;
this.emit("selecting");
return;
}
if (this.hasSelection()) {
if (this._isHoveringStartBoundary()) {
this._canvas.setAttribute("input", "hovering-selection-start-boundary");
this._shouldRedraw = true;
return;
}
if (this._isHoveringEndBoundary()) {
this._canvas.setAttribute("input", "hovering-selection-end-boundary");
this._shouldRedraw = true;
return;
}
if (this._isHoveringSelectionContents()) {
this._canvas.setAttribute("input", "hovering-selection-contents");
this._shouldRedraw = true;
return;
}
}
let region = this.getHoveredRegion();
if (region) {
this._canvas.setAttribute("input", "hovering-region");
} else {
this._canvas.setAttribute("input", "hovering-background");
}
this._shouldRedraw = true;
},
/**
* Listener for the "mousedown" event on the graph's container.
*/
_onMouseDown: function(e) {
this._isMouseActive = true;
let {mouseX} = this._getRelativeEventCoordinates(e);
switch (this._canvas.getAttribute("input")) {
case "hovering-background":
case "hovering-region":
if (!this.selectionEnabled) {
break;
}
this._selection.start = mouseX;
this._selection.end = null;
this.emit("selecting");
break;
case "hovering-selection-start-boundary":
this._selectionResizer.margin = "start";
break;
case "hovering-selection-end-boundary":
this._selectionResizer.margin = "end";
break;
case "hovering-selection-contents":
this._selectionDragger.origin = mouseX;
this._selectionDragger.anchor.start = this._selection.start;
this._selectionDragger.anchor.end = this._selection.end;
this._canvas.setAttribute("input", "dragging-selection-contents");
break;
}
// During a drag, bind to the top level window so that mouse movement
// outside of this frame will still work.
this._topWindow.addEventListener("mousemove", this._onMouseMove);
this._topWindow.addEventListener("mouseup", this._onMouseUp);
this._shouldRedraw = true;
this.emit("mousedown");
},
/**
* Listener for the "mouseup" event on the graph's container.
*/
_onMouseUp: function() {
this._isMouseActive = false;
switch (this._canvas.getAttribute("input")) {
case "hovering-background":
case "hovering-region":
if (!this.selectionEnabled) {
break;
}
if (this.getSelectionWidth() < 1) {
let region = this.getHoveredRegion();
if (region) {
this._selection.start = region.start;
this._selection.end = region.end;
this.emit("selecting");
} else {
this._selection.start = null;
this._selection.end = null;
this.emit("deselecting");
}
} else {
this._selection.end = this._cursor.x;
this.emit("selecting");
}
break;
case "hovering-selection-start-boundary":
case "hovering-selection-end-boundary":
this._selectionResizer.margin = null;
break;
case "dragging-selection-contents":
this._selectionDragger.origin = null;
this._canvas.setAttribute("input", "hovering-selection-contents");
break;
}
// No longer dragging, no need to bind to the top level window.
this._topWindow.removeEventListener("mousemove", this._onMouseMove);
this._topWindow.removeEventListener("mouseup", this._onMouseUp);
this._shouldRedraw = true;
this.emit("mouseup");
},
/**
* Listener for the "wheel" event on the graph's container.
*/
_onMouseWheel: function(e) {
if (!this.hasSelection()) {
return;
}
let {mouseX} = this._getRelativeEventCoordinates(e);
let focusX = mouseX;
let selection = this._selection;
let vector = 0;
// If the selection is hovered, "zoom" towards or away the cursor,
// by shrinking or growing the selection.
if (this._isHoveringSelectionContentsOrBoundaries()) {
let distStart = selection.start - focusX;
let distEnd = selection.end - focusX;
vector = e.detail * GRAPH_WHEEL_ZOOM_SENSITIVITY;
selection.start = selection.start + distStart * vector;
selection.end = selection.end + distEnd * vector;
}
// Otherwise, simply pan the selection towards the left or right.
else {
let direction = 0;
if (focusX > selection.end) {
direction = Math.sign(focusX - selection.end);
} else if (focusX < selection.start) {
direction = Math.sign(focusX - selection.start);
}
vector = direction * e.detail * GRAPH_WHEEL_SCROLL_SENSITIVITY;
selection.start -= vector;
selection.end -= vector;
}
// Make sure the selection bounds are still comfortably inside the
// graph's bounds when zooming out, to keep the margin handles accessible.
let minStart = GRAPH_MAX_SELECTION_LEFT_PADDING;
let maxEnd = this._width - GRAPH_MAX_SELECTION_RIGHT_PADDING;
if (selection.start < minStart) {
selection.start = minStart;
}
if (selection.start > maxEnd) {
selection.start = maxEnd;
}
if (selection.end < minStart) {
selection.end = minStart;
}
if (selection.end > maxEnd) {
selection.end = maxEnd;
}
// Make sure the selection doesn't get too narrow when zooming in.
let thickness = Math.abs(selection.start - selection.end);
if (thickness < GRAPH_WHEEL_MIN_SELECTION_WIDTH) {
let midPoint = (selection.start + selection.end) / 2;
selection.start = midPoint - GRAPH_WHEEL_MIN_SELECTION_WIDTH / 2;
selection.end = midPoint + GRAPH_WHEEL_MIN_SELECTION_WIDTH / 2;
}
this._shouldRedraw = true;
this.emit("selecting");
this.emit("scroll");
},
/**
* Listener for the "mouseout" event on the graph's container.
* Clear any active cursors if a drag isn't happening.
*/
_onMouseOut: function(e) {
if (!this._isMouseActive) {
this._cursor.x = null;
this._cursor.y = null;
this._canvas.removeAttribute("input");
this._shouldRedraw = true;
}
},
/**
* Listener for the "resize" event on the graph's parent node.
*/
_onResize: function() {
if (this.hasData()) {
// The assumption is that resize events may change the outside world
// layout in a way that affects this graph's bounding box location
// relative to the top window's document. Graphs aren't currently
// (or ever) expected to move around on their own.
this._maybeDirtyBoundingBox = true;
setNamedTimeout(this._uid, GRAPH_RESIZE_EVENTS_DRAIN, this.refresh);
}
}
};
// Helper functions.
/**
* Creates an iframe element with the provided source URL, appends it to
* the specified node and invokes the callback once the content is loaded.
*
* @param string url
* The desired source URL for the iframe.
* @param nsIDOMNode parent
* The desired parent node for the iframe.
* @param function callback
* Invoked once the content is loaded, with the iframe as an argument.
*/
AbstractCanvasGraph.createIframe = function(url, parent, callback) {
let iframe = parent.ownerDocument.createElementNS(HTML_NS, "iframe");
iframe.addEventListener("DOMContentLoaded", function onLoad() {
iframe.removeEventListener("DOMContentLoaded", onLoad);
callback(iframe);
});
// Setting 100% width on the frame and flex on the parent allows the graph
// to properly shrink when the window is resized to be smaller.
iframe.setAttribute("frameborder", "0");
iframe.style.width = "100%";
iframe.style.minWidth = "50px";
iframe.src = url;
parent.style.display = "flex";
parent.appendChild(iframe);
};
/**
* Gets a striped pattern used as a background in selections and regions.
*
* @param object data
* The following properties are required:
* - ownerDocument: the nsIDocumentElement owning the canvas
* - backgroundColor: a string representing the fill style
* - stripesColor: a string representing the stroke style
* @return nsIDOMCanvasPattern
* The custom striped pattern.
*/
AbstractCanvasGraph.getStripePattern = function(data) {
let { ownerDocument, backgroundColor, stripesColor } = data;
let id = [backgroundColor, stripesColor].join(",");
if (gCachedStripePattern.has(id)) {
return gCachedStripePattern.get(id);
}
let canvas = ownerDocument.createElementNS(HTML_NS, "canvas");
let ctx = canvas.getContext("2d");
let width = canvas.width = GRAPH_STRIPE_PATTERN_WIDTH;
let height = canvas.height = GRAPH_STRIPE_PATTERN_HEIGHT;
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, width, height);
let pixelRatio = ownerDocument.defaultView.devicePixelRatio;
let scaledLineWidth = GRAPH_STRIPE_PATTERN_LINE_WIDTH * pixelRatio;
let scaledLineSpacing = GRAPH_STRIPE_PATTERN_LINE_SPACING * pixelRatio;
ctx.strokeStyle = stripesColor;
ctx.lineWidth = scaledLineWidth;
ctx.lineCap = "square";
ctx.beginPath();
for (let i = -height; i <= height; i += scaledLineSpacing) {
ctx.moveTo(width, i);
ctx.lineTo(0, i + height);
}
ctx.stroke();
let pattern = ctx.createPattern(canvas, "repeat");
gCachedStripePattern.set(id, pattern);
return pattern;
};
/**
* Cache used by `AbstractCanvasGraph.getStripePattern`.
*/
const gCachedStripePattern = new Map();
/**
* Utility functions for graph canvases.
*/
this.CanvasGraphUtils = {
_graphUtilsWorker: null,
_graphUtilsTaskId: 0,
/**
* Merges the animation loop of two graphs.
*/
linkAnimation: Task.async(function*(graph1, graph2) {
if (!graph1 || !graph2) {
return;
}
yield graph1.ready();
yield graph2.ready();
let window = graph1._window;
window.cancelAnimationFrame(graph1._animationId);
window.cancelAnimationFrame(graph2._animationId);
let loop = () => {
window.requestAnimationFrame(loop);
graph1._drawWidget();
graph2._drawWidget();
};
window.requestAnimationFrame(loop);
}),
/**
* Makes sure selections in one graph are reflected in another.
*/
linkSelection: function(graph1, graph2) {
if (!graph1 || !graph2) {
return;
}
if (graph1.hasSelection()) {
graph2.setSelection(graph1.getSelection());
} else {
graph2.dropSelection();
}
graph1.on("selecting", () => {
graph2.setSelection(graph1.getSelection());
});
graph2.on("selecting", () => {
graph1.setSelection(graph2.getSelection());
});
graph1.on("deselecting", () => {
graph2.dropSelection();
});
graph2.on("deselecting", () => {
graph1.dropSelection();
});
},
/**
* Performs the given task in a chrome worker, assuming it exists.
*
* @param string task
* The task name. Currently supported: "plotTimestampsGraph".
* @param any data
* Extra arguments to pass to the worker.
* @return object
* A promise that is resolved once the worker finishes the task.
*/
_performTaskInWorker: function(task, data) {
let worker = this._graphUtilsWorker || new DevToolsWorker(WORKER_URL);
return worker.performTask(task, data);
}
};
/**
* Maps a value from one range to another.
* @param number value, istart, istop, ostart, ostop
* @return number
*/
function map(value, istart, istop, ostart, ostop) {
let ratio = istop - istart;
if (ratio == 0) {
return value;
}
return ostart + (ostop - ostart) * ((value - istart) / ratio);
}
/**
* Constrains a value to a range.
* @param number value, min, max
* @return number
*/
function clamp(value, min, max) {
if (value < min) return min;
if (value > max) return max;
return value;
}
exports.GraphCursor = GraphCursor;
exports.GraphArea = GraphArea;
exports.GraphAreaDragger = GraphAreaDragger;
exports.GraphAreaResizer = GraphAreaResizer;
exports.AbstractCanvasGraph = AbstractCanvasGraph;
exports.CanvasGraphUtils = CanvasGraphUtils;
exports.CanvasGraphUtils.map = map;
exports.CanvasGraphUtils.clamp = clamp;