summaryrefslogtreecommitdiffstats
path: root/devtools/client/memory/components/tree-map
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/memory/components/tree-map')
-rw-r--r--devtools/client/memory/components/tree-map/canvas-utils.js132
-rw-r--r--devtools/client/memory/components/tree-map/color-coarse-type.js70
-rw-r--r--devtools/client/memory/components/tree-map/drag-zoom.js337
-rw-r--r--devtools/client/memory/components/tree-map/draw.js317
-rw-r--r--devtools/client/memory/components/tree-map/moz.build12
-rw-r--r--devtools/client/memory/components/tree-map/start.js40
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();
+ };
+};