/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const NET_STRINGS_URI = "devtools/client/locales/netmonitor.properties"; const SVG_NS = "http://www.w3.org/2000/svg"; const PI = Math.PI; const TAU = PI * 2; const EPSILON = 0.0000001; const NAMED_SLICE_MIN_ANGLE = TAU / 8; const NAMED_SLICE_TEXT_DISTANCE_RATIO = 1.9; const HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO = 20; const EventEmitter = require("resource://devtools/shared/event-emitter.js"); const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); const L10N = new LocalizationHelper(NET_STRINGS_URI); /** * A factory for creating charts. * Example usage: let myChart = Chart.Pie(document, { ... }); */ var Chart = { Pie: createPieChart, Table: createTableChart, PieTable: createPieTableChart, }; /** * A simple pie chart proxy for the underlying view. * Each item in the `slices` property represents a [data, node] pair containing * the data used to create the slice and the Node displaying it. * * @param Node node * The node representing the view for this chart. */ function PieChart(node) { this.node = node; this.slices = new WeakMap(); EventEmitter.decorate(this); } /** * A simple table chart proxy for the underlying view. * Each item in the `rows` property represents a [data, node] pair containing * the data used to create the row and the Node displaying it. * * @param Node node * The node representing the view for this chart. */ function TableChart(node) { this.node = node; this.rows = new WeakMap(); EventEmitter.decorate(this); } /** * A simple pie+table chart proxy for the underlying view. * * @param Node node * The node representing the view for this chart. * @param PieChart pie * The pie chart proxy. * @param TableChart table * The table chart proxy. */ function PieTableChart(node, pie, table) { this.node = node; this.pie = pie; this.table = table; EventEmitter.decorate(this); } /** * Creates the DOM for a pie+table chart. * * @param Document document * The document responsible with creating the DOM. * @param object * An object containing all or some of the following properties: * - title: a string displayed as the table chart's (description)/local * - diameter: the diameter of the pie chart, in pixels * - data: an array of items used to display each slice in the pie * and each row in the table; * @see `createPieChart` and `createTableChart` for details. * - strings: @see `createTableChart` for details. * - totals: @see `createTableChart` for details. * - sorted: a flag specifying if the `data` should be sorted * ascending by `size`. * @return PieTableChart * A pie+table chart proxy instance, which emits the following events: * - "mouseover", when the mouse enters a slice or a row * - "mouseout", when the mouse leaves a slice or a row * - "click", when the mouse enters a slice or a row */ function createPieTableChart( document, { title, diameter, data, strings, totals, sorted, header } ) { if (data && sorted) { data = data.slice().sort((a, b) => +(a.size < b.size)); } const pie = Chart.Pie(document, { width: diameter, data, }); const table = Chart.Table(document, { title, data, strings, totals, header, }); const container = document.createElement("div"); container.className = "pie-table-chart-container"; container.appendChild(pie.node); container.appendChild(table.node); const proxy = new PieTableChart(container, pie, table); pie.on("click", item => { proxy.emit("click", item); }); table.on("click", item => { proxy.emit("click", item); }); pie.on("mouseover", item => { proxy.emit("mouseover", item); if (table.rows.has(item)) { table.rows.get(item).setAttribute("focused", ""); } }); pie.on("mouseout", item => { proxy.emit("mouseout", item); if (table.rows.has(item)) { table.rows.get(item).removeAttribute("focused"); } }); table.on("mouseover", item => { proxy.emit("mouseover", item); if (pie.slices.has(item)) { pie.slices.get(item).setAttribute("focused", ""); } }); table.on("mouseout", item => { proxy.emit("mouseout", item); if (pie.slices.has(item)) { pie.slices.get(item).removeAttribute("focused"); } }); return proxy; } /** * Creates the DOM for a pie chart based on the specified properties. * * @param Document document * The document responsible with creating the DOM. * @param object * An object containing all or some of the following properties: * - data: an array of items used to display each slice; all the items * should be objects containing a `size` and a `label` property. * e.g: [{ * size: 1, * label: "foo" * }, { * size: 2, * label: "bar" * }]; * - width: the width of the chart, in pixels * - height: optional, the height of the chart, in pixels. * - centerX: optional, the X-axis center of the chart, in pixels. * - centerY: optional, the Y-axis center of the chart, in pixels. * - radius: optional, the radius of the chart, in pixels. * @return PieChart * A pie chart proxy instance, which emits the following events: * - "mouseover", when the mouse enters a slice * - "mouseout", when the mouse leaves a slice * - "click", when the mouse clicks a slice */ function createPieChart( document, { data, width, height, centerX, centerY, radius } ) { height = height || width; centerX = centerX || width / 2; centerY = centerY || height / 2; radius = radius || (width + height) / 4; let isPlaceholder = false; // If there's no data available, display an empty placeholder. if (!data) { data = loadingPieChartData(); isPlaceholder = true; } if (!data.length) { data = emptyPieChartData(); isPlaceholder = true; } const container = document.createElementNS(SVG_NS, "svg"); container.setAttribute( "class", "generic-chart-container pie-chart-container" ); container.setAttribute("width", width); container.setAttribute("height", height); container.setAttribute("viewBox", "0 0 " + width + " " + height); container.setAttribute("slices", data.length); container.setAttribute("placeholder", isPlaceholder); container.setAttribute("role", "group"); container.setAttribute("aria-label", L10N.getStr("pieChart.ariaLabel")); const slicesGroup = document.createElementNS(SVG_NS, "g"); slicesGroup.setAttribute("role", "list"); container.append(slicesGroup); const proxy = new PieChart(container); const total = data.reduce((acc, e) => acc + e.size, 0); const angles = data.map(e => (e.size / total) * (TAU - EPSILON)); const largest = data.reduce((a, b) => (a.size > b.size ? a : b)); const smallest = data.reduce((a, b) => (a.size < b.size ? a : b)); const textDistance = radius / NAMED_SLICE_TEXT_DISTANCE_RATIO; const translateDistance = radius / HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO; let startAngle = TAU; let endAngle = 0; let midAngle = 0; radius -= translateDistance; for (let i = data.length - 1; i >= 0; i--) { const sliceInfo = data[i]; const sliceAngle = angles[i]; const sliceNode = document.createElementNS(SVG_NS, "g"); sliceNode.setAttribute("role", "listitem"); slicesGroup.append(sliceNode); const interactiveNodeId = `${sliceInfo.label}-slice`; const textNodeId = `${sliceInfo.label}-slice-label`; // The only way to make this keyboard accessible is to have a link const interactiveNode = document.createElementNS(SVG_NS, "a"); interactiveNode.setAttribute("id", interactiveNodeId); interactiveNode.setAttribute("xlink:href", `#${interactiveNodeId}`); interactiveNode.setAttribute("tabindex", `0`); interactiveNode.setAttribute("role", `button`); interactiveNode.classList.add("pie-chart-slice-container"); if (!isPlaceholder) { interactiveNode.setAttribute( "aria-label", L10N.getFormatStr( "pieChart.sliceAriaLabel", sliceInfo.label, new Intl.NumberFormat(undefined, { style: "unit", unit: "percent", maximumFractionDigits: 2, }).format((sliceInfo.size / total) * 100) ) ); } sliceNode.append(interactiveNode); endAngle = startAngle - sliceAngle; midAngle = (startAngle + endAngle) / 2; const x1 = centerX + radius * Math.sin(startAngle); const y1 = centerY - radius * Math.cos(startAngle); const x2 = centerX + radius * Math.sin(endAngle); const y2 = centerY - radius * Math.cos(endAngle); const largeArcFlag = Math.abs(startAngle - endAngle) > PI ? 1 : 0; const pathNode = document.createElementNS(SVG_NS, "path"); pathNode.classList.add("pie-chart-slice"); pathNode.setAttribute("data-statistic-name", sliceInfo.label); pathNode.setAttribute( "d", " M " + centerX + "," + centerY + " L " + x2 + "," + y2 + " A " + radius + "," + radius + " 0 " + largeArcFlag + " 1 " + x1 + "," + y1 + " Z" ); if (sliceInfo == largest) { pathNode.setAttribute("largest", ""); } if (sliceInfo == smallest) { pathNode.setAttribute("smallest", ""); } const hoverX = translateDistance * Math.sin(midAngle); const hoverY = -translateDistance * Math.cos(midAngle); const hoverTransform = "transform: translate(" + hoverX + "px, " + hoverY + "px)"; pathNode.setAttribute("style", data.length > 1 ? hoverTransform : ""); proxy.slices.set(sliceInfo, pathNode); delegate( proxy, ["click", "mouseover", "mouseout", "focus"], interactiveNode, sliceInfo ); interactiveNode.appendChild(pathNode); const textX = centerX + textDistance * Math.sin(midAngle); const textY = centerY - textDistance * Math.cos(midAngle); // Don't add the label if the slice isn't large enough so it doesn't look cramped. if (sliceAngle >= NAMED_SLICE_MIN_ANGLE) { const label = document.createElementNS(SVG_NS, "text"); label.appendChild(document.createTextNode(sliceInfo.label)); label.setAttribute("id", textNodeId); // A label is already set on `interactiveNode`, so hide this from the accessibility tree // to avoid duplicating text. label.setAttribute("aria-hidden", "true"); label.setAttribute("class", "pie-chart-label"); label.setAttribute("style", data.length > 1 ? hoverTransform : ""); label.setAttribute("x", data.length > 1 ? textX : centerX); label.setAttribute("y", data.length > 1 ? textY : centerY); interactiveNode.append(label); } startAngle = endAngle; } return proxy; } /** * Creates the DOM for a table chart based on the specified properties. * * @param Document document * The document responsible with creating the DOM. * @param object * An object containing all or some of the following properties: * - title: a string displayed as the chart's (description)/local * - data: an array of items used to display each row; all the items * should be objects representing columns, for which the * properties' values will be displayed in each cell of a row. * e.g: [{ * label1: 1, * label2: 3, * label3: "foo" * }, { * label1: 4, * label2: 6, * label3: "bar * }]; * - strings: an object specifying for which rows in the `data` array * their cell values should be stringified and localized * based on a predicate function; * e.g: { * label1: value => l10n.getFormatStr("...", value) * } * - totals: an object specifying for which rows in the `data` array * the sum of their cells is to be displayed in the chart; * e.g: { * label1: total => l10n.getFormatStr("...", total), // 5 * label2: total => l10n.getFormatStr("...", total), // 9 * } * - header: an object specifying strings to use for table column * headers * e.g. { * label1: l10n.getStr(...), * label2: l10n.getStr(...), * } * @return TableChart * A table chart proxy instance, which emits the following events: * - "mouseover", when the mouse enters a row * - "mouseout", when the mouse leaves a row * - "click", when the mouse clicks a row */ function createTableChart(document, { title, data, strings, totals, header }) { strings = strings || {}; totals = totals || {}; header = header || {}; let isPlaceholder = false; // If there's no data available, display an empty placeholder. if (!data) { data = loadingTableChartData(); isPlaceholder = true; } if (!data.length) { data = emptyTableChartData(); isPlaceholder = true; } const container = document.createElement("div"); container.className = "generic-chart-container table-chart-container"; container.setAttribute("placeholder", isPlaceholder); const proxy = new TableChart(container); const titleNode = document.createElement("span"); titleNode.className = "plain table-chart-title"; titleNode.textContent = title; container.appendChild(titleNode); const tableNode = document.createElement("table"); tableNode.className = "plain table-chart-grid"; container.appendChild(tableNode); const headerNode = document.createElement("thead"); headerNode.className = "table-chart-row"; const bodyNode = document.createElement("tbody"); const headerBoxNode = document.createElement("tr"); headerBoxNode.className = "table-chart-row-box"; headerNode.appendChild(headerBoxNode); for (const [key, value] of Object.entries(header)) { const headerLabelNode = document.createElement("th"); headerLabelNode.className = "plain table-chart-row-label"; headerLabelNode.setAttribute("name", key); headerLabelNode.textContent = value; if (key == "count") { headerLabelNode.classList.add("offscreen"); } headerBoxNode.appendChild(headerLabelNode); } tableNode.append(headerNode, bodyNode); for (const rowInfo of data) { const rowNode = document.createElement("tr"); rowNode.className = "table-chart-row"; rowNode.setAttribute("data-statistic-name", rowInfo.label); for (const [key, value] of Object.entries(rowInfo)) { // Don't render the "cached" column. We only have it in here so it can be displayed // in the `totals` section. if (key == "cached") { continue; } const index = data.indexOf(rowInfo); const stringified = strings[key] ? strings[key](value, index) : value; const labelNode = document.createElement("td"); labelNode.className = "plain table-chart-row-label"; labelNode.setAttribute("name", key); labelNode.textContent = stringified; rowNode.appendChild(labelNode); } proxy.rows.set(rowInfo, rowNode); delegate(proxy, ["click", "mouseover", "mouseout"], rowNode, rowInfo); bodyNode.appendChild(rowNode); } const totalsNode = document.createElement("div"); totalsNode.className = "table-chart-totals"; for (const [key, value] of Object.entries(totals)) { const total = data.reduce((acc, e) => acc + e[key], 0); const stringified = value ? value(total || 0) : total; const labelNode = document.createElement("span"); labelNode.className = "plain table-chart-summary-label"; labelNode.setAttribute("name", key); labelNode.textContent = stringified; totalsNode.appendChild(labelNode); } container.appendChild(totalsNode); return proxy; } function loadingPieChartData() { return [{ size: 1, label: L10N.getStr("pieChart.loading") }]; } function emptyPieChartData() { return [{ size: 1, label: L10N.getStr("pieChart.unavailable") }]; } function loadingTableChartData() { return [{ size: "", label: L10N.getStr("tableChart.loading") }]; } function emptyTableChartData() { return [{ size: "", label: L10N.getStr("tableChart.unavailable") }]; } /** * Delegates DOM events emitted by a Node to an EventEmitter proxy. * * @param EventEmitter emitter * The event emitter proxy instance. * @param array events * An array of events, e.g. ["mouseover", "mouseout"]. * @param Node node * The element firing the DOM events. * @param any args * The arguments passed when emitting events through the proxy. */ function delegate(emitter, events, node, args) { for (const event of events) { node.addEventListener(event, emitter.emit.bind(emitter, event, args)); } } exports.Chart = Chart;