BarGraphWidget.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. "use strict";
  2. const { Heritage, setNamedTimeout, clearNamedTimeout } = require("devtools/client/shared/widgets/view-helpers");
  3. const { AbstractCanvasGraph, CanvasGraphUtils } = require("devtools/client/shared/widgets/Graphs");
  4. const HTML_NS = "http://www.w3.org/1999/xhtml";
  5. // Bar graph constants.
  6. const GRAPH_DAMPEN_VALUES_FACTOR = 0.75;
  7. // The following are in pixels
  8. const GRAPH_BARS_MARGIN_TOP = 1;
  9. const GRAPH_BARS_MARGIN_END = 1;
  10. const GRAPH_MIN_BARS_WIDTH = 5;
  11. const GRAPH_MIN_BLOCKS_HEIGHT = 1;
  12. const GRAPH_BACKGROUND_GRADIENT_START = "rgba(0,136,204,0.0)";
  13. const GRAPH_BACKGROUND_GRADIENT_END = "rgba(255,255,255,0.25)";
  14. const GRAPH_CLIPHEAD_LINE_COLOR = "#666";
  15. const GRAPH_SELECTION_LINE_COLOR = "#555";
  16. const GRAPH_SELECTION_BACKGROUND_COLOR = "rgba(0,136,204,0.25)";
  17. const GRAPH_SELECTION_STRIPES_COLOR = "rgba(255,255,255,0.1)";
  18. const GRAPH_REGION_BACKGROUND_COLOR = "transparent";
  19. const GRAPH_REGION_STRIPES_COLOR = "rgba(237,38,85,0.2)";
  20. const GRAPH_HIGHLIGHTS_MASK_BACKGROUND = "rgba(255,255,255,0.75)";
  21. const GRAPH_HIGHLIGHTS_MASK_STRIPES = "rgba(255,255,255,0.5)";
  22. // in ms
  23. const GRAPH_LEGEND_MOUSEOVER_DEBOUNCE = 50;
  24. /**
  25. * A bar graph, plotting tuples of values as rectangles.
  26. *
  27. * @see AbstractCanvasGraph for emitted events and other options.
  28. *
  29. * Example usage:
  30. * let graph = new BarGraphWidget(node);
  31. * graph.format = ...;
  32. * graph.once("ready", () => {
  33. * graph.setData(src);
  34. * });
  35. *
  36. * The `graph.format` traits are mandatory and will determine how the values
  37. * are styled as "blocks" in every "bar":
  38. * [
  39. * { color: "#f00", label: "Foo" },
  40. * { color: "#0f0", label: "Bar" },
  41. * ...
  42. * { color: "#00f", label: "Baz" }
  43. * ]
  44. *
  45. * Data source format:
  46. * [
  47. * { delta: x1, values: [y11, y12, ... y1n] },
  48. * { delta: x2, values: [y21, y22, ... y2n] },
  49. * ...
  50. * { delta: xm, values: [ym1, ym2, ... ymn] }
  51. * ]
  52. * where each item in the array represents a "bar", for which every value
  53. * represents a "block" inside that "bar", plotted at the "delta" position.
  54. *
  55. * @param nsIDOMNode parent
  56. * The parent node holding the graph.
  57. */
  58. this.BarGraphWidget = function (parent, ...args) {
  59. AbstractCanvasGraph.apply(this, [parent, "bar-graph", ...args]);
  60. this.once("ready", () => {
  61. this._onLegendMouseOver = this._onLegendMouseOver.bind(this);
  62. this._onLegendMouseOut = this._onLegendMouseOut.bind(this);
  63. this._onLegendMouseDown = this._onLegendMouseDown.bind(this);
  64. this._onLegendMouseUp = this._onLegendMouseUp.bind(this);
  65. this._createLegend();
  66. });
  67. };
  68. BarGraphWidget.prototype = Heritage.extend(AbstractCanvasGraph.prototype, {
  69. clipheadLineColor: GRAPH_CLIPHEAD_LINE_COLOR,
  70. selectionLineColor: GRAPH_SELECTION_LINE_COLOR,
  71. selectionBackgroundColor: GRAPH_SELECTION_BACKGROUND_COLOR,
  72. selectionStripesColor: GRAPH_SELECTION_STRIPES_COLOR,
  73. regionBackgroundColor: GRAPH_REGION_BACKGROUND_COLOR,
  74. regionStripesColor: GRAPH_REGION_STRIPES_COLOR,
  75. /**
  76. * List of colors used to fill each block inside every bar, also
  77. * corresponding to labels displayed in this graph's legend.
  78. * @see constructor
  79. */
  80. format: null,
  81. /**
  82. * Optionally offsets the `delta` in the data source by this scalar.
  83. */
  84. dataOffsetX: 0,
  85. /**
  86. * Optionally uses this value instead of the last tick in the data source
  87. * to compute the horizontal scaling.
  88. */
  89. dataDuration: 0,
  90. /**
  91. * The scalar used to multiply the graph values to leave some headroom
  92. * on the top.
  93. */
  94. dampenValuesFactor: GRAPH_DAMPEN_VALUES_FACTOR,
  95. /**
  96. * Bars that are too close too each other in the graph will be combined.
  97. * This scalar specifies the required minimum width of each bar.
  98. */
  99. minBarsWidth: GRAPH_MIN_BARS_WIDTH,
  100. /**
  101. * Blocks in a bar that are too thin inside the bar will not be rendered.
  102. * This scalar specifies the required minimum height of each block.
  103. */
  104. minBlocksHeight: GRAPH_MIN_BLOCKS_HEIGHT,
  105. /**
  106. * Renders the graph's background.
  107. * @see AbstractCanvasGraph.prototype.buildBackgroundImage
  108. */
  109. buildBackgroundImage: function () {
  110. let { canvas, ctx } = this._getNamedCanvas("bar-graph-background");
  111. let width = this._width;
  112. let height = this._height;
  113. let gradient = ctx.createLinearGradient(0, 0, 0, height);
  114. gradient.addColorStop(0, GRAPH_BACKGROUND_GRADIENT_START);
  115. gradient.addColorStop(1, GRAPH_BACKGROUND_GRADIENT_END);
  116. ctx.fillStyle = gradient;
  117. ctx.fillRect(0, 0, width, height);
  118. return canvas;
  119. },
  120. /**
  121. * Renders the graph's data source.
  122. * @see AbstractCanvasGraph.prototype.buildGraphImage
  123. */
  124. buildGraphImage: function () {
  125. if (!this.format || !this.format.length) {
  126. throw new Error("The graph format traits are mandatory to style " +
  127. "the data source.");
  128. }
  129. let { canvas, ctx } = this._getNamedCanvas("bar-graph-data");
  130. let width = this._width;
  131. let height = this._height;
  132. let totalTypes = this.format.length;
  133. let totalTicks = this._data.length;
  134. let lastTick = this._data[totalTicks - 1].delta;
  135. let minBarsWidth = this.minBarsWidth * this._pixelRatio;
  136. let minBlocksHeight = this.minBlocksHeight * this._pixelRatio;
  137. let duration = this.dataDuration || lastTick;
  138. let dataScaleX = this.dataScaleX = width / (duration - this.dataOffsetX);
  139. let dataScaleY = this.dataScaleY = height / this._calcMaxHeight({
  140. data: this._data,
  141. dataScaleX: dataScaleX,
  142. minBarsWidth: minBarsWidth
  143. }) * this.dampenValuesFactor;
  144. // Draw the graph.
  145. // Iterate over the blocks, then the bars, to draw all rectangles of
  146. // the same color in a single pass. See the @constructor for more
  147. // information about the data source, and how a "bar" contains "blocks".
  148. this._blocksBoundingRects = [];
  149. let prevHeight = [];
  150. let scaledMarginEnd = GRAPH_BARS_MARGIN_END * this._pixelRatio;
  151. let scaledMarginTop = GRAPH_BARS_MARGIN_TOP * this._pixelRatio;
  152. for (let type = 0; type < totalTypes; type++) {
  153. ctx.fillStyle = this.format[type].color || "#000";
  154. ctx.beginPath();
  155. let prevRight = 0;
  156. let skippedCount = 0;
  157. let skippedHeight = 0;
  158. for (let tick = 0; tick < totalTicks; tick++) {
  159. let delta = this._data[tick].delta;
  160. let value = this._data[tick].values[type] || 0;
  161. let blockRight = (delta - this.dataOffsetX) * dataScaleX;
  162. let blockHeight = value * dataScaleY;
  163. let blockWidth = blockRight - prevRight;
  164. if (blockWidth < minBarsWidth) {
  165. skippedCount++;
  166. skippedHeight += blockHeight;
  167. continue;
  168. }
  169. let averageHeight = (blockHeight + skippedHeight) / (skippedCount + 1);
  170. if (averageHeight >= minBlocksHeight) {
  171. let bottom = height - ~~prevHeight[tick];
  172. ctx.moveTo(prevRight, bottom);
  173. ctx.lineTo(prevRight, bottom - averageHeight);
  174. ctx.lineTo(blockRight, bottom - averageHeight);
  175. ctx.lineTo(blockRight, bottom);
  176. // Remember this block's type and location.
  177. this._blocksBoundingRects.push({
  178. type: type,
  179. start: prevRight,
  180. end: blockRight,
  181. top: bottom - averageHeight,
  182. bottom: bottom
  183. });
  184. if (prevHeight[tick] === undefined) {
  185. prevHeight[tick] = averageHeight + scaledMarginTop;
  186. } else {
  187. prevHeight[tick] += averageHeight + scaledMarginTop;
  188. }
  189. }
  190. prevRight += blockWidth + scaledMarginEnd;
  191. skippedHeight = 0;
  192. skippedCount = 0;
  193. }
  194. ctx.fill();
  195. }
  196. // The blocks bounding rects isn't guaranteed to be sorted ascending by
  197. // block location on the X axis. This should be the case, for better
  198. // cache cohesion and a faster `buildMaskImage`.
  199. this._blocksBoundingRects.sort((a, b) => a.start > b.start ? 1 : -1);
  200. // Update the legend.
  201. while (this._legendNode.hasChildNodes()) {
  202. this._legendNode.firstChild.remove();
  203. }
  204. for (let { color, label } of this.format) {
  205. this._createLegendItem(color, label);
  206. }
  207. return canvas;
  208. },
  209. /**
  210. * Renders the graph's mask.
  211. * Fades in only the parts of the graph that are inside the specified areas.
  212. *
  213. * @param array highlights
  214. * A list of { start, end } values. Optionally, each object
  215. * in the list may also specify { top, bottom } pixel values if the
  216. * highlighting shouldn't span across the full height of the graph.
  217. * @param boolean inPixels
  218. * Set this to true if the { start, end } values in the highlights
  219. * list are pixel values, and not values from the data source.
  220. * @param function unpack [optional]
  221. * @see AbstractCanvasGraph.prototype.getMappedSelection
  222. */
  223. buildMaskImage: function (highlights, inPixels = false,
  224. unpack = e => e.delta) {
  225. // A null `highlights` array is used to clear the mask. An empty array
  226. // will mask the entire graph.
  227. if (!highlights) {
  228. return null;
  229. }
  230. // Get a render target for the highlights. It will be overlaid on top of
  231. // the existing graph, masking the areas that aren't highlighted.
  232. let { canvas, ctx } = this._getNamedCanvas("graph-highlights");
  233. let width = this._width;
  234. let height = this._height;
  235. // Draw the background mask.
  236. let pattern = AbstractCanvasGraph.getStripePattern({
  237. ownerDocument: this._document,
  238. backgroundColor: GRAPH_HIGHLIGHTS_MASK_BACKGROUND,
  239. stripesColor: GRAPH_HIGHLIGHTS_MASK_STRIPES
  240. });
  241. ctx.fillStyle = pattern;
  242. ctx.fillRect(0, 0, width, height);
  243. // Clear highlighted areas.
  244. let totalTicks = this._data.length;
  245. let firstTick = unpack(this._data[0]);
  246. let lastTick = unpack(this._data[totalTicks - 1]);
  247. for (let { start, end, top, bottom } of highlights) {
  248. if (!inPixels) {
  249. start = CanvasGraphUtils.map(start, firstTick, lastTick, 0, width);
  250. end = CanvasGraphUtils.map(end, firstTick, lastTick, 0, width);
  251. }
  252. let firstSnap = findFirst(this._blocksBoundingRects,
  253. e => e.start >= start);
  254. let lastSnap = findLast(this._blocksBoundingRects,
  255. e => e.start >= start && e.end <= end);
  256. let x1 = firstSnap ? firstSnap.start : start;
  257. let x2;
  258. if (lastSnap) {
  259. x2 = lastSnap.end;
  260. } else {
  261. x2 = firstSnap ? firstSnap.end : end;
  262. }
  263. let y1 = top || 0;
  264. let y2 = bottom || height;
  265. ctx.clearRect(x1, y1, x2 - x1, y2 - y1);
  266. }
  267. return canvas;
  268. },
  269. /**
  270. * A list storing the bounding rectangle for each drawn block in the graph.
  271. * Created whenever `buildGraphImage` is invoked.
  272. */
  273. _blocksBoundingRects: null,
  274. /**
  275. * Calculates the height of the tallest bar that would eventially be rendered
  276. * in this graph.
  277. *
  278. * Bars that are too close too each other in the graph will be combined.
  279. * @see `minBarsWidth`
  280. *
  281. * @return number
  282. * The tallest bar height in this graph.
  283. */
  284. _calcMaxHeight: function ({ data, dataScaleX, minBarsWidth }) {
  285. let maxHeight = 0;
  286. let prevRight = 0;
  287. let skippedCount = 0;
  288. let skippedHeight = 0;
  289. let scaledMarginEnd = GRAPH_BARS_MARGIN_END * this._pixelRatio;
  290. for (let { delta, values } of data) {
  291. let barRight = (delta - this.dataOffsetX) * dataScaleX;
  292. let barHeight = values.reduce((a, b) => a + b, 0);
  293. let barWidth = barRight - prevRight;
  294. if (barWidth < minBarsWidth) {
  295. skippedCount++;
  296. skippedHeight += barHeight;
  297. continue;
  298. }
  299. let averageHeight = (barHeight + skippedHeight) / (skippedCount + 1);
  300. maxHeight = Math.max(averageHeight, maxHeight);
  301. prevRight += barWidth + scaledMarginEnd;
  302. skippedHeight = 0;
  303. skippedCount = 0;
  304. }
  305. return maxHeight;
  306. },
  307. /**
  308. * Creates the legend container when constructing this graph.
  309. */
  310. _createLegend: function () {
  311. let legendNode = this._legendNode = this._document.createElementNS(HTML_NS,
  312. "div");
  313. legendNode.className = "bar-graph-widget-legend";
  314. this._container.appendChild(legendNode);
  315. },
  316. /**
  317. * Creates a legend item when constructing this graph.
  318. */
  319. _createLegendItem: function (color, label) {
  320. let itemNode = this._document.createElementNS(HTML_NS, "div");
  321. itemNode.className = "bar-graph-widget-legend-item";
  322. let colorNode = this._document.createElementNS(HTML_NS, "span");
  323. colorNode.setAttribute("view", "color");
  324. colorNode.setAttribute("data-index", this._legendNode.childNodes.length);
  325. colorNode.style.backgroundColor = color;
  326. colorNode.addEventListener("mouseover", this._onLegendMouseOver);
  327. colorNode.addEventListener("mouseout", this._onLegendMouseOut);
  328. colorNode.addEventListener("mousedown", this._onLegendMouseDown);
  329. colorNode.addEventListener("mouseup", this._onLegendMouseUp);
  330. let labelNode = this._document.createElementNS(HTML_NS, "span");
  331. labelNode.setAttribute("view", "label");
  332. labelNode.textContent = label;
  333. itemNode.appendChild(colorNode);
  334. itemNode.appendChild(labelNode);
  335. this._legendNode.appendChild(itemNode);
  336. },
  337. /**
  338. * Invoked whenever a color node in the legend is hovered.
  339. */
  340. _onLegendMouseOver: function (ev) {
  341. setNamedTimeout(
  342. "bar-graph-debounce",
  343. GRAPH_LEGEND_MOUSEOVER_DEBOUNCE,
  344. () => {
  345. let type = ev.target.dataset.index;
  346. let rects = this._blocksBoundingRects.filter(e => e.type == type);
  347. this._originalHighlights = this._mask;
  348. this._hasCustomHighlights = true;
  349. this.setMask(rects, true);
  350. this.emit("legend-hover", [type, rects]);
  351. }
  352. );
  353. },
  354. /**
  355. * Invoked whenever a color node in the legend is unhovered.
  356. */
  357. _onLegendMouseOut: function () {
  358. clearNamedTimeout("bar-graph-debounce");
  359. if (this._hasCustomHighlights) {
  360. this.setMask(this._originalHighlights);
  361. this._hasCustomHighlights = false;
  362. this._originalHighlights = null;
  363. }
  364. this.emit("legend-unhover");
  365. },
  366. /**
  367. * Invoked whenever a color node in the legend is pressed.
  368. */
  369. _onLegendMouseDown: function (ev) {
  370. ev.preventDefault();
  371. ev.stopPropagation();
  372. let type = ev.target.dataset.index;
  373. let rects = this._blocksBoundingRects.filter(e => e.type == type);
  374. let leftmost = rects[0];
  375. let rightmost = rects[rects.length - 1];
  376. if (!leftmost || !rightmost) {
  377. this.dropSelection();
  378. } else {
  379. this.setSelection({ start: leftmost.start, end: rightmost.end });
  380. }
  381. this.emit("legend-selection", [leftmost, rightmost]);
  382. },
  383. /**
  384. * Invoked whenever a color node in the legend is released.
  385. */
  386. _onLegendMouseUp: function (e) {
  387. e.preventDefault();
  388. e.stopPropagation();
  389. }
  390. });
  391. /**
  392. * Finds the first element in an array that validates a predicate.
  393. * @param array
  394. * @param function predicate
  395. * @return number
  396. */
  397. function findFirst(array, predicate) {
  398. for (let i = 0, len = array.length; i < len; i++) {
  399. let element = array[i];
  400. if (predicate(element)) {
  401. return element;
  402. }
  403. }
  404. return null;
  405. }
  406. /**
  407. * Finds the last element in an array that validates a predicate.
  408. * @param array
  409. * @param function predicate
  410. * @return number
  411. */
  412. function findLast(array, predicate) {
  413. for (let i = array.length - 1; i >= 0; i--) {
  414. let element = array[i];
  415. if (predicate(element)) {
  416. return element;
  417. }
  418. }
  419. return null;
  420. }
  421. module.exports = BarGraphWidget;