1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/browser/devtools/shared/widgets/Chart.jsm Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,450 @@ 1.4 +/* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ 1.5 +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ 1.6 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.7 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.8 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.9 +"use strict"; 1.10 + 1.11 +const Ci = Components.interfaces; 1.12 +const Cu = Components.utils; 1.13 + 1.14 +const NET_STRINGS_URI = "chrome://browser/locale/devtools/netmonitor.properties"; 1.15 +const SVG_NS = "http://www.w3.org/2000/svg"; 1.16 +const PI = Math.PI; 1.17 +const TAU = PI * 2; 1.18 +const EPSILON = 0.0000001; 1.19 +const NAMED_SLICE_MIN_ANGLE = TAU / 8; 1.20 +const NAMED_SLICE_TEXT_DISTANCE_RATIO = 1.9; 1.21 +const HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO = 20; 1.22 + 1.23 +Cu.import("resource://gre/modules/Services.jsm"); 1.24 +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); 1.25 +Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); 1.26 +Cu.import("resource://gre/modules/devtools/event-emitter.js"); 1.27 + 1.28 +this.EXPORTED_SYMBOLS = ["Chart"]; 1.29 + 1.30 +/** 1.31 + * Localization convenience methods. 1.32 + */ 1.33 +let L10N = new ViewHelpers.L10N(NET_STRINGS_URI); 1.34 + 1.35 +/** 1.36 + * A factory for creating charts. 1.37 + * Example usage: let myChart = Chart.Pie(document, { ... }); 1.38 + */ 1.39 +let Chart = { 1.40 + Pie: createPieChart, 1.41 + Table: createTableChart, 1.42 + PieTable: createPieTableChart 1.43 +}; 1.44 + 1.45 +/** 1.46 + * A simple pie chart proxy for the underlying view. 1.47 + * Each item in the `slices` property represents a [data, node] pair containing 1.48 + * the data used to create the slice and the nsIDOMNode displaying it. 1.49 + * 1.50 + * @param nsIDOMNode node 1.51 + * The node representing the view for this chart. 1.52 + */ 1.53 +function PieChart(node) { 1.54 + this.node = node; 1.55 + this.slices = new WeakMap(); 1.56 + EventEmitter.decorate(this); 1.57 +} 1.58 + 1.59 +/** 1.60 + * A simple table chart proxy for the underlying view. 1.61 + * Each item in the `rows` property represents a [data, node] pair containing 1.62 + * the data used to create the row and the nsIDOMNode displaying it. 1.63 + * 1.64 + * @param nsIDOMNode node 1.65 + * The node representing the view for this chart. 1.66 + */ 1.67 +function TableChart(node) { 1.68 + this.node = node; 1.69 + this.rows = new WeakMap(); 1.70 + EventEmitter.decorate(this); 1.71 +} 1.72 + 1.73 +/** 1.74 + * A simple pie+table chart proxy for the underlying view. 1.75 + * 1.76 + * @param nsIDOMNode node 1.77 + * The node representing the view for this chart. 1.78 + * @param PieChart pie 1.79 + * The pie chart proxy. 1.80 + * @param TableChart table 1.81 + * The table chart proxy. 1.82 + */ 1.83 +function PieTableChart(node, pie, table) { 1.84 + this.node = node; 1.85 + this.pie = pie; 1.86 + this.table = table; 1.87 + EventEmitter.decorate(this); 1.88 +} 1.89 + 1.90 +/** 1.91 + * Creates the DOM for a pie+table chart. 1.92 + * 1.93 + * @param nsIDocument document 1.94 + * The document responsible with creating the DOM. 1.95 + * @param object 1.96 + * An object containing all or some of the following properties: 1.97 + * - title: a string displayed as the table chart's (description)/local 1.98 + * - diameter: the diameter of the pie chart, in pixels 1.99 + * - data: an array of items used to display each slice in the pie 1.100 + * and each row in the table; 1.101 + * @see `createPieChart` and `createTableChart` for details. 1.102 + * - strings: @see `createTableChart` for details. 1.103 + * - totals: @see `createTableChart` for details. 1.104 + * - sorted: a flag specifying if the `data` should be sorted 1.105 + * ascending by `size`. 1.106 + * @return PieTableChart 1.107 + * A pie+table chart proxy instance, which emits the following events: 1.108 + * - "mouseenter", when the mouse enters a slice or a row 1.109 + * - "mouseleave", when the mouse leaves a slice or a row 1.110 + * - "click", when the mouse enters a slice or a row 1.111 + */ 1.112 +function createPieTableChart(document, { title, diameter, data, strings, totals, sorted }) { 1.113 + if (data && sorted) { 1.114 + data = data.slice().sort((a, b) => +(a.size < b.size)); 1.115 + } 1.116 + 1.117 + let pie = Chart.Pie(document, { 1.118 + width: diameter, 1.119 + data: data 1.120 + }); 1.121 + 1.122 + let table = Chart.Table(document, { 1.123 + title: title, 1.124 + data: data, 1.125 + strings: strings, 1.126 + totals: totals 1.127 + }); 1.128 + 1.129 + let container = document.createElement("hbox"); 1.130 + container.className = "pie-table-chart-container"; 1.131 + container.appendChild(pie.node); 1.132 + container.appendChild(table.node); 1.133 + 1.134 + let proxy = new PieTableChart(container, pie, table); 1.135 + 1.136 + pie.on("click", (event, item) => { 1.137 + proxy.emit(event, item) 1.138 + }); 1.139 + 1.140 + table.on("click", (event, item) => { 1.141 + proxy.emit(event, item) 1.142 + }); 1.143 + 1.144 + pie.on("mouseenter", (event, item) => { 1.145 + proxy.emit(event, item); 1.146 + if (table.rows.has(item)) { 1.147 + table.rows.get(item).setAttribute("focused", ""); 1.148 + } 1.149 + }); 1.150 + 1.151 + pie.on("mouseleave", (event, item) => { 1.152 + proxy.emit(event, item); 1.153 + if (table.rows.has(item)) { 1.154 + table.rows.get(item).removeAttribute("focused"); 1.155 + } 1.156 + }); 1.157 + 1.158 + table.on("mouseenter", (event, item) => { 1.159 + proxy.emit(event, item); 1.160 + if (pie.slices.has(item)) { 1.161 + pie.slices.get(item).setAttribute("focused", ""); 1.162 + } 1.163 + }); 1.164 + 1.165 + table.on("mouseleave", (event, item) => { 1.166 + proxy.emit(event, item); 1.167 + if (pie.slices.has(item)) { 1.168 + pie.slices.get(item).removeAttribute("focused"); 1.169 + } 1.170 + }); 1.171 + 1.172 + return proxy; 1.173 +} 1.174 + 1.175 +/** 1.176 + * Creates the DOM for a pie chart based on the specified properties. 1.177 + * 1.178 + * @param nsIDocument document 1.179 + * The document responsible with creating the DOM. 1.180 + * @param object 1.181 + * An object containing all or some of the following properties: 1.182 + * - data: an array of items used to display each slice; all the items 1.183 + * should be objects containing a `size` and a `label` property. 1.184 + * e.g: [{ 1.185 + * size: 1, 1.186 + * label: "foo" 1.187 + * }, { 1.188 + * size: 2, 1.189 + * label: "bar" 1.190 + * }]; 1.191 + * - width: the width of the chart, in pixels 1.192 + * - height: optional, the height of the chart, in pixels. 1.193 + * - centerX: optional, the X-axis center of the chart, in pixels. 1.194 + * - centerY: optional, the Y-axis center of the chart, in pixels. 1.195 + * - radius: optional, the radius of the chart, in pixels. 1.196 + * @return PieChart 1.197 + * A pie chart proxy instance, which emits the following events: 1.198 + * - "mouseenter", when the mouse enters a slice 1.199 + * - "mouseleave", when the mouse leaves a slice 1.200 + * - "click", when the mouse clicks a slice 1.201 + */ 1.202 +function createPieChart(document, { data, width, height, centerX, centerY, radius }) { 1.203 + height = height || width; 1.204 + centerX = centerX || width / 2; 1.205 + centerY = centerY || height / 2; 1.206 + radius = radius || (width + height) / 4; 1.207 + let isPlaceholder = false; 1.208 + 1.209 + // Filter out very small sizes, as they'll just render invisible slices. 1.210 + data = data ? data.filter(e => e.size > EPSILON) : null; 1.211 + 1.212 + // If there's no data available, display an empty placeholder. 1.213 + if (!data) { 1.214 + data = loadingPieChartData; 1.215 + isPlaceholder = true; 1.216 + } 1.217 + if (!data.length) { 1.218 + data = emptyPieChartData; 1.219 + isPlaceholder = true; 1.220 + } 1.221 + 1.222 + let container = document.createElementNS(SVG_NS, "svg"); 1.223 + container.setAttribute("class", "generic-chart-container pie-chart-container"); 1.224 + container.setAttribute("pack", "center"); 1.225 + container.setAttribute("flex", "1"); 1.226 + container.setAttribute("width", width); 1.227 + container.setAttribute("height", height); 1.228 + container.setAttribute("viewBox", "0 0 " + width + " " + height); 1.229 + container.setAttribute("slices", data.length); 1.230 + container.setAttribute("placeholder", isPlaceholder); 1.231 + 1.232 + let proxy = new PieChart(container); 1.233 + 1.234 + let total = data.reduce((acc, e) => acc + e.size, 0); 1.235 + let angles = data.map(e => e.size / total * (TAU - EPSILON)); 1.236 + let largest = data.reduce((a, b) => a.size > b.size ? a : b); 1.237 + let smallest = data.reduce((a, b) => a.size < b.size ? a : b); 1.238 + 1.239 + let textDistance = radius / NAMED_SLICE_TEXT_DISTANCE_RATIO; 1.240 + let translateDistance = radius / HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO; 1.241 + let startAngle = TAU; 1.242 + let endAngle = 0; 1.243 + let midAngle = 0; 1.244 + radius -= translateDistance; 1.245 + 1.246 + for (let i = data.length - 1; i >= 0; i--) { 1.247 + let sliceInfo = data[i]; 1.248 + let sliceAngle = angles[i]; 1.249 + if (!sliceInfo.size || sliceAngle < EPSILON) { 1.250 + continue; 1.251 + } 1.252 + 1.253 + endAngle = startAngle - sliceAngle; 1.254 + midAngle = (startAngle + endAngle) / 2; 1.255 + 1.256 + let x1 = centerX + radius * Math.sin(startAngle); 1.257 + let y1 = centerY - radius * Math.cos(startAngle); 1.258 + let x2 = centerX + radius * Math.sin(endAngle); 1.259 + let y2 = centerY - radius * Math.cos(endAngle); 1.260 + let largeArcFlag = Math.abs(startAngle - endAngle) > PI ? 1 : 0; 1.261 + 1.262 + let pathNode = document.createElementNS(SVG_NS, "path"); 1.263 + pathNode.setAttribute("class", "pie-chart-slice chart-colored-blob"); 1.264 + pathNode.setAttribute("name", sliceInfo.label); 1.265 + pathNode.setAttribute("d", 1.266 + " M " + centerX + "," + centerY + 1.267 + " L " + x2 + "," + y2 + 1.268 + " A " + radius + "," + radius + 1.269 + " 0 " + largeArcFlag + 1.270 + " 1 " + x1 + "," + y1 + 1.271 + " Z"); 1.272 + 1.273 + if (sliceInfo == largest) { 1.274 + pathNode.setAttribute("largest", ""); 1.275 + } 1.276 + if (sliceInfo == smallest) { 1.277 + pathNode.setAttribute("smallest", ""); 1.278 + } 1.279 + 1.280 + let hoverX = translateDistance * Math.sin(midAngle); 1.281 + let hoverY = -translateDistance * Math.cos(midAngle); 1.282 + let hoverTransform = "transform: translate(" + hoverX + "px, " + hoverY + "px)"; 1.283 + pathNode.setAttribute("style", data.length > 1 ? hoverTransform : ""); 1.284 + 1.285 + proxy.slices.set(sliceInfo, pathNode); 1.286 + delegate(proxy, ["click", "mouseenter", "mouseleave"], pathNode, sliceInfo); 1.287 + container.appendChild(pathNode); 1.288 + 1.289 + if (sliceInfo.label && sliceAngle > NAMED_SLICE_MIN_ANGLE) { 1.290 + let textX = centerX + textDistance * Math.sin(midAngle); 1.291 + let textY = centerY - textDistance * Math.cos(midAngle); 1.292 + let label = document.createElementNS(SVG_NS, "text"); 1.293 + label.appendChild(document.createTextNode(sliceInfo.label)); 1.294 + label.setAttribute("class", "pie-chart-label"); 1.295 + label.setAttribute("style", data.length > 1 ? hoverTransform : ""); 1.296 + label.setAttribute("x", data.length > 1 ? textX : centerX); 1.297 + label.setAttribute("y", data.length > 1 ? textY : centerY); 1.298 + container.appendChild(label); 1.299 + } 1.300 + 1.301 + startAngle = endAngle; 1.302 + } 1.303 + 1.304 + return proxy; 1.305 +} 1.306 + 1.307 +/** 1.308 + * Creates the DOM for a table chart based on the specified properties. 1.309 + * 1.310 + * @param nsIDocument document 1.311 + * The document responsible with creating the DOM. 1.312 + * @param object 1.313 + * An object containing all or some of the following properties: 1.314 + * - title: a string displayed as the chart's (description)/local 1.315 + * - data: an array of items used to display each row; all the items 1.316 + * should be objects representing columns, for which the 1.317 + * properties' values will be displayed in each cell of a row. 1.318 + * e.g: [{ 1.319 + * label1: 1, 1.320 + * label2: 3, 1.321 + * label3: "foo" 1.322 + * }, { 1.323 + * label1: 4, 1.324 + * label2: 6, 1.325 + * label3: "bar 1.326 + * }]; 1.327 + * - strings: an object specifying for which rows in the `data` array 1.328 + * their cell values should be stringified and localized 1.329 + * based on a predicate function; 1.330 + * e.g: { 1.331 + * label1: value => l10n.getFormatStr("...", value) 1.332 + * } 1.333 + * - totals: an object specifying for which rows in the `data` array 1.334 + * the sum of their cells is to be displayed in the chart; 1.335 + * e.g: { 1.336 + * label1: total => l10n.getFormatStr("...", total), // 5 1.337 + * label2: total => l10n.getFormatStr("...", total), // 9 1.338 + * } 1.339 + * @return TableChart 1.340 + * A table chart proxy instance, which emits the following events: 1.341 + * - "mouseenter", when the mouse enters a row 1.342 + * - "mouseleave", when the mouse leaves a row 1.343 + * - "click", when the mouse clicks a row 1.344 + */ 1.345 +function createTableChart(document, { title, data, strings, totals }) { 1.346 + strings = strings || {}; 1.347 + totals = totals || {}; 1.348 + let isPlaceholder = false; 1.349 + 1.350 + // If there's no data available, display an empty placeholder. 1.351 + if (!data) { 1.352 + data = loadingTableChartData; 1.353 + isPlaceholder = true; 1.354 + } 1.355 + if (!data.length) { 1.356 + data = emptyTableChartData; 1.357 + isPlaceholder = true; 1.358 + } 1.359 + 1.360 + let container = document.createElement("vbox"); 1.361 + container.className = "generic-chart-container table-chart-container"; 1.362 + container.setAttribute("pack", "center"); 1.363 + container.setAttribute("flex", "1"); 1.364 + container.setAttribute("rows", data.length); 1.365 + container.setAttribute("placeholder", isPlaceholder); 1.366 + 1.367 + let proxy = new TableChart(container); 1.368 + 1.369 + let titleNode = document.createElement("label"); 1.370 + titleNode.className = "plain table-chart-title"; 1.371 + titleNode.setAttribute("value", title); 1.372 + container.appendChild(titleNode); 1.373 + 1.374 + let tableNode = document.createElement("vbox"); 1.375 + tableNode.className = "plain table-chart-grid"; 1.376 + container.appendChild(tableNode); 1.377 + 1.378 + for (let rowInfo of data) { 1.379 + let rowNode = document.createElement("hbox"); 1.380 + rowNode.className = "table-chart-row"; 1.381 + rowNode.setAttribute("align", "center"); 1.382 + 1.383 + let boxNode = document.createElement("hbox"); 1.384 + boxNode.className = "table-chart-row-box chart-colored-blob"; 1.385 + boxNode.setAttribute("name", rowInfo.label); 1.386 + rowNode.appendChild(boxNode); 1.387 + 1.388 + for (let [key, value] in Iterator(rowInfo)) { 1.389 + let index = data.indexOf(rowInfo); 1.390 + let stringified = strings[key] ? strings[key](value, index) : value; 1.391 + let labelNode = document.createElement("label"); 1.392 + labelNode.className = "plain table-chart-row-label"; 1.393 + labelNode.setAttribute("name", key); 1.394 + labelNode.setAttribute("value", stringified); 1.395 + rowNode.appendChild(labelNode); 1.396 + } 1.397 + 1.398 + proxy.rows.set(rowInfo, rowNode); 1.399 + delegate(proxy, ["click", "mouseenter", "mouseleave"], rowNode, rowInfo); 1.400 + tableNode.appendChild(rowNode); 1.401 + } 1.402 + 1.403 + let totalsNode = document.createElement("vbox"); 1.404 + totalsNode.className = "table-chart-totals"; 1.405 + 1.406 + for (let [key, value] in Iterator(totals)) { 1.407 + let total = data.reduce((acc, e) => acc + e[key], 0); 1.408 + let stringified = totals[key] ? totals[key](total || 0) : total; 1.409 + let labelNode = document.createElement("label"); 1.410 + labelNode.className = "plain table-chart-summary-label"; 1.411 + labelNode.setAttribute("name", key); 1.412 + labelNode.setAttribute("value", stringified); 1.413 + totalsNode.appendChild(labelNode); 1.414 + } 1.415 + 1.416 + container.appendChild(totalsNode); 1.417 + 1.418 + return proxy; 1.419 +} 1.420 + 1.421 +XPCOMUtils.defineLazyGetter(this, "loadingPieChartData", () => { 1.422 + return [{ size: 1, label: L10N.getStr("pieChart.loading") }]; 1.423 +}); 1.424 + 1.425 +XPCOMUtils.defineLazyGetter(this, "emptyPieChartData", () => { 1.426 + return [{ size: 1, label: L10N.getStr("pieChart.unavailable") }]; 1.427 +}); 1.428 + 1.429 +XPCOMUtils.defineLazyGetter(this, "loadingTableChartData", () => { 1.430 + return [{ size: "", label: L10N.getStr("tableChart.loading") }]; 1.431 +}); 1.432 + 1.433 +XPCOMUtils.defineLazyGetter(this, "emptyTableChartData", () => { 1.434 + return [{ size: "", label: L10N.getStr("tableChart.unavailable") }]; 1.435 +}); 1.436 + 1.437 +/** 1.438 + * Delegates DOM events emitted by an nsIDOMNode to an EventEmitter proxy. 1.439 + * 1.440 + * @param EventEmitter emitter 1.441 + * The event emitter proxy instance. 1.442 + * @param array events 1.443 + * An array of events, e.g. ["mouseenter", "mouseleave"]. 1.444 + * @param nsIDOMNode node 1.445 + * The element firing the DOM events. 1.446 + * @param any args 1.447 + * The arguments passed when emitting events through the proxy. 1.448 + */ 1.449 +function delegate(emitter, events, node, args) { 1.450 + for (let event of events) { 1.451 + node.addEventListener(event, emitter.emit.bind(emitter, event, args)); 1.452 + } 1.453 +}