Chart.js (17650B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 "use strict"; 5 6 const NET_STRINGS_URI = "devtools/client/locales/netmonitor.properties"; 7 const SVG_NS = "http://www.w3.org/2000/svg"; 8 const PI = Math.PI; 9 const TAU = PI * 2; 10 const EPSILON = 0.0000001; 11 const NAMED_SLICE_MIN_ANGLE = TAU / 8; 12 const NAMED_SLICE_TEXT_DISTANCE_RATIO = 1.9; 13 const HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO = 20; 14 15 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 16 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 17 const L10N = new LocalizationHelper(NET_STRINGS_URI); 18 19 /** 20 * A factory for creating charts. 21 * Example usage: let myChart = Chart.Pie(document, { ... }); 22 */ 23 var Chart = { 24 Pie: createPieChart, 25 Table: createTableChart, 26 PieTable: createPieTableChart, 27 }; 28 29 /** 30 * A simple pie chart proxy for the underlying view. 31 * Each item in the `slices` property represents a [data, node] pair containing 32 * the data used to create the slice and the Node displaying it. 33 * 34 * @param Node node 35 * The node representing the view for this chart. 36 */ 37 function PieChart(node) { 38 this.node = node; 39 this.slices = new WeakMap(); 40 EventEmitter.decorate(this); 41 } 42 43 /** 44 * A simple table chart proxy for the underlying view. 45 * Each item in the `rows` property represents a [data, node] pair containing 46 * the data used to create the row and the Node displaying it. 47 * 48 * @param Node node 49 * The node representing the view for this chart. 50 */ 51 function TableChart(node) { 52 this.node = node; 53 this.rows = new WeakMap(); 54 EventEmitter.decorate(this); 55 } 56 57 /** 58 * A simple pie+table chart proxy for the underlying view. 59 * 60 * @param Node node 61 * The node representing the view for this chart. 62 * @param PieChart pie 63 * The pie chart proxy. 64 * @param TableChart table 65 * The table chart proxy. 66 */ 67 function PieTableChart(node, pie, table) { 68 this.node = node; 69 this.pie = pie; 70 this.table = table; 71 EventEmitter.decorate(this); 72 } 73 74 /** 75 * Creates the DOM for a pie+table chart. 76 * 77 * @param Document document 78 * The document responsible with creating the DOM. 79 * @param object 80 * An object containing all or some of the following properties: 81 * - title: a string displayed as the table chart's (description)/local 82 * - diameter: the diameter of the pie chart, in pixels 83 * - data: an array of items used to display each slice in the pie 84 * and each row in the table; 85 * @see `createPieChart` and `createTableChart` for details. 86 * - strings: @see `createTableChart` for details. 87 * - totals: @see `createTableChart` for details. 88 * - sorted: a flag specifying if the `data` should be sorted 89 * ascending by `size`. 90 * @return PieTableChart 91 * A pie+table chart proxy instance, which emits the following events: 92 * - "mouseover", when the mouse enters a slice or a row 93 * - "mouseout", when the mouse leaves a slice or a row 94 * - "click", when the mouse enters a slice or a row 95 */ 96 function createPieTableChart( 97 document, 98 { title, diameter, data, strings, totals, sorted, header } 99 ) { 100 if (data && sorted) { 101 data = data.slice().sort((a, b) => +(a.size < b.size)); 102 } 103 104 const pie = Chart.Pie(document, { 105 width: diameter, 106 data, 107 }); 108 109 const table = Chart.Table(document, { 110 title, 111 data, 112 strings, 113 totals, 114 header, 115 }); 116 117 const container = document.createElement("div"); 118 container.className = "pie-table-chart-container"; 119 container.appendChild(pie.node); 120 container.appendChild(table.node); 121 122 const proxy = new PieTableChart(container, pie, table); 123 124 pie.on("click", item => { 125 proxy.emit("click", item); 126 }); 127 128 table.on("click", item => { 129 proxy.emit("click", item); 130 }); 131 132 pie.on("mouseover", item => { 133 proxy.emit("mouseover", item); 134 if (table.rows.has(item)) { 135 table.rows.get(item).setAttribute("focused", ""); 136 } 137 }); 138 139 pie.on("mouseout", item => { 140 proxy.emit("mouseout", item); 141 if (table.rows.has(item)) { 142 table.rows.get(item).removeAttribute("focused"); 143 } 144 }); 145 146 table.on("mouseover", item => { 147 proxy.emit("mouseover", item); 148 if (pie.slices.has(item)) { 149 pie.slices.get(item).setAttribute("focused", ""); 150 } 151 }); 152 153 table.on("mouseout", item => { 154 proxy.emit("mouseout", item); 155 if (pie.slices.has(item)) { 156 pie.slices.get(item).removeAttribute("focused"); 157 } 158 }); 159 160 return proxy; 161 } 162 163 /** 164 * Creates the DOM for a pie chart based on the specified properties. 165 * 166 * @param Document document 167 * The document responsible with creating the DOM. 168 * @param object 169 * An object containing all or some of the following properties: 170 * - data: an array of items used to display each slice; all the items 171 * should be objects containing a `size` and a `label` property. 172 * e.g: [{ 173 * size: 1, 174 * label: "foo" 175 * }, { 176 * size: 2, 177 * label: "bar" 178 * }]; 179 * - width: the width of the chart, in pixels 180 * - height: optional, the height of the chart, in pixels. 181 * - centerX: optional, the X-axis center of the chart, in pixels. 182 * - centerY: optional, the Y-axis center of the chart, in pixels. 183 * - radius: optional, the radius of the chart, in pixels. 184 * @return PieChart 185 * A pie chart proxy instance, which emits the following events: 186 * - "mouseover", when the mouse enters a slice 187 * - "mouseout", when the mouse leaves a slice 188 * - "click", when the mouse clicks a slice 189 */ 190 function createPieChart( 191 document, 192 { data, width, height, centerX, centerY, radius } 193 ) { 194 height = height || width; 195 centerX = centerX || width / 2; 196 centerY = centerY || height / 2; 197 radius = radius || (width + height) / 4; 198 let isPlaceholder = false; 199 200 // If there's no data available, display an empty placeholder. 201 if (!data) { 202 data = loadingPieChartData(); 203 isPlaceholder = true; 204 } 205 if (!data.length) { 206 data = emptyPieChartData(); 207 isPlaceholder = true; 208 } 209 210 const container = document.createElementNS(SVG_NS, "svg"); 211 container.setAttribute( 212 "class", 213 "generic-chart-container pie-chart-container" 214 ); 215 216 container.setAttribute("width", width); 217 container.setAttribute("height", height); 218 container.setAttribute("viewBox", "0 0 " + width + " " + height); 219 container.setAttribute("slices", data.length); 220 container.setAttribute("placeholder", isPlaceholder); 221 container.setAttribute("role", "group"); 222 container.setAttribute("aria-label", L10N.getStr("pieChart.ariaLabel")); 223 224 const slicesGroup = document.createElementNS(SVG_NS, "g"); 225 slicesGroup.setAttribute("role", "list"); 226 container.append(slicesGroup); 227 228 const proxy = new PieChart(container); 229 230 const total = data.reduce((acc, e) => acc + e.size, 0); 231 const angles = data.map(e => (e.size / total) * (TAU - EPSILON)); 232 const largest = data.reduce((a, b) => (a.size > b.size ? a : b)); 233 const smallest = data.reduce((a, b) => (a.size < b.size ? a : b)); 234 235 const textDistance = radius / NAMED_SLICE_TEXT_DISTANCE_RATIO; 236 const translateDistance = radius / HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO; 237 let startAngle = TAU; 238 let endAngle = 0; 239 let midAngle = 0; 240 radius -= translateDistance; 241 242 for (let i = data.length - 1; i >= 0; i--) { 243 const sliceInfo = data[i]; 244 const sliceAngle = angles[i]; 245 246 const sliceNode = document.createElementNS(SVG_NS, "g"); 247 sliceNode.setAttribute("role", "listitem"); 248 slicesGroup.append(sliceNode); 249 250 const interactiveNodeId = `${sliceInfo.label}-slice`; 251 const textNodeId = `${sliceInfo.label}-slice-label`; 252 253 // The only way to make this keyboard accessible is to have a link 254 const interactiveNode = document.createElementNS(SVG_NS, "a"); 255 interactiveNode.setAttribute("id", interactiveNodeId); 256 interactiveNode.setAttribute("xlink:href", `#${interactiveNodeId}`); 257 interactiveNode.setAttribute("tabindex", `0`); 258 interactiveNode.setAttribute("role", `button`); 259 interactiveNode.classList.add("pie-chart-slice-container"); 260 if (!isPlaceholder) { 261 interactiveNode.setAttribute( 262 "aria-label", 263 L10N.getFormatStr( 264 "pieChart.sliceAriaLabel", 265 sliceInfo.label, 266 new Intl.NumberFormat(undefined, { 267 style: "unit", 268 unit: "percent", 269 maximumFractionDigits: 2, 270 }).format((sliceInfo.size / total) * 100) 271 ) 272 ); 273 } 274 275 sliceNode.append(interactiveNode); 276 277 endAngle = startAngle - sliceAngle; 278 midAngle = (startAngle + endAngle) / 2; 279 280 const x1 = centerX + radius * Math.sin(startAngle); 281 const y1 = centerY - radius * Math.cos(startAngle); 282 const x2 = centerX + radius * Math.sin(endAngle); 283 const y2 = centerY - radius * Math.cos(endAngle); 284 const largeArcFlag = Math.abs(startAngle - endAngle) > PI ? 1 : 0; 285 286 const pathNode = document.createElementNS(SVG_NS, "path"); 287 pathNode.classList.add("pie-chart-slice"); 288 pathNode.setAttribute("data-statistic-name", sliceInfo.label); 289 pathNode.setAttribute( 290 "d", 291 " M " + 292 centerX + 293 "," + 294 centerY + 295 " L " + 296 x2 + 297 "," + 298 y2 + 299 " A " + 300 radius + 301 "," + 302 radius + 303 " 0 " + 304 largeArcFlag + 305 " 1 " + 306 x1 + 307 "," + 308 y1 + 309 " Z" 310 ); 311 312 if (sliceInfo == largest) { 313 pathNode.setAttribute("largest", ""); 314 } 315 if (sliceInfo == smallest) { 316 pathNode.setAttribute("smallest", ""); 317 } 318 319 const hoverX = translateDistance * Math.sin(midAngle); 320 const hoverY = -translateDistance * Math.cos(midAngle); 321 const hoverTranslate = "translate(" + hoverX + "px, " + hoverY + "px)"; 322 if (data.length > 1) { 323 pathNode.style.transform = hoverTranslate; 324 } 325 326 proxy.slices.set(sliceInfo, pathNode); 327 delegate( 328 proxy, 329 ["click", "mouseover", "mouseout", "focus"], 330 interactiveNode, 331 sliceInfo 332 ); 333 interactiveNode.appendChild(pathNode); 334 335 const textX = centerX + textDistance * Math.sin(midAngle); 336 const textY = centerY - textDistance * Math.cos(midAngle); 337 338 // Don't add the label if the slice isn't large enough so it doesn't look cramped. 339 if (sliceAngle >= NAMED_SLICE_MIN_ANGLE) { 340 const label = document.createElementNS(SVG_NS, "text"); 341 label.appendChild(document.createTextNode(sliceInfo.label)); 342 label.setAttribute("id", textNodeId); 343 // A label is already set on `interactiveNode`, so hide this from the accessibility tree 344 // to avoid duplicating text. 345 label.setAttribute("aria-hidden", "true"); 346 label.setAttribute("class", "pie-chart-label"); 347 if (data.length > 1) { 348 label.style.transform = hoverTranslate; 349 } 350 label.setAttribute("x", data.length > 1 ? textX : centerX); 351 label.setAttribute("y", data.length > 1 ? textY : centerY); 352 interactiveNode.append(label); 353 } 354 355 startAngle = endAngle; 356 } 357 358 return proxy; 359 } 360 361 /** 362 * Creates the DOM for a table chart based on the specified properties. 363 * 364 * @param Document document 365 * The document responsible with creating the DOM. 366 * @param object 367 * An object containing all or some of the following properties: 368 * - title: a string displayed as the chart's (description)/local 369 * - data: an array of items used to display each row; all the items 370 * should be objects representing columns, for which the 371 * properties' values will be displayed in each cell of a row. 372 * e.g: [{ 373 * label1: 1, 374 * label2: 3, 375 * label3: "foo" 376 * }, { 377 * label1: 4, 378 * label2: 6, 379 * label3: "bar 380 * }]; 381 * - strings: an object specifying for which rows in the `data` array 382 * their cell values should be stringified and localized 383 * based on a predicate function; 384 * e.g: { 385 * label1: value => l10n.getFormatStr("...", value) 386 * } 387 * - totals: an object specifying for which rows in the `data` array 388 * the sum of their cells is to be displayed in the chart; 389 * e.g: { 390 * label1: total => l10n.getFormatStr("...", total), // 5 391 * label2: total => l10n.getFormatStr("...", total), // 9 392 * } 393 * - header: an object specifying strings to use for table column 394 * headers 395 * e.g. { 396 * label1: l10n.getStr(...), 397 * label2: l10n.getStr(...), 398 * } 399 * @return TableChart 400 * A table chart proxy instance, which emits the following events: 401 * - "mouseover", when the mouse enters a row 402 * - "mouseout", when the mouse leaves a row 403 * - "click", when the mouse clicks a row 404 */ 405 function createTableChart(document, { title, data, strings, totals, header }) { 406 strings = strings || {}; 407 totals = totals || {}; 408 header = header || {}; 409 let isPlaceholder = false; 410 411 // If there's no data available, display an empty placeholder. 412 if (!data) { 413 data = loadingTableChartData(); 414 isPlaceholder = true; 415 } 416 if (!data.length) { 417 data = emptyTableChartData(); 418 isPlaceholder = true; 419 } 420 421 const container = document.createElement("div"); 422 container.className = "generic-chart-container table-chart-container"; 423 container.setAttribute("placeholder", isPlaceholder); 424 425 const proxy = new TableChart(container); 426 427 const titleNode = document.createElement("span"); 428 titleNode.className = "table-chart-title"; 429 titleNode.textContent = title; 430 container.appendChild(titleNode); 431 432 const tableNode = document.createElement("table"); 433 tableNode.className = "table-chart-grid"; 434 container.appendChild(tableNode); 435 436 const headerNode = document.createElement("thead"); 437 headerNode.className = "table-chart-row"; 438 439 const bodyNode = document.createElement("tbody"); 440 441 const headerBoxNode = document.createElement("tr"); 442 headerBoxNode.className = "table-chart-row-box"; 443 headerNode.appendChild(headerBoxNode); 444 445 for (const [key, value] of Object.entries(header)) { 446 const headerLabelNode = document.createElement("th"); 447 headerLabelNode.className = "table-chart-row-label"; 448 headerLabelNode.setAttribute("name", key); 449 headerLabelNode.textContent = value; 450 if (key == "count") { 451 headerLabelNode.classList.add("offscreen"); 452 } 453 headerBoxNode.appendChild(headerLabelNode); 454 } 455 456 tableNode.append(headerNode, bodyNode); 457 458 for (const rowInfo of data) { 459 const rowNode = document.createElement("tr"); 460 rowNode.className = "table-chart-row"; 461 rowNode.setAttribute("data-statistic-name", rowInfo.label); 462 463 for (const [key, value] of Object.entries(rowInfo)) { 464 // Don't render the "cached" column. We only have it in here so it can be displayed 465 // in the `totals` section. 466 if (key == "cached") { 467 continue; 468 } 469 const index = data.indexOf(rowInfo); 470 const stringified = strings[key] ? strings[key](value, index) : value; 471 const labelNode = document.createElement("td"); 472 labelNode.className = "table-chart-row-label"; 473 labelNode.setAttribute("name", key); 474 labelNode.textContent = stringified; 475 rowNode.appendChild(labelNode); 476 } 477 478 proxy.rows.set(rowInfo, rowNode); 479 delegate(proxy, ["click", "mouseover", "mouseout"], rowNode, rowInfo); 480 bodyNode.appendChild(rowNode); 481 } 482 483 const totalsNode = document.createElement("div"); 484 totalsNode.className = "table-chart-totals"; 485 486 for (const [key, value] of Object.entries(totals)) { 487 const total = data.reduce((acc, e) => acc + e[key], 0); 488 const stringified = value ? value(total || 0) : total; 489 const labelNode = document.createElement("span"); 490 labelNode.className = "table-chart-summary-label"; 491 labelNode.setAttribute("name", key); 492 labelNode.textContent = stringified; 493 totalsNode.appendChild(labelNode); 494 } 495 496 container.appendChild(totalsNode); 497 498 return proxy; 499 } 500 501 function loadingPieChartData() { 502 return [{ size: 1, label: L10N.getStr("pieChart.loading") }]; 503 } 504 505 function emptyPieChartData() { 506 return [{ size: 1, label: L10N.getStr("pieChart.unavailable") }]; 507 } 508 509 function loadingTableChartData() { 510 return [{ size: "", label: L10N.getStr("tableChart.loading") }]; 511 } 512 513 function emptyTableChartData() { 514 return [{ size: "", label: L10N.getStr("tableChart.unavailable") }]; 515 } 516 517 /** 518 * Delegates DOM events emitted by a Node to an EventEmitter proxy. 519 * 520 * @param EventEmitter emitter 521 * The event emitter proxy instance. 522 * @param array events 523 * An array of events, e.g. ["mouseover", "mouseout"]. 524 * @param Node node 525 * The element firing the DOM events. 526 * @param any args 527 * The arguments passed when emitting events through the proxy. 528 */ 529 function delegate(emitter, events, node, args) { 530 for (const event of events) { 531 node.addEventListener(event, emitter.emit.bind(emitter, event, args)); 532 } 533 } 534 535 exports.Chart = Chart;