123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499 |
- "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;
|