michael@0: /* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ michael@0: /* vim: set ft=javascript ts=2 et sw=2 tw=80: */ michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: "use strict"; michael@0: michael@0: const Ci = Components.interfaces; michael@0: const Cu = Components.utils; michael@0: michael@0: const NET_STRINGS_URI = "chrome://browser/locale/devtools/netmonitor.properties"; michael@0: const SVG_NS = "http://www.w3.org/2000/svg"; michael@0: const PI = Math.PI; michael@0: const TAU = PI * 2; michael@0: const EPSILON = 0.0000001; michael@0: const NAMED_SLICE_MIN_ANGLE = TAU / 8; michael@0: const NAMED_SLICE_TEXT_DISTANCE_RATIO = 1.9; michael@0: const HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO = 20; michael@0: michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); michael@0: Cu.import("resource://gre/modules/devtools/event-emitter.js"); michael@0: michael@0: this.EXPORTED_SYMBOLS = ["Chart"]; michael@0: michael@0: /** michael@0: * Localization convenience methods. michael@0: */ michael@0: let L10N = new ViewHelpers.L10N(NET_STRINGS_URI); michael@0: michael@0: /** michael@0: * A factory for creating charts. michael@0: * Example usage: let myChart = Chart.Pie(document, { ... }); michael@0: */ michael@0: let Chart = { michael@0: Pie: createPieChart, michael@0: Table: createTableChart, michael@0: PieTable: createPieTableChart michael@0: }; michael@0: michael@0: /** michael@0: * A simple pie chart proxy for the underlying view. michael@0: * Each item in the `slices` property represents a [data, node] pair containing michael@0: * the data used to create the slice and the nsIDOMNode displaying it. michael@0: * michael@0: * @param nsIDOMNode node michael@0: * The node representing the view for this chart. michael@0: */ michael@0: function PieChart(node) { michael@0: this.node = node; michael@0: this.slices = new WeakMap(); michael@0: EventEmitter.decorate(this); michael@0: } michael@0: michael@0: /** michael@0: * A simple table chart proxy for the underlying view. michael@0: * Each item in the `rows` property represents a [data, node] pair containing michael@0: * the data used to create the row and the nsIDOMNode displaying it. michael@0: * michael@0: * @param nsIDOMNode node michael@0: * The node representing the view for this chart. michael@0: */ michael@0: function TableChart(node) { michael@0: this.node = node; michael@0: this.rows = new WeakMap(); michael@0: EventEmitter.decorate(this); michael@0: } michael@0: michael@0: /** michael@0: * A simple pie+table chart proxy for the underlying view. michael@0: * michael@0: * @param nsIDOMNode node michael@0: * The node representing the view for this chart. michael@0: * @param PieChart pie michael@0: * The pie chart proxy. michael@0: * @param TableChart table michael@0: * The table chart proxy. michael@0: */ michael@0: function PieTableChart(node, pie, table) { michael@0: this.node = node; michael@0: this.pie = pie; michael@0: this.table = table; michael@0: EventEmitter.decorate(this); michael@0: } michael@0: michael@0: /** michael@0: * Creates the DOM for a pie+table chart. michael@0: * michael@0: * @param nsIDocument document michael@0: * The document responsible with creating the DOM. michael@0: * @param object michael@0: * An object containing all or some of the following properties: michael@0: * - title: a string displayed as the table chart's (description)/local michael@0: * - diameter: the diameter of the pie chart, in pixels michael@0: * - data: an array of items used to display each slice in the pie michael@0: * and each row in the table; michael@0: * @see `createPieChart` and `createTableChart` for details. michael@0: * - strings: @see `createTableChart` for details. michael@0: * - totals: @see `createTableChart` for details. michael@0: * - sorted: a flag specifying if the `data` should be sorted michael@0: * ascending by `size`. michael@0: * @return PieTableChart michael@0: * A pie+table chart proxy instance, which emits the following events: michael@0: * - "mouseenter", when the mouse enters a slice or a row michael@0: * - "mouseleave", when the mouse leaves a slice or a row michael@0: * - "click", when the mouse enters a slice or a row michael@0: */ michael@0: function createPieTableChart(document, { title, diameter, data, strings, totals, sorted }) { michael@0: if (data && sorted) { michael@0: data = data.slice().sort((a, b) => +(a.size < b.size)); michael@0: } michael@0: michael@0: let pie = Chart.Pie(document, { michael@0: width: diameter, michael@0: data: data michael@0: }); michael@0: michael@0: let table = Chart.Table(document, { michael@0: title: title, michael@0: data: data, michael@0: strings: strings, michael@0: totals: totals michael@0: }); michael@0: michael@0: let container = document.createElement("hbox"); michael@0: container.className = "pie-table-chart-container"; michael@0: container.appendChild(pie.node); michael@0: container.appendChild(table.node); michael@0: michael@0: let proxy = new PieTableChart(container, pie, table); michael@0: michael@0: pie.on("click", (event, item) => { michael@0: proxy.emit(event, item) michael@0: }); michael@0: michael@0: table.on("click", (event, item) => { michael@0: proxy.emit(event, item) michael@0: }); michael@0: michael@0: pie.on("mouseenter", (event, item) => { michael@0: proxy.emit(event, item); michael@0: if (table.rows.has(item)) { michael@0: table.rows.get(item).setAttribute("focused", ""); michael@0: } michael@0: }); michael@0: michael@0: pie.on("mouseleave", (event, item) => { michael@0: proxy.emit(event, item); michael@0: if (table.rows.has(item)) { michael@0: table.rows.get(item).removeAttribute("focused"); michael@0: } michael@0: }); michael@0: michael@0: table.on("mouseenter", (event, item) => { michael@0: proxy.emit(event, item); michael@0: if (pie.slices.has(item)) { michael@0: pie.slices.get(item).setAttribute("focused", ""); michael@0: } michael@0: }); michael@0: michael@0: table.on("mouseleave", (event, item) => { michael@0: proxy.emit(event, item); michael@0: if (pie.slices.has(item)) { michael@0: pie.slices.get(item).removeAttribute("focused"); michael@0: } michael@0: }); michael@0: michael@0: return proxy; michael@0: } michael@0: michael@0: /** michael@0: * Creates the DOM for a pie chart based on the specified properties. michael@0: * michael@0: * @param nsIDocument document michael@0: * The document responsible with creating the DOM. michael@0: * @param object michael@0: * An object containing all or some of the following properties: michael@0: * - data: an array of items used to display each slice; all the items michael@0: * should be objects containing a `size` and a `label` property. michael@0: * e.g: [{ michael@0: * size: 1, michael@0: * label: "foo" michael@0: * }, { michael@0: * size: 2, michael@0: * label: "bar" michael@0: * }]; michael@0: * - width: the width of the chart, in pixels michael@0: * - height: optional, the height of the chart, in pixels. michael@0: * - centerX: optional, the X-axis center of the chart, in pixels. michael@0: * - centerY: optional, the Y-axis center of the chart, in pixels. michael@0: * - radius: optional, the radius of the chart, in pixels. michael@0: * @return PieChart michael@0: * A pie chart proxy instance, which emits the following events: michael@0: * - "mouseenter", when the mouse enters a slice michael@0: * - "mouseleave", when the mouse leaves a slice michael@0: * - "click", when the mouse clicks a slice michael@0: */ michael@0: function createPieChart(document, { data, width, height, centerX, centerY, radius }) { michael@0: height = height || width; michael@0: centerX = centerX || width / 2; michael@0: centerY = centerY || height / 2; michael@0: radius = radius || (width + height) / 4; michael@0: let isPlaceholder = false; michael@0: michael@0: // Filter out very small sizes, as they'll just render invisible slices. michael@0: data = data ? data.filter(e => e.size > EPSILON) : null; michael@0: michael@0: // If there's no data available, display an empty placeholder. michael@0: if (!data) { michael@0: data = loadingPieChartData; michael@0: isPlaceholder = true; michael@0: } michael@0: if (!data.length) { michael@0: data = emptyPieChartData; michael@0: isPlaceholder = true; michael@0: } michael@0: michael@0: let container = document.createElementNS(SVG_NS, "svg"); michael@0: container.setAttribute("class", "generic-chart-container pie-chart-container"); michael@0: container.setAttribute("pack", "center"); michael@0: container.setAttribute("flex", "1"); michael@0: container.setAttribute("width", width); michael@0: container.setAttribute("height", height); michael@0: container.setAttribute("viewBox", "0 0 " + width + " " + height); michael@0: container.setAttribute("slices", data.length); michael@0: container.setAttribute("placeholder", isPlaceholder); michael@0: michael@0: let proxy = new PieChart(container); michael@0: michael@0: let total = data.reduce((acc, e) => acc + e.size, 0); michael@0: let angles = data.map(e => e.size / total * (TAU - EPSILON)); michael@0: let largest = data.reduce((a, b) => a.size > b.size ? a : b); michael@0: let smallest = data.reduce((a, b) => a.size < b.size ? a : b); michael@0: michael@0: let textDistance = radius / NAMED_SLICE_TEXT_DISTANCE_RATIO; michael@0: let translateDistance = radius / HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO; michael@0: let startAngle = TAU; michael@0: let endAngle = 0; michael@0: let midAngle = 0; michael@0: radius -= translateDistance; michael@0: michael@0: for (let i = data.length - 1; i >= 0; i--) { michael@0: let sliceInfo = data[i]; michael@0: let sliceAngle = angles[i]; michael@0: if (!sliceInfo.size || sliceAngle < EPSILON) { michael@0: continue; michael@0: } michael@0: michael@0: endAngle = startAngle - sliceAngle; michael@0: midAngle = (startAngle + endAngle) / 2; michael@0: michael@0: let x1 = centerX + radius * Math.sin(startAngle); michael@0: let y1 = centerY - radius * Math.cos(startAngle); michael@0: let x2 = centerX + radius * Math.sin(endAngle); michael@0: let y2 = centerY - radius * Math.cos(endAngle); michael@0: let largeArcFlag = Math.abs(startAngle - endAngle) > PI ? 1 : 0; michael@0: michael@0: let pathNode = document.createElementNS(SVG_NS, "path"); michael@0: pathNode.setAttribute("class", "pie-chart-slice chart-colored-blob"); michael@0: pathNode.setAttribute("name", sliceInfo.label); michael@0: pathNode.setAttribute("d", michael@0: " M " + centerX + "," + centerY + michael@0: " L " + x2 + "," + y2 + michael@0: " A " + radius + "," + radius + michael@0: " 0 " + largeArcFlag + michael@0: " 1 " + x1 + "," + y1 + michael@0: " Z"); michael@0: michael@0: if (sliceInfo == largest) { michael@0: pathNode.setAttribute("largest", ""); michael@0: } michael@0: if (sliceInfo == smallest) { michael@0: pathNode.setAttribute("smallest", ""); michael@0: } michael@0: michael@0: let hoverX = translateDistance * Math.sin(midAngle); michael@0: let hoverY = -translateDistance * Math.cos(midAngle); michael@0: let hoverTransform = "transform: translate(" + hoverX + "px, " + hoverY + "px)"; michael@0: pathNode.setAttribute("style", data.length > 1 ? hoverTransform : ""); michael@0: michael@0: proxy.slices.set(sliceInfo, pathNode); michael@0: delegate(proxy, ["click", "mouseenter", "mouseleave"], pathNode, sliceInfo); michael@0: container.appendChild(pathNode); michael@0: michael@0: if (sliceInfo.label && sliceAngle > NAMED_SLICE_MIN_ANGLE) { michael@0: let textX = centerX + textDistance * Math.sin(midAngle); michael@0: let textY = centerY - textDistance * Math.cos(midAngle); michael@0: let label = document.createElementNS(SVG_NS, "text"); michael@0: label.appendChild(document.createTextNode(sliceInfo.label)); michael@0: label.setAttribute("class", "pie-chart-label"); michael@0: label.setAttribute("style", data.length > 1 ? hoverTransform : ""); michael@0: label.setAttribute("x", data.length > 1 ? textX : centerX); michael@0: label.setAttribute("y", data.length > 1 ? textY : centerY); michael@0: container.appendChild(label); michael@0: } michael@0: michael@0: startAngle = endAngle; michael@0: } michael@0: michael@0: return proxy; michael@0: } michael@0: michael@0: /** michael@0: * Creates the DOM for a table chart based on the specified properties. michael@0: * michael@0: * @param nsIDocument document michael@0: * The document responsible with creating the DOM. michael@0: * @param object michael@0: * An object containing all or some of the following properties: michael@0: * - title: a string displayed as the chart's (description)/local michael@0: * - data: an array of items used to display each row; all the items michael@0: * should be objects representing columns, for which the michael@0: * properties' values will be displayed in each cell of a row. michael@0: * e.g: [{ michael@0: * label1: 1, michael@0: * label2: 3, michael@0: * label3: "foo" michael@0: * }, { michael@0: * label1: 4, michael@0: * label2: 6, michael@0: * label3: "bar michael@0: * }]; michael@0: * - strings: an object specifying for which rows in the `data` array michael@0: * their cell values should be stringified and localized michael@0: * based on a predicate function; michael@0: * e.g: { michael@0: * label1: value => l10n.getFormatStr("...", value) michael@0: * } michael@0: * - totals: an object specifying for which rows in the `data` array michael@0: * the sum of their cells is to be displayed in the chart; michael@0: * e.g: { michael@0: * label1: total => l10n.getFormatStr("...", total), // 5 michael@0: * label2: total => l10n.getFormatStr("...", total), // 9 michael@0: * } michael@0: * @return TableChart michael@0: * A table chart proxy instance, which emits the following events: michael@0: * - "mouseenter", when the mouse enters a row michael@0: * - "mouseleave", when the mouse leaves a row michael@0: * - "click", when the mouse clicks a row michael@0: */ michael@0: function createTableChart(document, { title, data, strings, totals }) { michael@0: strings = strings || {}; michael@0: totals = totals || {}; michael@0: let isPlaceholder = false; michael@0: michael@0: // If there's no data available, display an empty placeholder. michael@0: if (!data) { michael@0: data = loadingTableChartData; michael@0: isPlaceholder = true; michael@0: } michael@0: if (!data.length) { michael@0: data = emptyTableChartData; michael@0: isPlaceholder = true; michael@0: } michael@0: michael@0: let container = document.createElement("vbox"); michael@0: container.className = "generic-chart-container table-chart-container"; michael@0: container.setAttribute("pack", "center"); michael@0: container.setAttribute("flex", "1"); michael@0: container.setAttribute("rows", data.length); michael@0: container.setAttribute("placeholder", isPlaceholder); michael@0: michael@0: let proxy = new TableChart(container); michael@0: michael@0: let titleNode = document.createElement("label"); michael@0: titleNode.className = "plain table-chart-title"; michael@0: titleNode.setAttribute("value", title); michael@0: container.appendChild(titleNode); michael@0: michael@0: let tableNode = document.createElement("vbox"); michael@0: tableNode.className = "plain table-chart-grid"; michael@0: container.appendChild(tableNode); michael@0: michael@0: for (let rowInfo of data) { michael@0: let rowNode = document.createElement("hbox"); michael@0: rowNode.className = "table-chart-row"; michael@0: rowNode.setAttribute("align", "center"); michael@0: michael@0: let boxNode = document.createElement("hbox"); michael@0: boxNode.className = "table-chart-row-box chart-colored-blob"; michael@0: boxNode.setAttribute("name", rowInfo.label); michael@0: rowNode.appendChild(boxNode); michael@0: michael@0: for (let [key, value] in Iterator(rowInfo)) { michael@0: let index = data.indexOf(rowInfo); michael@0: let stringified = strings[key] ? strings[key](value, index) : value; michael@0: let labelNode = document.createElement("label"); michael@0: labelNode.className = "plain table-chart-row-label"; michael@0: labelNode.setAttribute("name", key); michael@0: labelNode.setAttribute("value", stringified); michael@0: rowNode.appendChild(labelNode); michael@0: } michael@0: michael@0: proxy.rows.set(rowInfo, rowNode); michael@0: delegate(proxy, ["click", "mouseenter", "mouseleave"], rowNode, rowInfo); michael@0: tableNode.appendChild(rowNode); michael@0: } michael@0: michael@0: let totalsNode = document.createElement("vbox"); michael@0: totalsNode.className = "table-chart-totals"; michael@0: michael@0: for (let [key, value] in Iterator(totals)) { michael@0: let total = data.reduce((acc, e) => acc + e[key], 0); michael@0: let stringified = totals[key] ? totals[key](total || 0) : total; michael@0: let labelNode = document.createElement("label"); michael@0: labelNode.className = "plain table-chart-summary-label"; michael@0: labelNode.setAttribute("name", key); michael@0: labelNode.setAttribute("value", stringified); michael@0: totalsNode.appendChild(labelNode); michael@0: } michael@0: michael@0: container.appendChild(totalsNode); michael@0: michael@0: return proxy; michael@0: } michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "loadingPieChartData", () => { michael@0: return [{ size: 1, label: L10N.getStr("pieChart.loading") }]; michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "emptyPieChartData", () => { michael@0: return [{ size: 1, label: L10N.getStr("pieChart.unavailable") }]; michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "loadingTableChartData", () => { michael@0: return [{ size: "", label: L10N.getStr("tableChart.loading") }]; michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "emptyTableChartData", () => { michael@0: return [{ size: "", label: L10N.getStr("tableChart.unavailable") }]; michael@0: }); michael@0: michael@0: /** michael@0: * Delegates DOM events emitted by an nsIDOMNode to an EventEmitter proxy. michael@0: * michael@0: * @param EventEmitter emitter michael@0: * The event emitter proxy instance. michael@0: * @param array events michael@0: * An array of events, e.g. ["mouseenter", "mouseleave"]. michael@0: * @param nsIDOMNode node michael@0: * The element firing the DOM events. michael@0: * @param any args michael@0: * The arguments passed when emitting events through the proxy. michael@0: */ michael@0: function delegate(emitter, events, node, args) { michael@0: for (let event of events) { michael@0: node.addEventListener(event, emitter.emit.bind(emitter, event, args)); michael@0: } michael@0: }