123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425 |
- /* 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("devtools/shared/task");
- const { setNamedTimeout } = require("devtools/client/shared/widgets/view-helpers");
- const { getCurrentZoom } = require("devtools/shared/layout/utils");
- loader.lazyRequireGetter(this, "defer", "devtools/shared/defer");
- 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.
- // ms
- const GRAPH_RESIZE_EVENTS_DRAIN = 100;
- const GRAPH_WHEEL_ZOOM_SENSITIVITY = 0.00075;
- const GRAPH_WHEEL_SCROLL_SENSITIVITY = 0.1;
- // px
- const GRAPH_WHEEL_MIN_SELECTION_WIDTH = 10;
- // px
- const GRAPH_SELECTION_BOUNDARY_HOVER_LINE_WIDTH = 4;
- const GRAPH_SELECTION_BOUNDARY_HOVER_THRESHOLD = 10;
- const GRAPH_MAX_SELECTION_LEFT_PADDING = 1;
- const GRAPH_MAX_SELECTION_RIGHT_PADDING = 1;
- // px
- const GRAPH_REGION_LINE_WIDTH = 1;
- const GRAPH_REGION_LINE_COLOR = "rgba(237,38,85,0.8)";
- // px
- const GRAPH_STRIPE_PATTERN_WIDTH = 16;
- const GRAPH_STRIPE_PATTERN_HEIGHT = 16;
- const GRAPH_STRIPE_PATTERN_LINE_WIDTH = 2;
- const GRAPH_STRIPE_PATTERN_LINE_SPACING = 4;
- /**
- * 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 = 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.imageSmoothingEnabled = 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 () {
- let error = "This method needs to be implemented by inheriting classes.";
- throw new Error(error);
- },
- /**
- * 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 new Error("Can't highlight regions on a graph with " +
- "no data displayed.");
- }
- if (this._regions) {
- throw new Error("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 new Error("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 new Error("A data source is necessary for retrieving " +
- "a mapped selection.");
- }
- if (!selection || selection.start == null || selection.end == null) {
- throw new Error("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 new Error("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 new Error("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 false;
- }
- 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 false;
- }
- 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 false;
- }
- 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(y, 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)) {
- this._onMouseUp();
- return;
- }
- 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;
- } else {
- // Otherwise, simply pan the selection towards the left or right.
- 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;
|