diff options
Diffstat (limited to 'devtools/client/shared/widgets/Chart.js')
-rw-r--r-- | devtools/client/shared/widgets/Chart.js | 532 |
1 files changed, 532 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/Chart.js b/devtools/client/shared/widgets/Chart.js new file mode 100644 index 0000000000..574fadbadf --- /dev/null +++ b/devtools/client/shared/widgets/Chart.js @@ -0,0 +1,532 @@ +/* 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; |