summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets/BarGraphWidget.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/widgets/BarGraphWidget.js')
-rw-r--r--devtools/client/shared/widgets/BarGraphWidget.js498
1 files changed, 498 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/BarGraphWidget.js b/devtools/client/shared/widgets/BarGraphWidget.js
new file mode 100644
index 000000000..b11c6c021
--- /dev/null
+++ b/devtools/client/shared/widgets/BarGraphWidget.js
@@ -0,0 +1,498 @@
+"use strict";
+
+const { Heritage, setNamedTimeout, clearNamedTimeout } = require("devtools/client/shared/widgets/view-helpers");
+const { AbstractCanvasGraph, CanvasGraphUtils } = require("devtools/client/shared/widgets/Graphs");
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+// Bar graph constants.
+
+const GRAPH_DAMPEN_VALUES_FACTOR = 0.75;
+
+// The following are in pixels
+const GRAPH_BARS_MARGIN_TOP = 1;
+const GRAPH_BARS_MARGIN_END = 1;
+const GRAPH_MIN_BARS_WIDTH = 5;
+const GRAPH_MIN_BLOCKS_HEIGHT = 1;
+
+const GRAPH_BACKGROUND_GRADIENT_START = "rgba(0,136,204,0.0)";
+const GRAPH_BACKGROUND_GRADIENT_END = "rgba(255,255,255,0.25)";
+
+const GRAPH_CLIPHEAD_LINE_COLOR = "#666";
+const GRAPH_SELECTION_LINE_COLOR = "#555";
+const GRAPH_SELECTION_BACKGROUND_COLOR = "rgba(0,136,204,0.25)";
+const GRAPH_SELECTION_STRIPES_COLOR = "rgba(255,255,255,0.1)";
+const GRAPH_REGION_BACKGROUND_COLOR = "transparent";
+const GRAPH_REGION_STRIPES_COLOR = "rgba(237,38,85,0.2)";
+
+const GRAPH_HIGHLIGHTS_MASK_BACKGROUND = "rgba(255,255,255,0.75)";
+const GRAPH_HIGHLIGHTS_MASK_STRIPES = "rgba(255,255,255,0.5)";
+
+// in ms
+const GRAPH_LEGEND_MOUSEOVER_DEBOUNCE = 50;
+
+/**
+ * A bar graph, plotting tuples of values as rectangles.
+ *
+ * @see AbstractCanvasGraph for emitted events and other options.
+ *
+ * Example usage:
+ * let graph = new BarGraphWidget(node);
+ * graph.format = ...;
+ * graph.once("ready", () => {
+ * graph.setData(src);
+ * });
+ *
+ * The `graph.format` traits are mandatory and will determine how the values
+ * are styled as "blocks" in every "bar":
+ * [
+ * { color: "#f00", label: "Foo" },
+ * { color: "#0f0", label: "Bar" },
+ * ...
+ * { color: "#00f", label: "Baz" }
+ * ]
+ *
+ * Data source format:
+ * [
+ * { delta: x1, values: [y11, y12, ... y1n] },
+ * { delta: x2, values: [y21, y22, ... y2n] },
+ * ...
+ * { delta: xm, values: [ym1, ym2, ... ymn] }
+ * ]
+ * where each item in the array represents a "bar", for which every value
+ * represents a "block" inside that "bar", plotted at the "delta" position.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the graph.
+ */
+this.BarGraphWidget = function (parent, ...args) {
+ AbstractCanvasGraph.apply(this, [parent, "bar-graph", ...args]);
+
+ this.once("ready", () => {
+ this._onLegendMouseOver = this._onLegendMouseOver.bind(this);
+ this._onLegendMouseOut = this._onLegendMouseOut.bind(this);
+ this._onLegendMouseDown = this._onLegendMouseDown.bind(this);
+ this._onLegendMouseUp = this._onLegendMouseUp.bind(this);
+ this._createLegend();
+ });
+};
+
+BarGraphWidget.prototype = Heritage.extend(AbstractCanvasGraph.prototype, {
+ clipheadLineColor: GRAPH_CLIPHEAD_LINE_COLOR,
+ selectionLineColor: GRAPH_SELECTION_LINE_COLOR,
+ selectionBackgroundColor: GRAPH_SELECTION_BACKGROUND_COLOR,
+ selectionStripesColor: GRAPH_SELECTION_STRIPES_COLOR,
+ regionBackgroundColor: GRAPH_REGION_BACKGROUND_COLOR,
+ regionStripesColor: GRAPH_REGION_STRIPES_COLOR,
+
+ /**
+ * List of colors used to fill each block inside every bar, also
+ * corresponding to labels displayed in this graph's legend.
+ * @see constructor
+ */
+ format: null,
+
+ /**
+ * Optionally offsets the `delta` in the data source by this scalar.
+ */
+ dataOffsetX: 0,
+
+ /**
+ * Optionally uses this value instead of the last tick in the data source
+ * to compute the horizontal scaling.
+ */
+ dataDuration: 0,
+
+ /**
+ * The scalar used to multiply the graph values to leave some headroom
+ * on the top.
+ */
+ dampenValuesFactor: GRAPH_DAMPEN_VALUES_FACTOR,
+
+ /**
+ * Bars that are too close too each other in the graph will be combined.
+ * This scalar specifies the required minimum width of each bar.
+ */
+ minBarsWidth: GRAPH_MIN_BARS_WIDTH,
+
+ /**
+ * Blocks in a bar that are too thin inside the bar will not be rendered.
+ * This scalar specifies the required minimum height of each block.
+ */
+ minBlocksHeight: GRAPH_MIN_BLOCKS_HEIGHT,
+
+ /**
+ * Renders the graph's background.
+ * @see AbstractCanvasGraph.prototype.buildBackgroundImage
+ */
+ buildBackgroundImage: function () {
+ let { canvas, ctx } = this._getNamedCanvas("bar-graph-background");
+ let width = this._width;
+ let height = this._height;
+
+ let gradient = ctx.createLinearGradient(0, 0, 0, height);
+ gradient.addColorStop(0, GRAPH_BACKGROUND_GRADIENT_START);
+ gradient.addColorStop(1, GRAPH_BACKGROUND_GRADIENT_END);
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, width, height);
+
+ return canvas;
+ },
+
+ /**
+ * Renders the graph's data source.
+ * @see AbstractCanvasGraph.prototype.buildGraphImage
+ */
+ buildGraphImage: function () {
+ if (!this.format || !this.format.length) {
+ throw new Error("The graph format traits are mandatory to style " +
+ "the data source.");
+ }
+ let { canvas, ctx } = this._getNamedCanvas("bar-graph-data");
+ let width = this._width;
+ let height = this._height;
+
+ let totalTypes = this.format.length;
+ let totalTicks = this._data.length;
+ let lastTick = this._data[totalTicks - 1].delta;
+
+ let minBarsWidth = this.minBarsWidth * this._pixelRatio;
+ let minBlocksHeight = this.minBlocksHeight * this._pixelRatio;
+
+ let duration = this.dataDuration || lastTick;
+ let dataScaleX = this.dataScaleX = width / (duration - this.dataOffsetX);
+ let dataScaleY = this.dataScaleY = height / this._calcMaxHeight({
+ data: this._data,
+ dataScaleX: dataScaleX,
+ minBarsWidth: minBarsWidth
+ }) * this.dampenValuesFactor;
+
+ // Draw the graph.
+
+ // Iterate over the blocks, then the bars, to draw all rectangles of
+ // the same color in a single pass. See the @constructor for more
+ // information about the data source, and how a "bar" contains "blocks".
+
+ this._blocksBoundingRects = [];
+ let prevHeight = [];
+ let scaledMarginEnd = GRAPH_BARS_MARGIN_END * this._pixelRatio;
+ let scaledMarginTop = GRAPH_BARS_MARGIN_TOP * this._pixelRatio;
+
+ for (let type = 0; type < totalTypes; type++) {
+ ctx.fillStyle = this.format[type].color || "#000";
+ ctx.beginPath();
+
+ let prevRight = 0;
+ let skippedCount = 0;
+ let skippedHeight = 0;
+
+ for (let tick = 0; tick < totalTicks; tick++) {
+ let delta = this._data[tick].delta;
+ let value = this._data[tick].values[type] || 0;
+ let blockRight = (delta - this.dataOffsetX) * dataScaleX;
+ let blockHeight = value * dataScaleY;
+
+ let blockWidth = blockRight - prevRight;
+ if (blockWidth < minBarsWidth) {
+ skippedCount++;
+ skippedHeight += blockHeight;
+ continue;
+ }
+
+ let averageHeight = (blockHeight + skippedHeight) / (skippedCount + 1);
+ if (averageHeight >= minBlocksHeight) {
+ let bottom = height - ~~prevHeight[tick];
+ ctx.moveTo(prevRight, bottom);
+ ctx.lineTo(prevRight, bottom - averageHeight);
+ ctx.lineTo(blockRight, bottom - averageHeight);
+ ctx.lineTo(blockRight, bottom);
+
+ // Remember this block's type and location.
+ this._blocksBoundingRects.push({
+ type: type,
+ start: prevRight,
+ end: blockRight,
+ top: bottom - averageHeight,
+ bottom: bottom
+ });
+
+ if (prevHeight[tick] === undefined) {
+ prevHeight[tick] = averageHeight + scaledMarginTop;
+ } else {
+ prevHeight[tick] += averageHeight + scaledMarginTop;
+ }
+ }
+
+ prevRight += blockWidth + scaledMarginEnd;
+ skippedHeight = 0;
+ skippedCount = 0;
+ }
+
+ ctx.fill();
+ }
+
+ // The blocks bounding rects isn't guaranteed to be sorted ascending by
+ // block location on the X axis. This should be the case, for better
+ // cache cohesion and a faster `buildMaskImage`.
+ this._blocksBoundingRects.sort((a, b) => a.start > b.start ? 1 : -1);
+
+ // Update the legend.
+
+ while (this._legendNode.hasChildNodes()) {
+ this._legendNode.firstChild.remove();
+ }
+ for (let { color, label } of this.format) {
+ this._createLegendItem(color, label);
+ }
+
+ return canvas;
+ },
+
+ /**
+ * Renders the graph's mask.
+ * Fades in only the parts of the graph that are inside the specified areas.
+ *
+ * @param array highlights
+ * A list of { start, end } values. Optionally, each object
+ * in the list may also specify { top, bottom } pixel values if the
+ * highlighting shouldn't span across the full height of the graph.
+ * @param boolean inPixels
+ * Set this to true if the { start, end } values in the highlights
+ * list are pixel values, and not values from the data source.
+ * @param function unpack [optional]
+ * @see AbstractCanvasGraph.prototype.getMappedSelection
+ */
+ buildMaskImage: function (highlights, inPixels = false,
+ unpack = e => e.delta) {
+ // A null `highlights` array is used to clear the mask. An empty array
+ // will mask the entire graph.
+ if (!highlights) {
+ return null;
+ }
+
+ // Get a render target for the highlights. It will be overlaid on top of
+ // the existing graph, masking the areas that aren't highlighted.
+
+ let { canvas, ctx } = this._getNamedCanvas("graph-highlights");
+ let width = this._width;
+ let height = this._height;
+
+ // Draw the background mask.
+
+ let pattern = AbstractCanvasGraph.getStripePattern({
+ ownerDocument: this._document,
+ backgroundColor: GRAPH_HIGHLIGHTS_MASK_BACKGROUND,
+ stripesColor: GRAPH_HIGHLIGHTS_MASK_STRIPES
+ });
+ ctx.fillStyle = pattern;
+ ctx.fillRect(0, 0, width, height);
+
+ // Clear highlighted areas.
+
+ let totalTicks = this._data.length;
+ let firstTick = unpack(this._data[0]);
+ let lastTick = unpack(this._data[totalTicks - 1]);
+
+ for (let { start, end, top, bottom } of highlights) {
+ if (!inPixels) {
+ start = CanvasGraphUtils.map(start, firstTick, lastTick, 0, width);
+ end = CanvasGraphUtils.map(end, firstTick, lastTick, 0, width);
+ }
+ let firstSnap = findFirst(this._blocksBoundingRects,
+ e => e.start >= start);
+ let lastSnap = findLast(this._blocksBoundingRects,
+ e => e.start >= start && e.end <= end);
+
+ let x1 = firstSnap ? firstSnap.start : start;
+ let x2;
+ if (lastSnap) {
+ x2 = lastSnap.end;
+ } else {
+ x2 = firstSnap ? firstSnap.end : end;
+ }
+
+ let y1 = top || 0;
+ let y2 = bottom || height;
+ ctx.clearRect(x1, y1, x2 - x1, y2 - y1);
+ }
+
+ return canvas;
+ },
+
+ /**
+ * A list storing the bounding rectangle for each drawn block in the graph.
+ * Created whenever `buildGraphImage` is invoked.
+ */
+ _blocksBoundingRects: null,
+
+ /**
+ * Calculates the height of the tallest bar that would eventially be rendered
+ * in this graph.
+ *
+ * Bars that are too close too each other in the graph will be combined.
+ * @see `minBarsWidth`
+ *
+ * @return number
+ * The tallest bar height in this graph.
+ */
+ _calcMaxHeight: function ({ data, dataScaleX, minBarsWidth }) {
+ let maxHeight = 0;
+ let prevRight = 0;
+ let skippedCount = 0;
+ let skippedHeight = 0;
+ let scaledMarginEnd = GRAPH_BARS_MARGIN_END * this._pixelRatio;
+
+ for (let { delta, values } of data) {
+ let barRight = (delta - this.dataOffsetX) * dataScaleX;
+ let barHeight = values.reduce((a, b) => a + b, 0);
+
+ let barWidth = barRight - prevRight;
+ if (barWidth < minBarsWidth) {
+ skippedCount++;
+ skippedHeight += barHeight;
+ continue;
+ }
+
+ let averageHeight = (barHeight + skippedHeight) / (skippedCount + 1);
+ maxHeight = Math.max(averageHeight, maxHeight);
+
+ prevRight += barWidth + scaledMarginEnd;
+ skippedHeight = 0;
+ skippedCount = 0;
+ }
+
+ return maxHeight;
+ },
+
+ /**
+ * Creates the legend container when constructing this graph.
+ */
+ _createLegend: function () {
+ let legendNode = this._legendNode = this._document.createElementNS(HTML_NS,
+ "div");
+ legendNode.className = "bar-graph-widget-legend";
+ this._container.appendChild(legendNode);
+ },
+
+ /**
+ * Creates a legend item when constructing this graph.
+ */
+ _createLegendItem: function (color, label) {
+ let itemNode = this._document.createElementNS(HTML_NS, "div");
+ itemNode.className = "bar-graph-widget-legend-item";
+
+ let colorNode = this._document.createElementNS(HTML_NS, "span");
+ colorNode.setAttribute("view", "color");
+ colorNode.setAttribute("data-index", this._legendNode.childNodes.length);
+ colorNode.style.backgroundColor = color;
+ colorNode.addEventListener("mouseover", this._onLegendMouseOver);
+ colorNode.addEventListener("mouseout", this._onLegendMouseOut);
+ colorNode.addEventListener("mousedown", this._onLegendMouseDown);
+ colorNode.addEventListener("mouseup", this._onLegendMouseUp);
+
+ let labelNode = this._document.createElementNS(HTML_NS, "span");
+ labelNode.setAttribute("view", "label");
+ labelNode.textContent = label;
+
+ itemNode.appendChild(colorNode);
+ itemNode.appendChild(labelNode);
+ this._legendNode.appendChild(itemNode);
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is hovered.
+ */
+ _onLegendMouseOver: function (ev) {
+ setNamedTimeout(
+ "bar-graph-debounce",
+ GRAPH_LEGEND_MOUSEOVER_DEBOUNCE,
+ () => {
+ let type = ev.target.dataset.index;
+ let rects = this._blocksBoundingRects.filter(e => e.type == type);
+
+ this._originalHighlights = this._mask;
+ this._hasCustomHighlights = true;
+ this.setMask(rects, true);
+
+ this.emit("legend-hover", [type, rects]);
+ }
+ );
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is unhovered.
+ */
+ _onLegendMouseOut: function () {
+ clearNamedTimeout("bar-graph-debounce");
+
+ if (this._hasCustomHighlights) {
+ this.setMask(this._originalHighlights);
+ this._hasCustomHighlights = false;
+ this._originalHighlights = null;
+ }
+
+ this.emit("legend-unhover");
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is pressed.
+ */
+ _onLegendMouseDown: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ let type = ev.target.dataset.index;
+ let rects = this._blocksBoundingRects.filter(e => e.type == type);
+ let leftmost = rects[0];
+ let rightmost = rects[rects.length - 1];
+ if (!leftmost || !rightmost) {
+ this.dropSelection();
+ } else {
+ this.setSelection({ start: leftmost.start, end: rightmost.end });
+ }
+
+ this.emit("legend-selection", [leftmost, rightmost]);
+ },
+
+ /**
+ * Invoked whenever a color node in the legend is released.
+ */
+ _onLegendMouseUp: function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+});
+
+/**
+ * Finds the first element in an array that validates a predicate.
+ * @param array
+ * @param function predicate
+ * @return number
+ */
+function findFirst(array, predicate) {
+ for (let i = 0, len = array.length; i < len; i++) {
+ let element = array[i];
+ if (predicate(element)) {
+ return element;
+ }
+ }
+ return null;
+}
+
+/**
+ * Finds the last element in an array that validates a predicate.
+ * @param array
+ * @param function predicate
+ * @return number
+ */
+function findLast(array, predicate) {
+ for (let i = array.length - 1; i >= 0; i--) {
+ let element = array[i];
+ if (predicate(element)) {
+ return element;
+ }
+ }
+ return null;
+}
+
+module.exports = BarGraphWidget;