|
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"; |
|
7 |
|
8 const Ci = Components.interfaces; |
|
9 const Cu = Components.utils; |
|
10 |
|
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; |
|
19 |
|
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"); |
|
24 |
|
25 this.EXPORTED_SYMBOLS = ["Chart"]; |
|
26 |
|
27 /** |
|
28 * Localization convenience methods. |
|
29 */ |
|
30 let L10N = new ViewHelpers.L10N(NET_STRINGS_URI); |
|
31 |
|
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 }; |
|
41 |
|
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 } |
|
55 |
|
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 } |
|
69 |
|
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 } |
|
86 |
|
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 } |
|
113 |
|
114 let pie = Chart.Pie(document, { |
|
115 width: diameter, |
|
116 data: data |
|
117 }); |
|
118 |
|
119 let table = Chart.Table(document, { |
|
120 title: title, |
|
121 data: data, |
|
122 strings: strings, |
|
123 totals: totals |
|
124 }); |
|
125 |
|
126 let container = document.createElement("hbox"); |
|
127 container.className = "pie-table-chart-container"; |
|
128 container.appendChild(pie.node); |
|
129 container.appendChild(table.node); |
|
130 |
|
131 let proxy = new PieTableChart(container, pie, table); |
|
132 |
|
133 pie.on("click", (event, item) => { |
|
134 proxy.emit(event, item) |
|
135 }); |
|
136 |
|
137 table.on("click", (event, item) => { |
|
138 proxy.emit(event, item) |
|
139 }); |
|
140 |
|
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 }); |
|
147 |
|
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 }); |
|
154 |
|
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 }); |
|
161 |
|
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 }); |
|
168 |
|
169 return proxy; |
|
170 } |
|
171 |
|
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; |
|
205 |
|
206 // Filter out very small sizes, as they'll just render invisible slices. |
|
207 data = data ? data.filter(e => e.size > EPSILON) : null; |
|
208 |
|
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 } |
|
218 |
|
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); |
|
228 |
|
229 let proxy = new PieChart(container); |
|
230 |
|
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); |
|
235 |
|
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; |
|
242 |
|
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 } |
|
249 |
|
250 endAngle = startAngle - sliceAngle; |
|
251 midAngle = (startAngle + endAngle) / 2; |
|
252 |
|
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; |
|
258 |
|
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"); |
|
269 |
|
270 if (sliceInfo == largest) { |
|
271 pathNode.setAttribute("largest", ""); |
|
272 } |
|
273 if (sliceInfo == smallest) { |
|
274 pathNode.setAttribute("smallest", ""); |
|
275 } |
|
276 |
|
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 : ""); |
|
281 |
|
282 proxy.slices.set(sliceInfo, pathNode); |
|
283 delegate(proxy, ["click", "mouseenter", "mouseleave"], pathNode, sliceInfo); |
|
284 container.appendChild(pathNode); |
|
285 |
|
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 } |
|
297 |
|
298 startAngle = endAngle; |
|
299 } |
|
300 |
|
301 return proxy; |
|
302 } |
|
303 |
|
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; |
|
346 |
|
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 } |
|
356 |
|
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); |
|
363 |
|
364 let proxy = new TableChart(container); |
|
365 |
|
366 let titleNode = document.createElement("label"); |
|
367 titleNode.className = "plain table-chart-title"; |
|
368 titleNode.setAttribute("value", title); |
|
369 container.appendChild(titleNode); |
|
370 |
|
371 let tableNode = document.createElement("vbox"); |
|
372 tableNode.className = "plain table-chart-grid"; |
|
373 container.appendChild(tableNode); |
|
374 |
|
375 for (let rowInfo of data) { |
|
376 let rowNode = document.createElement("hbox"); |
|
377 rowNode.className = "table-chart-row"; |
|
378 rowNode.setAttribute("align", "center"); |
|
379 |
|
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); |
|
384 |
|
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 } |
|
394 |
|
395 proxy.rows.set(rowInfo, rowNode); |
|
396 delegate(proxy, ["click", "mouseenter", "mouseleave"], rowNode, rowInfo); |
|
397 tableNode.appendChild(rowNode); |
|
398 } |
|
399 |
|
400 let totalsNode = document.createElement("vbox"); |
|
401 totalsNode.className = "table-chart-totals"; |
|
402 |
|
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 } |
|
412 |
|
413 container.appendChild(totalsNode); |
|
414 |
|
415 return proxy; |
|
416 } |
|
417 |
|
418 XPCOMUtils.defineLazyGetter(this, "loadingPieChartData", () => { |
|
419 return [{ size: 1, label: L10N.getStr("pieChart.loading") }]; |
|
420 }); |
|
421 |
|
422 XPCOMUtils.defineLazyGetter(this, "emptyPieChartData", () => { |
|
423 return [{ size: 1, label: L10N.getStr("pieChart.unavailable") }]; |
|
424 }); |
|
425 |
|
426 XPCOMUtils.defineLazyGetter(this, "loadingTableChartData", () => { |
|
427 return [{ size: "", label: L10N.getStr("tableChart.loading") }]; |
|
428 }); |
|
429 |
|
430 XPCOMUtils.defineLazyGetter(this, "emptyTableChartData", () => { |
|
431 return [{ size: "", label: L10N.getStr("tableChart.unavailable") }]; |
|
432 }); |
|
433 |
|
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 } |