draw.js (9760B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 "use strict"; 5 /** 6 * Draw the treemap into the provided canvases using the 2d context. The treemap 7 * layout is computed with d3. There are 2 canvases provided, each matching 8 * the resolution of the window. The main canvas is a fully drawn version of 9 * the treemap that is positioned and zoomed using css. It gets blurry the more 10 * you zoom in as it doesn't get redrawn when zooming. The zoom canvas is 11 * repositioned absolutely after every change in the dragZoom object, and then 12 * redrawn to provide a full-resolution (non-blurry) view of zoomed in segment 13 * of the treemap. 14 */ 15 16 const colorCoarseType = require("resource://devtools/client/memory/components/tree-map/color-coarse-type.js"); 17 const { 18 hslToStyle, 19 formatAbbreviatedBytes, 20 L10N, 21 } = require("resource://devtools/client/memory/utils.js"); 22 23 // A constant fully zoomed out dragZoom object for the main canvas 24 const NO_SCROLL = { 25 translateX: 0, 26 translateY: 0, 27 zoom: 0, 28 offsetX: 0, 29 offsetY: 0, 30 }; 31 32 // Drawing constants 33 const ELLIPSIS = "..."; 34 const TEXT_MARGIN = 2; 35 const TEXT_COLOR = "#000000"; 36 const TEXT_LIGHT_COLOR = "rgba(0,0,0,0.5)"; 37 const LINE_WIDTH = 1; 38 const FONT_SIZE = 10; 39 const FONT_LINE_HEIGHT = 2; 40 const PADDING = [5 + FONT_SIZE, 5, 5, 5]; 41 const COUNT_LABEL = L10N.getStr("tree-map.node-count"); 42 43 /** 44 * Setup and start drawing the treemap visualization 45 * 46 * @param {object} report 47 * @param {object} canvases 48 * A CanvasUtils object that contains references to the main and zoom 49 * canvases and contexts 50 * @param {object} dragZoom 51 * A DragZoom object representing the current state of the dragging 52 * and zooming behavior 53 */ 54 exports.setupDraw = function (report, canvases, dragZoom) { 55 const getTreemap = configureD3Treemap.bind(null, canvases.main.canvas); 56 57 let treemap, nodes; 58 59 function drawFullTreemap() { 60 treemap = getTreemap(); 61 nodes = treemap(report); 62 drawTreemap(canvases.main, nodes, NO_SCROLL); 63 drawTreemap(canvases.zoom, nodes, dragZoom); 64 } 65 66 function drawZoomedTreemap() { 67 drawTreemap(canvases.zoom, nodes, dragZoom); 68 positionZoomedCanvas(canvases.zoom.canvas, dragZoom); 69 } 70 71 drawFullTreemap(); 72 canvases.on("resize", drawFullTreemap); 73 dragZoom.on("change", drawZoomedTreemap); 74 }; 75 76 /** 77 * Returns a configured d3 treemap function 78 * 79 * @param {HTMLCanvasElement} canvas 80 * @return {Function} 81 */ 82 const configureD3Treemap = (exports.configureD3Treemap = function (canvas) { 83 const window = canvas.ownerDocument.defaultView; 84 const ratio = window.devicePixelRatio; 85 const treemap = window.d3.layout 86 .treemap() 87 .size([ 88 // The d3 layout includes the padding around everything, add some 89 // extra padding to the size to compensate for thi 90 canvas.width + (PADDING[1] + PADDING[3]) * ratio, 91 canvas.height + (PADDING[0] + PADDING[2]) * ratio, 92 ]) 93 .sticky(true) 94 .padding([ 95 PADDING[0] * ratio, 96 PADDING[1] * ratio, 97 PADDING[2] * ratio, 98 PADDING[3] * ratio, 99 ]) 100 .value(d => d.bytes); 101 102 /** 103 * Create treemap nodes from a census report that are sorted by depth 104 * 105 * @param {object} report 106 * @return {Array} An array of d3 treemap nodes 107 * // https://github.com/mbostock/d3/wiki/Treemap-Layout 108 * parent - the parent node, or null for the root. 109 * children - the array of child nodes, or null for leaf nodes. 110 * value - the node value, as returned by the value accessor. 111 * depth - the depth of the node, starting at 0 for the root. 112 * area - the computed pixel area of this node. 113 * x - the minimum x-coordinate of the node position. 114 * y - the minimum y-coordinate of the node position. 115 * z - the orientation of this cell’s subdivision, if any. 116 * dx - the x-extent of the node position. 117 * dy - the y-extent of the node position. 118 */ 119 return function depthSortedNodes(report) { 120 const nodes = treemap(report); 121 nodes.sort((a, b) => a.depth - b.depth); 122 return nodes; 123 }; 124 }); 125 126 /** 127 * Draw the text, cut it in half every time it doesn't fit until it fits or 128 * it's smaller than the "..." text. 129 * 130 * @param {CanvasRenderingContext2D} ctx 131 * @param {number} x 132 * the position of the text 133 * @param {number} y 134 * the position of the text 135 * @param {number} innerWidth 136 * the inner width of the containing treemap cell 137 * @param {Text} name 138 */ 139 const drawTruncatedName = (exports.drawTruncatedName = function ( 140 ctx, 141 x, 142 y, 143 innerWidth, 144 name 145 ) { 146 const truncated = name.substr(0, Math.floor(name.length / 2)); 147 const formatted = truncated + ELLIPSIS; 148 149 if (ctx.measureText(formatted).width > innerWidth) { 150 drawTruncatedName(ctx, x, y, innerWidth, truncated); 151 } else { 152 ctx.fillText(formatted, x, y); 153 } 154 }); 155 156 /** 157 * Fit and draw the text in a node with the following strategies to shrink 158 * down the text size: 159 * 160 * Function 608KB 9083 count 161 * Function 162 * Func... 163 * Fu... 164 * ... 165 * 166 * @param {CanvasRenderingContext2D} ctx 167 * @param {object} node 168 * @param {number} borderWidth 169 * @param {object} dragZoom 170 * @param {Array} padding 171 */ 172 const drawText = (exports.drawText = function ( 173 ctx, 174 node, 175 borderWidth, 176 ratio, 177 dragZoom, 178 padding 179 ) { 180 let { dx, dy, name, totalBytes, totalCount } = node; 181 const scale = dragZoom.zoom + 1; 182 dx *= scale; 183 dy *= scale; 184 185 // Start checking to see how much text we can fit in, optimizing for the 186 // common case of lots of small leaf nodes 187 if (FONT_SIZE * FONT_LINE_HEIGHT < dy) { 188 const margin = borderWidth(node) * 1.5 + ratio * TEXT_MARGIN; 189 const x = margin + (node.x - padding[0]) * scale - dragZoom.offsetX; 190 const y = margin + (node.y - padding[1]) * scale - dragZoom.offsetY; 191 const innerWidth = dx - margin * 2; 192 const nameSize = ctx.measureText(name).width; 193 194 if (ctx.measureText(ELLIPSIS).width > innerWidth) { 195 return; 196 } 197 198 ctx.fillStyle = TEXT_COLOR; 199 200 if (nameSize > innerWidth) { 201 // The name is too long - halve the name as an expediant way to shorten it 202 drawTruncatedName(ctx, x, y, innerWidth, name); 203 } else { 204 const bytesFormatted = formatAbbreviatedBytes(totalBytes); 205 const countFormatted = `${totalCount} ${COUNT_LABEL}`; 206 const byteSize = ctx.measureText(bytesFormatted).width; 207 const countSize = ctx.measureText(countFormatted).width; 208 const spaceSize = ctx.measureText(" ").width; 209 210 if (nameSize + byteSize + countSize + spaceSize * 3 > innerWidth) { 211 // The full name will fit 212 ctx.fillText(`${name}`, x, y); 213 } else { 214 // The full name plus the byte information will fit 215 ctx.fillText(name, x, y); 216 ctx.fillStyle = TEXT_LIGHT_COLOR; 217 ctx.fillText( 218 `${bytesFormatted} ${countFormatted}`, 219 x + nameSize + spaceSize, 220 y 221 ); 222 } 223 } 224 } 225 }); 226 227 /** 228 * Draw a box given a node 229 * 230 * @param {CanvasRenderingContext2D} ctx 231 * @param {object} node 232 * @param {number} borderWidth 233 * @param {number} ratio 234 * @param {object} dragZoom 235 * @param {Array} padding 236 */ 237 const drawBox = (exports.drawBox = function ( 238 ctx, 239 node, 240 borderWidth, 241 dragZoom, 242 padding 243 ) { 244 const border = borderWidth(node); 245 const fillHSL = colorCoarseType(node); 246 const strokeHSL = [fillHSL[0], fillHSL[1], fillHSL[2] * 0.5]; 247 const scale = 1 + dragZoom.zoom; 248 249 // Offset the draw so that box strokes don't overlap 250 const x = scale * (node.x - padding[0]) - dragZoom.offsetX + border / 2; 251 const y = scale * (node.y - padding[1]) - dragZoom.offsetY + border / 2; 252 const dx = scale * node.dx - border; 253 const dy = scale * node.dy - border; 254 255 ctx.fillStyle = hslToStyle(...fillHSL); 256 ctx.fillRect(x, y, dx, dy); 257 258 ctx.strokeStyle = hslToStyle(...strokeHSL); 259 ctx.lineWidth = border; 260 ctx.strokeRect(x, y, dx, dy); 261 }); 262 263 /** 264 * Draw the overall treemap 265 * 266 * @param {HTMLCanvasElement} canvas 267 * @param {CanvasRenderingContext2D} ctx 268 * @param {Array} nodes 269 * @param {Objbect} dragZoom 270 */ 271 const drawTreemap = (exports.drawTreemap = function ( 272 { canvas, ctx }, 273 nodes, 274 dragZoom 275 ) { 276 const window = canvas.ownerDocument.defaultView; 277 const ratio = window.devicePixelRatio; 278 const canvasArea = canvas.width * canvas.height; 279 // Subtract the outer padding from the tree map layout. 280 const padding = [PADDING[3] * ratio, PADDING[0] * ratio]; 281 282 ctx.clearRect(0, 0, canvas.width, canvas.height); 283 ctx.font = `${FONT_SIZE * ratio}px sans-serif`; 284 ctx.textBaseline = "top"; 285 286 function borderWidth(node) { 287 const areaRatio = Math.sqrt(node.area / canvasArea); 288 return ratio * Math.max(1, LINE_WIDTH * areaRatio); 289 } 290 291 for (let i = 0; i < nodes.length; i++) { 292 const node = nodes[i]; 293 if (node.parent === undefined) { 294 continue; 295 } 296 297 drawBox(ctx, node, borderWidth, dragZoom, padding); 298 drawText(ctx, node, borderWidth, ratio, dragZoom, padding); 299 } 300 }); 301 302 /** 303 * Set the position of the zoomed in canvas. It always take up 100% of the view 304 * window, but is transformed relative to the zoomed in containing element, 305 * essentially reversing the transform of the containing element. 306 * 307 * @param {HTMLCanvasElement} canvas 308 * @param {object} dragZoom 309 */ 310 const positionZoomedCanvas = function (canvas, dragZoom) { 311 const scale = 1 / (1 + dragZoom.zoom); 312 const x = -dragZoom.translateX; 313 const y = -dragZoom.translateY; 314 canvas.style.transform = `scale(${scale}) translate(${x}px, ${y}px)`; 315 }; 316 317 exports.positionZoomedCanvas = positionZoomedCanvas;