diff options
Diffstat (limited to 'devtools/client/memory/components/tree-map')
6 files changed, 908 insertions, 0 deletions
diff --git a/devtools/client/memory/components/tree-map/canvas-utils.js b/devtools/client/memory/components/tree-map/canvas-utils.js new file mode 100644 index 0000000000..bbafb4a5fc --- /dev/null +++ b/devtools/client/memory/components/tree-map/canvas-utils.js @@ -0,0 +1,132 @@ +/* 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/. */ + +/* eslint-env browser */ + +"use strict"; + +/** + * Create 2 canvases and contexts for drawing onto, 1 main canvas, and 1 zoom + * canvas. The main canvas dimensions match the parent div, but the CSS can be + * transformed to be zoomed and dragged around (potentially creating a blurry + * canvas once zoomed in). The zoom canvas is a zoomed in section that matches + * the parent div's dimensions and is kept in place through CSS. A zoomed in + * view of the visualization is drawn onto this canvas, providing a crisp zoomed + * in view of the tree map. + */ +const { debounce } = require("devtools/shared/debounce"); +const EventEmitter = require("devtools/shared/event-emitter"); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const FULLSCREEN_STYLE = { + width: "100%", + height: "100%", + position: "absolute", +}; + +/** + * Create the canvases, resize handlers, and return references to them all + * + * @param {HTMLDivElement} parentEl + * @param {Number} debounceRate + * @return {Object} + */ +function Canvases(parentEl, debounceRate) { + EventEmitter.decorate(this); + this.container = createContainingDiv(parentEl); + + // This canvas contains all of the treemap + this.main = createCanvas(this.container, "main"); + // This canvas contains only the zoomed in portion, overlaying the main canvas + this.zoom = createCanvas(this.container, "zoom"); + + this.removeHandlers = handleResizes(this, debounceRate); +} + +Canvases.prototype = { + /** + * Remove the handlers and elements + * + * @return {type} description + */ + destroy: function() { + this.removeHandlers(); + this.container.removeChild(this.main.canvas); + this.container.removeChild(this.zoom.canvas); + }, +}; + +module.exports = Canvases; + +/** + * Create the containing div + * + * @param {HTMLDivElement} parentEl + * @return {HTMLDivElement} + */ +function createContainingDiv(parentEl) { + const div = parentEl.ownerDocument.createElementNS(HTML_NS, "div"); + Object.assign(div.style, FULLSCREEN_STYLE); + parentEl.appendChild(div); + return div; +} + +/** + * Create a canvas and context + * + * @param {HTMLDivElement} container + * @param {String} className + * @return {Object} { canvas, ctx } + */ +function createCanvas(container, className) { + const window = container.ownerDocument.defaultView; + const canvas = container.ownerDocument.createElementNS(HTML_NS, "canvas"); + container.appendChild(canvas); + canvas.width = container.offsetWidth * window.devicePixelRatio; + canvas.height = container.offsetHeight * window.devicePixelRatio; + canvas.className = className; + + Object.assign(canvas.style, FULLSCREEN_STYLE, { + pointerEvents: "none", + }); + + const ctx = canvas.getContext("2d"); + + return { canvas, ctx }; +} + +/** + * Resize the canvases' resolutions, and fires out the onResize callback + * + * @param {HTMLDivElement} container + * @param {Object} canvases + * @param {Number} debounceRate + */ +function handleResizes(canvases, debounceRate) { + const { container, main, zoom } = canvases; + const window = container.ownerDocument.defaultView; + + function resize() { + const width = container.offsetWidth * window.devicePixelRatio; + const height = container.offsetHeight * window.devicePixelRatio; + + main.canvas.width = width; + main.canvas.height = height; + zoom.canvas.width = width; + zoom.canvas.height = height; + + canvases.emit("resize"); + } + + // Tests may not need debouncing + const debouncedResize = + debounceRate > 0 ? debounce(resize, debounceRate) : resize; + + window.addEventListener("resize", debouncedResize); + resize(); + + return function removeResizeHandlers() { + window.removeEventListener("resize", debouncedResize); + }; +} diff --git a/devtools/client/memory/components/tree-map/color-coarse-type.js b/devtools/client/memory/components/tree-map/color-coarse-type.js new file mode 100644 index 0000000000..b511f9f50e --- /dev/null +++ b/devtools/client/memory/components/tree-map/color-coarse-type.js @@ -0,0 +1,70 @@ +/* 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"; + +/** + * Color the boxes in the treemap + */ + +const TYPES = ["objects", "other", "strings", "scripts", "domNode"]; + +// The factors determine how much the hue shifts +const TYPE_FACTOR = TYPES.length * 3; +const DEPTH_FACTOR = -10; +const H = 0.5; +const S = 0.6; +const L = 0.9; + +/** + * Recursively find the index of the coarse type of a node + * + * @param {Object} node + * d3 treemap + * @return {Integer} + * index + */ +function findCoarseTypeIndex(node) { + const index = TYPES.indexOf(node.name); + + if (node.parent) { + return index === -1 ? findCoarseTypeIndex(node.parent) : index; + } + + return TYPES.indexOf("other"); +} + +/** + * Decide a color value for depth to be used in the HSL computation + * + * @param {Object} node + * @return {Number} + */ +function depthColorFactor(node) { + return Math.min(1, node.depth / DEPTH_FACTOR); +} + +/** + * Decide a color value for type to be used in the HSL computation + * + * @param {Object} node + * @return {Number} + */ +function typeColorFactor(node) { + return findCoarseTypeIndex(node) / TYPE_FACTOR; +} + +/** + * Color a node + * + * @param {Object} node + * @return {Array} HSL values ranged 0-1 + */ +module.exports = function colorCoarseType(node) { + const h = Math.min(1, H + typeColorFactor(node)); + const s = Math.min(1, S); + const l = Math.min(1, L + depthColorFactor(node)); + + return [h, s, l]; +}; diff --git a/devtools/client/memory/components/tree-map/drag-zoom.js b/devtools/client/memory/components/tree-map/drag-zoom.js new file mode 100644 index 0000000000..066c6f055f --- /dev/null +++ b/devtools/client/memory/components/tree-map/drag-zoom.js @@ -0,0 +1,337 @@ +/* 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 { debounce } = require("devtools/shared/debounce"); +const { lerp } = require("devtools/client/memory/utils"); +const EventEmitter = require("devtools/shared/event-emitter"); + +const LERP_SPEED = 0.5; +const ZOOM_SPEED = 0.01; +const TRANSLATE_EPSILON = 1; +const ZOOM_EPSILON = 0.001; +const LINE_SCROLL_MODE = 1; +const SCROLL_LINE_SIZE = 15; + +/** + * DragZoom is a constructor that contains the state of the current dragging and + * zooming behavior. It sets the scrolling and zooming behaviors. + * + * @param {HTMLElement} container description + * The container for the canvases + */ +function DragZoom(container, debounceRate, requestAnimationFrame) { + EventEmitter.decorate(this); + + this.isDragging = false; + + // The current mouse position + this.mouseX = container.offsetWidth / 2; + this.mouseY = container.offsetHeight / 2; + + // The total size of the visualization after being zoomed, in pixels + this.zoomedWidth = container.offsetWidth; + this.zoomedHeight = container.offsetHeight; + + // How much the visualization has been zoomed in + this.zoom = 0; + + // The offset of visualization from the container. This is applied after + // the zoom, and the visualization by default is centered + this.translateX = 0; + this.translateY = 0; + + // The size of the offset between the top/left of the container, and the + // top/left of the containing element. This value takes into account + // the device pixel ratio for canvas draws. + this.offsetX = 0; + this.offsetY = 0; + + // The smoothed values that are animated and eventually match the target + // values. The values are updated by the update loop + this.smoothZoom = 0; + this.smoothTranslateX = 0; + this.smoothTranslateY = 0; + + // Add the constant values for testing purposes + this.ZOOM_SPEED = ZOOM_SPEED; + this.ZOOM_EPSILON = ZOOM_EPSILON; + + const update = createUpdateLoop(container, this, requestAnimationFrame); + + this.destroy = setHandlers(this, container, update, debounceRate); +} + +module.exports = DragZoom; + +/** + * Returns an update loop. This loop smoothly updates the visualization when + * actions are performed. Once the animations have reached their target values + * the animation loop is stopped. + * + * Any value in the `dragZoom` object that starts with "smooth" is the + * smoothed version of a value that is interpolating toward the target value. + * For instance `dragZoom.smoothZoom` approaches `dragZoom.zoom` on each + * iteration of the update loop until it's sufficiently close as defined by + * the epsilon values. + * + * Only these smoothed values and the container CSS are updated by the loop. + * + * @param {HTMLDivElement} container + * @param {Object} dragZoom + * The values that represent the current dragZoom state + * @param {Function} requestAnimationFrame + */ +function createUpdateLoop(container, dragZoom, requestAnimationFrame) { + let isLooping = false; + + function update() { + const isScrollChanging = + Math.abs(dragZoom.smoothZoom - dragZoom.zoom) > ZOOM_EPSILON; + const isTranslateChanging = + Math.abs(dragZoom.smoothTranslateX - dragZoom.translateX) > + TRANSLATE_EPSILON || + Math.abs(dragZoom.smoothTranslateY - dragZoom.translateY) > + TRANSLATE_EPSILON; + + isLooping = isScrollChanging || isTranslateChanging; + + if (isScrollChanging) { + dragZoom.smoothZoom = lerp( + dragZoom.smoothZoom, + dragZoom.zoom, + LERP_SPEED + ); + } else { + dragZoom.smoothZoom = dragZoom.zoom; + } + + if (isTranslateChanging) { + dragZoom.smoothTranslateX = lerp( + dragZoom.smoothTranslateX, + dragZoom.translateX, + LERP_SPEED + ); + dragZoom.smoothTranslateY = lerp( + dragZoom.smoothTranslateY, + dragZoom.translateY, + LERP_SPEED + ); + } else { + dragZoom.smoothTranslateX = dragZoom.translateX; + dragZoom.smoothTranslateY = dragZoom.translateY; + } + + const zoom = 1 + dragZoom.smoothZoom; + const x = dragZoom.smoothTranslateX; + const y = dragZoom.smoothTranslateY; + container.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`; + + if (isLooping) { + requestAnimationFrame(update); + } + } + + // Go ahead and start the update loop + update(); + + return function restartLoopingIfStopped() { + if (!isLooping) { + update(); + } + }; +} + +/** + * Set the various event listeners and return a function to remove them + * + * @param {Object} dragZoom + * @param {HTMLElement} container + * @param {Function} update + * @return {Function} The function to remove the handlers + */ +function setHandlers(dragZoom, container, update, debounceRate) { + const emitChanged = debounce(() => dragZoom.emit("change"), debounceRate); + + const removeDragHandlers = setDragHandlers( + container, + dragZoom, + emitChanged, + update + ); + const removeScrollHandlers = setScrollHandlers( + container, + dragZoom, + emitChanged, + update + ); + + return function removeHandlers() { + removeDragHandlers(); + removeScrollHandlers(); + }; +} + +/** + * Sets handlers for when the user drags on the canvas. It will update dragZoom + * object with new translate and offset values. + * + * @param {HTMLElement} container + * @param {Object} dragZoom + * @param {Function} changed + * @param {Function} update + */ +function setDragHandlers(container, dragZoom, emitChanged, update) { + const parentEl = container.parentElement; + + function startDrag() { + dragZoom.isDragging = true; + container.style.cursor = "grabbing"; + } + + function stopDrag() { + dragZoom.isDragging = false; + container.style.cursor = "grab"; + } + + function drag(event) { + const prevMouseX = dragZoom.mouseX; + const prevMouseY = dragZoom.mouseY; + + dragZoom.mouseX = event.clientX - parentEl.offsetLeft; + dragZoom.mouseY = event.clientY - parentEl.offsetTop; + + if (!dragZoom.isDragging) { + return; + } + + dragZoom.translateX += dragZoom.mouseX - prevMouseX; + dragZoom.translateY += dragZoom.mouseY - prevMouseY; + + keepInView(container, dragZoom); + + emitChanged(); + update(); + } + + parentEl.addEventListener("mousedown", startDrag); + parentEl.addEventListener("mouseup", stopDrag); + parentEl.addEventListener("mouseout", stopDrag); + parentEl.addEventListener("mousemove", drag); + + return function removeListeners() { + parentEl.removeEventListener("mousedown", startDrag); + parentEl.removeEventListener("mouseup", stopDrag); + parentEl.removeEventListener("mouseout", stopDrag); + parentEl.removeEventListener("mousemove", drag); + }; +} + +/** + * Sets the handlers for when the user scrolls. It updates the dragZoom object + * and keeps the canvases all within the view. After changing values update + * loop is called, and the changed event is emitted. + * + * @param {HTMLDivElement} container + * @param {Object} dragZoom + * @param {Function} changed + * @param {Function} update + */ +function setScrollHandlers(container, dragZoom, emitChanged, update) { + const window = container.ownerDocument.defaultView; + + function handleWheel(event) { + event.preventDefault(); + + if (dragZoom.isDragging) { + return; + } + + // Update the zoom level + const scrollDelta = getScrollDelta(event, window); + const prevZoom = dragZoom.zoom; + dragZoom.zoom = Math.max(0, dragZoom.zoom - scrollDelta * ZOOM_SPEED); + + // Calculate the updated width and height + const prevZoomedWidth = container.offsetWidth * (1 + prevZoom); + const prevZoomedHeight = container.offsetHeight * (1 + prevZoom); + dragZoom.zoomedWidth = container.offsetWidth * (1 + dragZoom.zoom); + dragZoom.zoomedHeight = container.offsetHeight * (1 + dragZoom.zoom); + const deltaWidth = dragZoom.zoomedWidth - prevZoomedWidth; + const deltaHeight = dragZoom.zoomedHeight - prevZoomedHeight; + + const mouseOffsetX = dragZoom.mouseX - container.offsetWidth / 2; + const mouseOffsetY = dragZoom.mouseY - container.offsetHeight / 2; + + // The ratio of where the center of the mouse is in regards to the total + // zoomed width/height + const ratioZoomX = + (prevZoomedWidth / 2 + mouseOffsetX - dragZoom.translateX) / + prevZoomedWidth; + const ratioZoomY = + (prevZoomedHeight / 2 + mouseOffsetY - dragZoom.translateY) / + prevZoomedHeight; + + // Distribute the change in width and height based on the above ratio + dragZoom.translateX -= lerp(-deltaWidth / 2, deltaWidth / 2, ratioZoomX); + dragZoom.translateY -= lerp(-deltaHeight / 2, deltaHeight / 2, ratioZoomY); + + // Keep the canvas in range of the container + keepInView(container, dragZoom); + emitChanged(); + update(); + } + + container.addEventListener("wheel", handleWheel); + + return function removeListener() { + container.removeEventListener("wheel", handleWheel); + }; +} + +/** + * Account for the various mouse wheel event types, per pixel or per line + * + * @param {WheelEvent} event + * @param {Window} window + * @return {Number} The scroll size in pixels + */ +function getScrollDelta(event, window) { + if (event.deltaMode === LINE_SCROLL_MODE) { + // Update by a fixed arbitrary value to normalize scroll types + return event.deltaY * SCROLL_LINE_SIZE; + } + return event.deltaY; +} + +/** + * Keep the dragging and zooming within the view by updating the values in the + * `dragZoom` object. + * + * @param {HTMLDivElement} container + * @param {Object} dragZoom + */ +function keepInView(container, dragZoom) { + const { devicePixelRatio } = container.ownerDocument.defaultView; + const overdrawX = (dragZoom.zoomedWidth - container.offsetWidth) / 2; + const overdrawY = (dragZoom.zoomedHeight - container.offsetHeight) / 2; + + dragZoom.translateX = Math.max( + -overdrawX, + Math.min(overdrawX, dragZoom.translateX) + ); + dragZoom.translateY = Math.max( + -overdrawY, + Math.min(overdrawY, dragZoom.translateY) + ); + + dragZoom.offsetX = + devicePixelRatio * + ((dragZoom.zoomedWidth - container.offsetWidth) / 2 - dragZoom.translateX); + dragZoom.offsetY = + devicePixelRatio * + ((dragZoom.zoomedHeight - container.offsetHeight) / 2 - + dragZoom.translateY); +} diff --git a/devtools/client/memory/components/tree-map/draw.js b/devtools/client/memory/components/tree-map/draw.js new file mode 100644 index 0000000000..f365b40834 --- /dev/null +++ b/devtools/client/memory/components/tree-map/draw.js @@ -0,0 +1,317 @@ +/* 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"; +/** + * Draw the treemap into the provided canvases using the 2d context. The treemap + * layout is computed with d3. There are 2 canvases provided, each matching + * the resolution of the window. The main canvas is a fully drawn version of + * the treemap that is positioned and zoomed using css. It gets blurry the more + * you zoom in as it doesn't get redrawn when zooming. The zoom canvas is + * repositioned absolutely after every change in the dragZoom object, and then + * redrawn to provide a full-resolution (non-blurry) view of zoomed in segment + * of the treemap. + */ + +const colorCoarseType = require("devtools/client/memory/components/tree-map/color-coarse-type"); +const { + hslToStyle, + formatAbbreviatedBytes, + L10N, +} = require("devtools/client/memory/utils"); + +// A constant fully zoomed out dragZoom object for the main canvas +const NO_SCROLL = { + translateX: 0, + translateY: 0, + zoom: 0, + offsetX: 0, + offsetY: 0, +}; + +// Drawing constants +const ELLIPSIS = "..."; +const TEXT_MARGIN = 2; +const TEXT_COLOR = "#000000"; +const TEXT_LIGHT_COLOR = "rgba(0,0,0,0.5)"; +const LINE_WIDTH = 1; +const FONT_SIZE = 10; +const FONT_LINE_HEIGHT = 2; +const PADDING = [5 + FONT_SIZE, 5, 5, 5]; +const COUNT_LABEL = L10N.getStr("tree-map.node-count"); + +/** + * Setup and start drawing the treemap visualization + * + * @param {Object} report + * @param {Object} canvases + * A CanvasUtils object that contains references to the main and zoom + * canvases and contexts + * @param {Object} dragZoom + * A DragZoom object representing the current state of the dragging + * and zooming behavior + */ +exports.setupDraw = function(report, canvases, dragZoom) { + const getTreemap = configureD3Treemap.bind(null, canvases.main.canvas); + + let treemap, nodes; + + function drawFullTreemap() { + treemap = getTreemap(); + nodes = treemap(report); + drawTreemap(canvases.main, nodes, NO_SCROLL); + drawTreemap(canvases.zoom, nodes, dragZoom); + } + + function drawZoomedTreemap() { + drawTreemap(canvases.zoom, nodes, dragZoom); + positionZoomedCanvas(canvases.zoom.canvas, dragZoom); + } + + drawFullTreemap(); + canvases.on("resize", drawFullTreemap); + dragZoom.on("change", drawZoomedTreemap); +}; + +/** + * Returns a configured d3 treemap function + * + * @param {HTMLCanvasElement} canvas + * @return {Function} + */ +const configureD3Treemap = (exports.configureD3Treemap = function(canvas) { + const window = canvas.ownerDocument.defaultView; + const ratio = window.devicePixelRatio; + const treemap = window.d3.layout + .treemap() + .size([ + // The d3 layout includes the padding around everything, add some + // extra padding to the size to compensate for thi + canvas.width + (PADDING[1] + PADDING[3]) * ratio, + canvas.height + (PADDING[0] + PADDING[2]) * ratio, + ]) + .sticky(true) + .padding([ + PADDING[0] * ratio, + PADDING[1] * ratio, + PADDING[2] * ratio, + PADDING[3] * ratio, + ]) + .value(d => d.bytes); + + /** + * Create treemap nodes from a census report that are sorted by depth + * + * @param {Object} report + * @return {Array} An array of d3 treemap nodes + * // https://github.com/mbostock/d3/wiki/Treemap-Layout + * parent - the parent node, or null for the root. + * children - the array of child nodes, or null for leaf nodes. + * value - the node value, as returned by the value accessor. + * depth - the depth of the node, starting at 0 for the root. + * area - the computed pixel area of this node. + * x - the minimum x-coordinate of the node position. + * y - the minimum y-coordinate of the node position. + * z - the orientation of this cell’s subdivision, if any. + * dx - the x-extent of the node position. + * dy - the y-extent of the node position. + */ + return function depthSortedNodes(report) { + const nodes = treemap(report); + nodes.sort((a, b) => a.depth - b.depth); + return nodes; + }; +}); + +/** + * Draw the text, cut it in half every time it doesn't fit until it fits or + * it's smaller than the "..." text. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Number} x + * the position of the text + * @param {Number} y + * the position of the text + * @param {Number} innerWidth + * the inner width of the containing treemap cell + * @param {Text} name + */ +const drawTruncatedName = (exports.drawTruncatedName = function( + ctx, + x, + y, + innerWidth, + name +) { + const truncated = name.substr(0, Math.floor(name.length / 2)); + const formatted = truncated + ELLIPSIS; + + if (ctx.measureText(formatted).width > innerWidth) { + drawTruncatedName(ctx, x, y, innerWidth, truncated); + } else { + ctx.fillText(formatted, x, y); + } +}); + +/** + * Fit and draw the text in a node with the following strategies to shrink + * down the text size: + * + * Function 608KB 9083 count + * Function + * Func... + * Fu... + * ... + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} node + * @param {Number} borderWidth + * @param {Object} dragZoom + * @param {Array} padding + */ +const drawText = (exports.drawText = function( + ctx, + node, + borderWidth, + ratio, + dragZoom, + padding +) { + let { dx, dy, name, totalBytes, totalCount } = node; + const scale = dragZoom.zoom + 1; + dx *= scale; + dy *= scale; + + // Start checking to see how much text we can fit in, optimizing for the + // common case of lots of small leaf nodes + if (FONT_SIZE * FONT_LINE_HEIGHT < dy) { + const margin = borderWidth(node) * 1.5 + ratio * TEXT_MARGIN; + const x = margin + (node.x - padding[0]) * scale - dragZoom.offsetX; + const y = margin + (node.y - padding[1]) * scale - dragZoom.offsetY; + const innerWidth = dx - margin * 2; + const nameSize = ctx.measureText(name).width; + + if (ctx.measureText(ELLIPSIS).width > innerWidth) { + return; + } + + ctx.fillStyle = TEXT_COLOR; + + if (nameSize > innerWidth) { + // The name is too long - halve the name as an expediant way to shorten it + drawTruncatedName(ctx, x, y, innerWidth, name); + } else { + const bytesFormatted = formatAbbreviatedBytes(totalBytes); + const countFormatted = `${totalCount} ${COUNT_LABEL}`; + const byteSize = ctx.measureText(bytesFormatted).width; + const countSize = ctx.measureText(countFormatted).width; + const spaceSize = ctx.measureText(" ").width; + + if (nameSize + byteSize + countSize + spaceSize * 3 > innerWidth) { + // The full name will fit + ctx.fillText(`${name}`, x, y); + } else { + // The full name plus the byte information will fit + ctx.fillText(name, x, y); + ctx.fillStyle = TEXT_LIGHT_COLOR; + ctx.fillText( + `${bytesFormatted} ${countFormatted}`, + x + nameSize + spaceSize, + y + ); + } + } + } +}); + +/** + * Draw a box given a node + * + * @param {CanvasRenderingContext2D} ctx + * @param {Object} node + * @param {Number} borderWidth + * @param {Number} ratio + * @param {Object} dragZoom + * @param {Array} padding + */ +const drawBox = (exports.drawBox = function( + ctx, + node, + borderWidth, + dragZoom, + padding +) { + const border = borderWidth(node); + const fillHSL = colorCoarseType(node); + const strokeHSL = [fillHSL[0], fillHSL[1], fillHSL[2] * 0.5]; + const scale = 1 + dragZoom.zoom; + + // Offset the draw so that box strokes don't overlap + const x = scale * (node.x - padding[0]) - dragZoom.offsetX + border / 2; + const y = scale * (node.y - padding[1]) - dragZoom.offsetY + border / 2; + const dx = scale * node.dx - border; + const dy = scale * node.dy - border; + + ctx.fillStyle = hslToStyle(...fillHSL); + ctx.fillRect(x, y, dx, dy); + + ctx.strokeStyle = hslToStyle(...strokeHSL); + ctx.lineWidth = border; + ctx.strokeRect(x, y, dx, dy); +}); + +/** + * Draw the overall treemap + * + * @param {HTMLCanvasElement} canvas + * @param {CanvasRenderingContext2D} ctx + * @param {Array} nodes + * @param {Objbect} dragZoom + */ +const drawTreemap = (exports.drawTreemap = function( + { canvas, ctx }, + nodes, + dragZoom +) { + const window = canvas.ownerDocument.defaultView; + const ratio = window.devicePixelRatio; + const canvasArea = canvas.width * canvas.height; + // Subtract the outer padding from the tree map layout. + const padding = [PADDING[3] * ratio, PADDING[0] * ratio]; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.font = `${FONT_SIZE * ratio}px sans-serif`; + ctx.textBaseline = "top"; + + function borderWidth(node) { + const areaRatio = Math.sqrt(node.area / canvasArea); + return ratio * Math.max(1, LINE_WIDTH * areaRatio); + } + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.parent === undefined) { + continue; + } + + drawBox(ctx, node, borderWidth, dragZoom, padding); + drawText(ctx, node, borderWidth, ratio, dragZoom, padding); + } +}); + +/** + * Set the position of the zoomed in canvas. It always take up 100% of the view + * window, but is transformed relative to the zoomed in containing element, + * essentially reversing the transform of the containing element. + * + * @param {HTMLCanvasElement} canvas + * @param {Object} dragZoom + */ +const positionZoomedCanvas = function(canvas, dragZoom) { + const scale = 1 / (1 + dragZoom.zoom); + const x = -dragZoom.translateX; + const y = -dragZoom.translateY; + canvas.style.transform = `scale(${scale}) translate(${x}px, ${y}px)`; +}; + +exports.positionZoomedCanvas = positionZoomedCanvas; diff --git a/devtools/client/memory/components/tree-map/moz.build b/devtools/client/memory/components/tree-map/moz.build new file mode 100644 index 0000000000..a9e5900339 --- /dev/null +++ b/devtools/client/memory/components/tree-map/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "canvas-utils.js", + "color-coarse-type.js", + "drag-zoom.js", + "draw.js", + "start.js", +) diff --git a/devtools/client/memory/components/tree-map/start.js b/devtools/client/memory/components/tree-map/start.js new file mode 100644 index 0000000000..9c3257e529 --- /dev/null +++ b/devtools/client/memory/components/tree-map/start.js @@ -0,0 +1,40 @@ +/* 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 { + setupDraw, +} = require("devtools/client/memory/components/tree-map/draw"); +const DragZoom = require("devtools/client/memory/components/tree-map/drag-zoom"); +const CanvasUtils = require("devtools/client/memory/components/tree-map/canvas-utils"); + +/** + * Start the tree map visualization + * + * @param {HTMLDivElement} container + * @param {Object} report + * the report from a census + * @param {Number} debounceRate + */ +module.exports = function startVisualization( + parentEl, + report, + debounceRate = 60 +) { + const window = parentEl.ownerDocument.defaultView; + const canvases = new CanvasUtils(parentEl, debounceRate); + const dragZoom = new DragZoom( + canvases.container, + debounceRate, + window.requestAnimationFrame + ); + + setupDraw(report, canvases, dragZoom); + + return function stopVisualization() { + canvases.destroy(); + dragZoom.destroy(); + }; +}; |