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