In a following patch, all DevTools moz.build files will use DevToolsModules to install JS modules at a path that corresponds directly to their source tree location. Here we rewrite all require and import calls to match the new location that these files are installed to.
1384 lines
41 KiB
JavaScript
1384 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 { Cc, Ci, Cu, Cr } = require("chrome");
|
|
|
|
const { Task } = Cu.import("resource://gre/modules/Task.jsm", {});
|
|
const { Heritage, setNamedTimeout, clearNamedTimeout } = require("resource:///modules/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://gre/modules/devtools/shared/shared/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:///modules/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
|
|
};
|
|
}
|
|
|
|
let quad = this._canvas.getBoxQuads({
|
|
relativeTo: this._topWindow.document
|
|
})[0];
|
|
|
|
let x = (e.screenX - this._topWindow.screenX) - quad.p1.x;
|
|
let y = (e.screenY - this._topWindow.screenY) - quad.p1.y;
|
|
|
|
// Don't allow the event coordinates to be bigger than the canvas
|
|
// or less than 0.
|
|
let maxX = quad.p2.x - quad.p1.x;
|
|
let maxY = quad.p3.y - quad.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()) {
|
|
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;
|