Chart.jsm 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
  2. /* This Source Code Form is subject to the terms of the Mozilla Public
  3. * License, v. 2.0. If a copy of the MPL was not distributed with this
  4. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  5. "use strict";
  6. const Cu = Components.utils;
  7. const NET_STRINGS_URI = "devtools/client/locales/netmonitor.properties";
  8. const SVG_NS = "http://www.w3.org/2000/svg";
  9. const PI = Math.PI;
  10. const TAU = PI * 2;
  11. const EPSILON = 0.0000001;
  12. const NAMED_SLICE_MIN_ANGLE = TAU / 8;
  13. const NAMED_SLICE_TEXT_DISTANCE_RATIO = 1.9;
  14. const HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO = 20;
  15. const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
  16. const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
  17. const EventEmitter = require("devtools/shared/event-emitter");
  18. const { LocalizationHelper } = require("devtools/shared/l10n");
  19. this.EXPORTED_SYMBOLS = ["Chart"];
  20. /**
  21. * Localization convenience methods.
  22. */
  23. var L10N = new LocalizationHelper(NET_STRINGS_URI);
  24. /**
  25. * A factory for creating charts.
  26. * Example usage: let myChart = Chart.Pie(document, { ... });
  27. */
  28. var Chart = {
  29. Pie: createPieChart,
  30. Table: createTableChart,
  31. PieTable: createPieTableChart
  32. };
  33. /**
  34. * A simple pie chart proxy for the underlying view.
  35. * Each item in the `slices` property represents a [data, node] pair containing
  36. * the data used to create the slice and the nsIDOMNode displaying it.
  37. *
  38. * @param nsIDOMNode node
  39. * The node representing the view for this chart.
  40. */
  41. function PieChart(node) {
  42. this.node = node;
  43. this.slices = new WeakMap();
  44. EventEmitter.decorate(this);
  45. }
  46. /**
  47. * A simple table chart proxy for the underlying view.
  48. * Each item in the `rows` property represents a [data, node] pair containing
  49. * the data used to create the row and the nsIDOMNode displaying it.
  50. *
  51. * @param nsIDOMNode node
  52. * The node representing the view for this chart.
  53. */
  54. function TableChart(node) {
  55. this.node = node;
  56. this.rows = new WeakMap();
  57. EventEmitter.decorate(this);
  58. }
  59. /**
  60. * A simple pie+table chart proxy for the underlying view.
  61. *
  62. * @param nsIDOMNode node
  63. * The node representing the view for this chart.
  64. * @param PieChart pie
  65. * The pie chart proxy.
  66. * @param TableChart table
  67. * The table chart proxy.
  68. */
  69. function PieTableChart(node, pie, table) {
  70. this.node = node;
  71. this.pie = pie;
  72. this.table = table;
  73. EventEmitter.decorate(this);
  74. }
  75. /**
  76. * Creates the DOM for a pie+table chart.
  77. *
  78. * @param nsIDocument document
  79. * The document responsible with creating the DOM.
  80. * @param object
  81. * An object containing all or some of the following properties:
  82. * - title: a string displayed as the table chart's (description)/local
  83. * - diameter: the diameter of the pie chart, in pixels
  84. * - data: an array of items used to display each slice in the pie
  85. * and each row in the table;
  86. * @see `createPieChart` and `createTableChart` for details.
  87. * - strings: @see `createTableChart` for details.
  88. * - totals: @see `createTableChart` for details.
  89. * - sorted: a flag specifying if the `data` should be sorted
  90. * ascending by `size`.
  91. * @return PieTableChart
  92. * A pie+table chart proxy instance, which emits the following events:
  93. * - "mouseover", when the mouse enters a slice or a row
  94. * - "mouseout", when the mouse leaves a slice or a row
  95. * - "click", when the mouse enters a slice or a row
  96. */
  97. function createPieTableChart(document, { title, diameter, data, strings, totals, sorted, header }) {
  98. if (data && sorted) {
  99. data = data.slice().sort((a, b) => +(a.size < b.size));
  100. }
  101. let pie = Chart.Pie(document, {
  102. width: diameter,
  103. data: data
  104. });
  105. let table = Chart.Table(document, {
  106. title: title,
  107. data: data,
  108. strings: strings,
  109. totals: totals,
  110. header: header,
  111. });
  112. let container = document.createElement("hbox");
  113. container.className = "pie-table-chart-container";
  114. container.appendChild(pie.node);
  115. container.appendChild(table.node);
  116. let proxy = new PieTableChart(container, pie, table);
  117. pie.on("click", (event, item) => {
  118. proxy.emit(event, item);
  119. });
  120. table.on("click", (event, item) => {
  121. proxy.emit(event, item);
  122. });
  123. pie.on("mouseover", (event, item) => {
  124. proxy.emit(event, item);
  125. if (table.rows.has(item)) {
  126. table.rows.get(item).setAttribute("focused", "");
  127. }
  128. });
  129. pie.on("mouseout", (event, item) => {
  130. proxy.emit(event, item);
  131. if (table.rows.has(item)) {
  132. table.rows.get(item).removeAttribute("focused");
  133. }
  134. });
  135. table.on("mouseover", (event, item) => {
  136. proxy.emit(event, item);
  137. if (pie.slices.has(item)) {
  138. pie.slices.get(item).setAttribute("focused", "");
  139. }
  140. });
  141. table.on("mouseout", (event, item) => {
  142. proxy.emit(event, item);
  143. if (pie.slices.has(item)) {
  144. pie.slices.get(item).removeAttribute("focused");
  145. }
  146. });
  147. return proxy;
  148. }
  149. /**
  150. * Creates the DOM for a pie chart based on the specified properties.
  151. *
  152. * @param nsIDocument document
  153. * The document responsible with creating the DOM.
  154. * @param object
  155. * An object containing all or some of the following properties:
  156. * - data: an array of items used to display each slice; all the items
  157. * should be objects containing a `size` and a `label` property.
  158. * e.g: [{
  159. * size: 1,
  160. * label: "foo"
  161. * }, {
  162. * size: 2,
  163. * label: "bar"
  164. * }];
  165. * - width: the width of the chart, in pixels
  166. * - height: optional, the height of the chart, in pixels.
  167. * - centerX: optional, the X-axis center of the chart, in pixels.
  168. * - centerY: optional, the Y-axis center of the chart, in pixels.
  169. * - radius: optional, the radius of the chart, in pixels.
  170. * @return PieChart
  171. * A pie chart proxy instance, which emits the following events:
  172. * - "mouseover", when the mouse enters a slice
  173. * - "mouseout", when the mouse leaves a slice
  174. * - "click", when the mouse clicks a slice
  175. */
  176. function createPieChart(document, { data, width, height, centerX, centerY, radius }) {
  177. height = height || width;
  178. centerX = centerX || width / 2;
  179. centerY = centerY || height / 2;
  180. radius = radius || (width + height) / 4;
  181. let isPlaceholder = false;
  182. // Filter out very small sizes, as they'll just render invisible slices.
  183. data = data ? data.filter(e => e.size > EPSILON) : null;
  184. // If there's no data available, display an empty placeholder.
  185. if (!data) {
  186. data = loadingPieChartData;
  187. isPlaceholder = true;
  188. }
  189. if (!data.length) {
  190. data = emptyPieChartData;
  191. isPlaceholder = true;
  192. }
  193. let container = document.createElementNS(SVG_NS, "svg");
  194. container.setAttribute("class", "generic-chart-container pie-chart-container");
  195. container.setAttribute("pack", "center");
  196. container.setAttribute("flex", "1");
  197. container.setAttribute("width", width);
  198. container.setAttribute("height", height);
  199. container.setAttribute("viewBox", "0 0 " + width + " " + height);
  200. container.setAttribute("slices", data.length);
  201. container.setAttribute("placeholder", isPlaceholder);
  202. let proxy = new PieChart(container);
  203. let total = data.reduce((acc, e) => acc + e.size, 0);
  204. let angles = data.map(e => e.size / total * (TAU - EPSILON));
  205. let largest = data.reduce((a, b) => a.size > b.size ? a : b);
  206. let smallest = data.reduce((a, b) => a.size < b.size ? a : b);
  207. let textDistance = radius / NAMED_SLICE_TEXT_DISTANCE_RATIO;
  208. let translateDistance = radius / HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO;
  209. let startAngle = TAU;
  210. let endAngle = 0;
  211. let midAngle = 0;
  212. radius -= translateDistance;
  213. for (let i = data.length - 1; i >= 0; i--) {
  214. let sliceInfo = data[i];
  215. let sliceAngle = angles[i];
  216. if (!sliceInfo.size || sliceAngle < EPSILON) {
  217. continue;
  218. }
  219. endAngle = startAngle - sliceAngle;
  220. midAngle = (startAngle + endAngle) / 2;
  221. let x1 = centerX + radius * Math.sin(startAngle);
  222. let y1 = centerY - radius * Math.cos(startAngle);
  223. let x2 = centerX + radius * Math.sin(endAngle);
  224. let y2 = centerY - radius * Math.cos(endAngle);
  225. let largeArcFlag = Math.abs(startAngle - endAngle) > PI ? 1 : 0;
  226. let pathNode = document.createElementNS(SVG_NS, "path");
  227. pathNode.setAttribute("class", "pie-chart-slice chart-colored-blob");
  228. pathNode.setAttribute("name", sliceInfo.label);
  229. pathNode.setAttribute("d",
  230. " M " + centerX + "," + centerY +
  231. " L " + x2 + "," + y2 +
  232. " A " + radius + "," + radius +
  233. " 0 " + largeArcFlag +
  234. " 1 " + x1 + "," + y1 +
  235. " Z");
  236. if (sliceInfo == largest) {
  237. pathNode.setAttribute("largest", "");
  238. }
  239. if (sliceInfo == smallest) {
  240. pathNode.setAttribute("smallest", "");
  241. }
  242. let hoverX = translateDistance * Math.sin(midAngle);
  243. let hoverY = -translateDistance * Math.cos(midAngle);
  244. let hoverTransform = "transform: translate(" + hoverX + "px, " + hoverY + "px)";
  245. pathNode.setAttribute("style", data.length > 1 ? hoverTransform : "");
  246. proxy.slices.set(sliceInfo, pathNode);
  247. delegate(proxy, ["click", "mouseover", "mouseout"], pathNode, sliceInfo);
  248. container.appendChild(pathNode);
  249. if (sliceInfo.label && sliceAngle > NAMED_SLICE_MIN_ANGLE) {
  250. let textX = centerX + textDistance * Math.sin(midAngle);
  251. let textY = centerY - textDistance * Math.cos(midAngle);
  252. let label = document.createElementNS(SVG_NS, "text");
  253. label.appendChild(document.createTextNode(sliceInfo.label));
  254. label.setAttribute("class", "pie-chart-label");
  255. label.setAttribute("style", data.length > 1 ? hoverTransform : "");
  256. label.setAttribute("x", data.length > 1 ? textX : centerX);
  257. label.setAttribute("y", data.length > 1 ? textY : centerY);
  258. container.appendChild(label);
  259. }
  260. startAngle = endAngle;
  261. }
  262. return proxy;
  263. }
  264. /**
  265. * Creates the DOM for a table chart based on the specified properties.
  266. *
  267. * @param nsIDocument document
  268. * The document responsible with creating the DOM.
  269. * @param object
  270. * An object containing all or some of the following properties:
  271. * - title: a string displayed as the chart's (description)/local
  272. * - data: an array of items used to display each row; all the items
  273. * should be objects representing columns, for which the
  274. * properties' values will be displayed in each cell of a row.
  275. * e.g: [{
  276. * label1: 1,
  277. * label2: 3,
  278. * label3: "foo"
  279. * }, {
  280. * label1: 4,
  281. * label2: 6,
  282. * label3: "bar
  283. * }];
  284. * - strings: an object specifying for which rows in the `data` array
  285. * their cell values should be stringified and localized
  286. * based on a predicate function;
  287. * e.g: {
  288. * label1: value => l10n.getFormatStr("...", value)
  289. * }
  290. * - totals: an object specifying for which rows in the `data` array
  291. * the sum of their cells is to be displayed in the chart;
  292. * e.g: {
  293. * label1: total => l10n.getFormatStr("...", total), // 5
  294. * label2: total => l10n.getFormatStr("...", total), // 9
  295. * }
  296. * @return TableChart
  297. * A table chart proxy instance, which emits the following events:
  298. * - "mouseover", when the mouse enters a row
  299. * - "mouseout", when the mouse leaves a row
  300. * - "click", when the mouse clicks a row
  301. */
  302. function createTableChart(document, { title, data, strings, totals, header }) {
  303. strings = strings || {};
  304. totals = totals || {};
  305. let isPlaceholder = false;
  306. // If there's no data available, display an empty placeholder.
  307. if (!data) {
  308. data = loadingTableChartData;
  309. isPlaceholder = true;
  310. }
  311. if (!data.length) {
  312. data = emptyTableChartData;
  313. isPlaceholder = true;
  314. }
  315. let container = document.createElement("vbox");
  316. container.className = "generic-chart-container table-chart-container";
  317. container.setAttribute("pack", "center");
  318. container.setAttribute("flex", "1");
  319. container.setAttribute("rows", data.length);
  320. container.setAttribute("placeholder", isPlaceholder);
  321. let proxy = new TableChart(container);
  322. let titleNode = document.createElement("label");
  323. titleNode.className = "plain table-chart-title";
  324. titleNode.setAttribute("value", title);
  325. container.appendChild(titleNode);
  326. let tableNode = document.createElement("vbox");
  327. tableNode.className = "plain table-chart-grid";
  328. container.appendChild(tableNode);
  329. const headerNode = document.createElement("div");
  330. headerNode.className = "table-chart-row";
  331. const headerBoxNode = document.createElement("div");
  332. headerBoxNode.className = "table-chart-row-box";
  333. headerNode.appendChild(headerBoxNode);
  334. for (let [key, value] of Object.entries(header)) {
  335. let headerLabelNode = document.createElement("span");
  336. headerLabelNode.className = "plain table-chart-row-label";
  337. headerLabelNode.setAttribute("name", key);
  338. headerLabelNode.textContent = value;
  339. headerNode.appendChild(headerLabelNode);
  340. }
  341. tableNode.appendChild(headerNode);
  342. for (let rowInfo of data) {
  343. let rowNode = document.createElement("hbox");
  344. rowNode.className = "table-chart-row";
  345. rowNode.setAttribute("align", "center");
  346. let boxNode = document.createElement("hbox");
  347. boxNode.className = "table-chart-row-box chart-colored-blob";
  348. boxNode.setAttribute("name", rowInfo.label);
  349. rowNode.appendChild(boxNode);
  350. for (let [key, value] of Object.entries(rowInfo)) {
  351. let index = data.indexOf(rowInfo);
  352. let stringified = strings[key] ? strings[key](value, index) : value;
  353. let labelNode = document.createElement("label");
  354. labelNode.className = "plain table-chart-row-label";
  355. labelNode.setAttribute("name", key);
  356. labelNode.setAttribute("value", stringified);
  357. rowNode.appendChild(labelNode);
  358. }
  359. proxy.rows.set(rowInfo, rowNode);
  360. delegate(proxy, ["click", "mouseover", "mouseout"], rowNode, rowInfo);
  361. tableNode.appendChild(rowNode);
  362. }
  363. let totalsNode = document.createElement("vbox");
  364. totalsNode.className = "table-chart-totals";
  365. for (let [key, value] of Object.entries(totals)) {
  366. let total = data.reduce((acc, e) => acc + e[key], 0);
  367. let stringified = totals[key] ? totals[key](total || 0) : total;
  368. let labelNode = document.createElement("label");
  369. labelNode.className = "plain table-chart-summary-label";
  370. labelNode.setAttribute("name", key);
  371. labelNode.setAttribute("value", stringified);
  372. totalsNode.appendChild(labelNode);
  373. }
  374. container.appendChild(totalsNode);
  375. return proxy;
  376. }
  377. XPCOMUtils.defineLazyGetter(this, "loadingPieChartData", () => {
  378. return [{ size: 1, label: L10N.getStr("pieChart.loading") }];
  379. });
  380. XPCOMUtils.defineLazyGetter(this, "emptyPieChartData", () => {
  381. return [{ size: 1, label: L10N.getStr("pieChart.unavailable") }];
  382. });
  383. XPCOMUtils.defineLazyGetter(this, "loadingTableChartData", () => {
  384. return [{ size: "", label: L10N.getStr("tableChart.loading") }];
  385. });
  386. XPCOMUtils.defineLazyGetter(this, "emptyTableChartData", () => {
  387. return [{ size: "", label: L10N.getStr("tableChart.unavailable") }];
  388. });
  389. /**
  390. * Delegates DOM events emitted by an nsIDOMNode to an EventEmitter proxy.
  391. *
  392. * @param EventEmitter emitter
  393. * The event emitter proxy instance.
  394. * @param array events
  395. * An array of events, e.g. ["mouseover", "mouseout"].
  396. * @param nsIDOMNode node
  397. * The element firing the DOM events.
  398. * @param any args
  399. * The arguments passed when emitting events through the proxy.
  400. */
  401. function delegate(emitter, events, node, args) {
  402. for (let event of events) {
  403. node.addEventListener(event, emitter.emit.bind(emitter, event, args));
  404. }
  405. }