summaryrefslogtreecommitdiffstats
path: root/devtools/client/memory/components
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/memory/components')
-rw-r--r--devtools/client/memory/components/Census.js94
-rw-r--r--devtools/client/memory/components/CensusHeader.js79
-rw-r--r--devtools/client/memory/components/CensusTreeItem.js185
-rw-r--r--devtools/client/memory/components/DominatorTree.js250
-rw-r--r--devtools/client/memory/components/DominatorTreeHeader.js51
-rw-r--r--devtools/client/memory/components/DominatorTreeItem.js174
-rw-r--r--devtools/client/memory/components/Heap.js547
-rw-r--r--devtools/client/memory/components/Individuals.js70
-rw-r--r--devtools/client/memory/components/IndividualsHeader.js51
-rw-r--r--devtools/client/memory/components/List.js46
-rw-r--r--devtools/client/memory/components/ShortestPaths.js196
-rw-r--r--devtools/client/memory/components/SnapshotListItem.js142
-rw-r--r--devtools/client/memory/components/Toolbar.js309
-rw-r--r--devtools/client/memory/components/TreeMap.js77
-rw-r--r--devtools/client/memory/components/moz.build25
-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
21 files changed, 3204 insertions, 0 deletions
diff --git a/devtools/client/memory/components/Census.js b/devtools/client/memory/components/Census.js
new file mode 100644
index 0000000000..d33050b019
--- /dev/null
+++ b/devtools/client/memory/components/Census.js
@@ -0,0 +1,94 @@
+/* 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 {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const Tree = createFactory(
+ require("resource://devtools/client/shared/components/VirtualizedTree.js")
+);
+const CensusTreeItem = createFactory(
+ require("resource://devtools/client/memory/components/CensusTreeItem.js")
+);
+const {
+ TREE_ROW_HEIGHT,
+} = require("resource://devtools/client/memory/constants.js");
+const {
+ censusModel,
+ diffingModel,
+} = require("resource://devtools/client/memory/models.js");
+
+class Census extends Component {
+ static get propTypes() {
+ return {
+ census: censusModel,
+ onExpand: PropTypes.func.isRequired,
+ onCollapse: PropTypes.func.isRequired,
+ onFocus: PropTypes.func.isRequired,
+ onViewSourceInDebugger: PropTypes.func.isRequired,
+ onViewIndividuals: PropTypes.func.isRequired,
+ diffing: diffingModel,
+ };
+ }
+
+ render() {
+ const {
+ census,
+ onExpand,
+ onCollapse,
+ onFocus,
+ diffing,
+ onViewSourceInDebugger,
+ onViewIndividuals,
+ } = this.props;
+
+ const report = census.report;
+ const parentMap = census.parentMap;
+ const { totalBytes, totalCount } = report;
+
+ const getPercentBytes =
+ totalBytes === 0 ? _ => 0 : bytes => (bytes / totalBytes) * 100;
+
+ const getPercentCount =
+ totalCount === 0 ? _ => 0 : count => (count / totalCount) * 100;
+
+ return Tree({
+ autoExpandDepth: 0,
+ preventNavigationOnArrowRight: false,
+ focused: census.focused,
+ getParent: node => {
+ const parent = parentMap[node.id];
+ return parent === report ? null : parent;
+ },
+ getChildren: node => node.children || [],
+ isExpanded: node => census.expanded.has(node.id),
+ onExpand,
+ onCollapse,
+ onFocus,
+ renderItem: (item, depth, focused, arrow, expanded) =>
+ new CensusTreeItem({
+ onViewSourceInDebugger,
+ item,
+ depth,
+ focused,
+ arrow,
+ expanded,
+ getPercentBytes,
+ getPercentCount,
+ diffing,
+ inverted: census.display.inverted,
+ onViewIndividuals,
+ }),
+ getRoots: () => report.children || [],
+ getKey: node => node.id,
+ itemHeight: TREE_ROW_HEIGHT,
+ });
+ }
+}
+
+module.exports = Census;
diff --git a/devtools/client/memory/components/CensusHeader.js b/devtools/client/memory/components/CensusHeader.js
new file mode 100644
index 0000000000..bdd9d15087
--- /dev/null
+++ b/devtools/client/memory/components/CensusHeader.js
@@ -0,0 +1,79 @@
+/* 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 {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const { L10N } = require("resource://devtools/client/memory/utils.js");
+const models = require("resource://devtools/client/memory/models.js");
+
+class CensusHeader extends Component {
+ static get propTypes() {
+ return {
+ diffing: models.diffingModel,
+ };
+ }
+
+ render() {
+ let individualsCell;
+ if (!this.props.diffing) {
+ individualsCell = dom.span({
+ className: "heap-tree-item-field heap-tree-item-individuals",
+ });
+ }
+
+ return dom.div(
+ {
+ className: "header",
+ },
+
+ dom.span(
+ {
+ className: "heap-tree-item-bytes",
+ title: L10N.getStr("heapview.field.bytes.tooltip"),
+ },
+ L10N.getStr("heapview.field.bytes")
+ ),
+
+ dom.span(
+ {
+ className: "heap-tree-item-count",
+ title: L10N.getStr("heapview.field.count.tooltip"),
+ },
+ L10N.getStr("heapview.field.count")
+ ),
+
+ dom.span(
+ {
+ className: "heap-tree-item-total-bytes",
+ title: L10N.getStr("heapview.field.totalbytes.tooltip"),
+ },
+ L10N.getStr("heapview.field.totalbytes")
+ ),
+
+ dom.span(
+ {
+ className: "heap-tree-item-total-count",
+ title: L10N.getStr("heapview.field.totalcount.tooltip"),
+ },
+ L10N.getStr("heapview.field.totalcount")
+ ),
+
+ individualsCell,
+
+ dom.span(
+ {
+ className: "heap-tree-item-name",
+ title: L10N.getStr("heapview.field.name.tooltip"),
+ },
+ L10N.getStr("heapview.field.name")
+ )
+ );
+ }
+}
+
+module.exports = CensusHeader;
diff --git a/devtools/client/memory/components/CensusTreeItem.js b/devtools/client/memory/components/CensusTreeItem.js
new file mode 100644
index 0000000000..39fdbeb052
--- /dev/null
+++ b/devtools/client/memory/components/CensusTreeItem.js
@@ -0,0 +1,185 @@
+/* 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 { isSavedFrame } = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ L10N,
+ formatNumber,
+ formatPercent,
+} = require("resource://devtools/client/memory/utils.js");
+const Frame = createFactory(
+ require("resource://devtools/client/shared/components/Frame.js")
+);
+const {
+ TREE_ROW_HEIGHT,
+} = require("resource://devtools/client/memory/constants.js");
+const models = require("resource://devtools/client/memory/models.js");
+
+class CensusTreeItem extends Component {
+ static get propTypes() {
+ return {
+ arrow: PropTypes.any,
+ depth: PropTypes.number.isRequired,
+ diffing: models.app.diffing,
+ expanded: PropTypes.bool.isRequired,
+ focused: PropTypes.bool.isRequired,
+ getPercentBytes: PropTypes.func.isRequired,
+ getPercentCount: PropTypes.func.isRequired,
+ inverted: PropTypes.bool,
+ item: PropTypes.object.isRequired,
+ onViewIndividuals: PropTypes.func.isRequired,
+ onViewSourceInDebugger: PropTypes.func.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.toLabel = this.toLabel.bind(this);
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ this.props.item != nextProps.item ||
+ this.props.depth != nextProps.depth ||
+ this.props.expanded != nextProps.expanded ||
+ this.props.focused != nextProps.focused ||
+ this.props.diffing != nextProps.diffing
+ );
+ }
+
+ toLabel(name, onViewSourceInDebugger) {
+ if (isSavedFrame(name)) {
+ return Frame({
+ frame: name,
+ onClick: onViewSourceInDebugger,
+ showFunctionName: true,
+ showHost: true,
+ });
+ }
+
+ if (name === null) {
+ return L10N.getStr("tree-item.root");
+ }
+
+ if (name === "noStack") {
+ return L10N.getStr("tree-item.nostack");
+ }
+
+ if (name === "noFilename") {
+ return L10N.getStr("tree-item.nofilename");
+ }
+
+ return String(name);
+ }
+
+ render() {
+ const {
+ item,
+ depth,
+ arrow,
+ focused,
+ getPercentBytes,
+ getPercentCount,
+ diffing,
+ onViewSourceInDebugger,
+ onViewIndividuals,
+ inverted,
+ } = this.props;
+
+ const bytes = formatNumber(item.bytes, !!diffing);
+ const percentBytes = formatPercent(getPercentBytes(item.bytes), !!diffing);
+
+ const count = formatNumber(item.count, !!diffing);
+ const percentCount = formatPercent(getPercentCount(item.count), !!diffing);
+
+ const totalBytes = formatNumber(item.totalBytes, !!diffing);
+ const percentTotalBytes = formatPercent(
+ getPercentBytes(item.totalBytes),
+ !!diffing
+ );
+
+ const totalCount = formatNumber(item.totalCount, !!diffing);
+ const percentTotalCount = formatPercent(
+ getPercentCount(item.totalCount),
+ !!diffing
+ );
+
+ let pointer;
+ if (inverted && depth > 0) {
+ pointer = dom.span({ className: "children-pointer" }, "↖");
+ } else if (!inverted && item.children?.length) {
+ pointer = dom.span({ className: "children-pointer" }, "↘");
+ }
+
+ let individualsCell;
+ if (!diffing) {
+ let individualsButton;
+ if (item.reportLeafIndex !== undefined) {
+ individualsButton = dom.button(
+ {
+ key: `individuals-button-${item.id}`,
+ title: L10N.getStr("tree-item.view-individuals.tooltip"),
+ className: "devtools-button individuals-button",
+ onClick: e => {
+ // Don't let the event bubble up to cause this item to focus after
+ // we have switched views, which would lead to assertion failures.
+ e.preventDefault();
+ e.stopPropagation();
+
+ onViewIndividuals(item);
+ },
+ },
+ "⁂"
+ );
+ }
+ individualsCell = dom.span(
+ { className: "heap-tree-item-field heap-tree-item-individuals" },
+ individualsButton
+ );
+ }
+
+ return dom.div(
+ { className: `heap-tree-item ${focused ? "focused" : ""}` },
+ dom.span(
+ { className: "heap-tree-item-field heap-tree-item-bytes" },
+ dom.span({ className: "heap-tree-number" }, bytes),
+ dom.span({ className: "heap-tree-percent" }, percentBytes)
+ ),
+ dom.span(
+ { className: "heap-tree-item-field heap-tree-item-count" },
+ dom.span({ className: "heap-tree-number" }, count),
+ dom.span({ className: "heap-tree-percent" }, percentCount)
+ ),
+ dom.span(
+ { className: "heap-tree-item-field heap-tree-item-total-bytes" },
+ dom.span({ className: "heap-tree-number" }, totalBytes),
+ dom.span({ className: "heap-tree-percent" }, percentTotalBytes)
+ ),
+ dom.span(
+ { className: "heap-tree-item-field heap-tree-item-total-count" },
+ dom.span({ className: "heap-tree-number" }, totalCount),
+ dom.span({ className: "heap-tree-percent" }, percentTotalCount)
+ ),
+ individualsCell,
+ dom.span(
+ {
+ className: "heap-tree-item-field heap-tree-item-name",
+ style: { marginInlineStart: depth * TREE_ROW_HEIGHT },
+ },
+ arrow,
+ pointer,
+ this.toLabel(item.name, onViewSourceInDebugger)
+ )
+ );
+ }
+}
+
+module.exports = CensusTreeItem;
diff --git a/devtools/client/memory/components/DominatorTree.js b/devtools/client/memory/components/DominatorTree.js
new file mode 100644
index 0000000000..9c767cece7
--- /dev/null
+++ b/devtools/client/memory/components/DominatorTree.js
@@ -0,0 +1,250 @@
+/* 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 {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const { assert } = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ createParentMap,
+} = require("resource://devtools/shared/heapsnapshot/CensusUtils.js");
+const Tree = createFactory(
+ require("resource://devtools/client/shared/components/VirtualizedTree.js")
+);
+const DominatorTreeItem = createFactory(
+ require("resource://devtools/client/memory/components/DominatorTreeItem.js")
+);
+const { L10N } = require("resource://devtools/client/memory/utils.js");
+const {
+ TREE_ROW_HEIGHT,
+ dominatorTreeState,
+} = require("resource://devtools/client/memory/constants.js");
+const {
+ dominatorTreeModel,
+} = require("resource://devtools/client/memory/models.js");
+const DominatorTreeLazyChildren = require("resource://devtools/client/memory/dominator-tree-lazy-children.js");
+
+const DOMINATOR_TREE_AUTO_EXPAND_DEPTH = 3;
+
+/**
+ * A throbber that represents a subtree in the dominator tree that is actively
+ * being incrementally loaded and fetched from the `HeapAnalysesWorker`.
+ */
+class DominatorTreeSubtreeFetchingClass extends Component {
+ static get propTypes() {
+ return {
+ depth: PropTypes.number.isRequired,
+ focused: PropTypes.bool.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ this.props.depth !== nextProps.depth ||
+ this.props.focused !== nextProps.focused
+ );
+ }
+
+ render() {
+ const { depth, focused } = this.props;
+
+ return dom.div(
+ {
+ className: `heap-tree-item subtree-fetching ${
+ focused ? "focused" : ""
+ }`,
+ },
+ dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }),
+ dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }),
+ dom.span({
+ className: "heap-tree-item-field heap-tree-item-name devtools-throbber",
+ style: { marginInlineStart: depth * TREE_ROW_HEIGHT },
+ })
+ );
+ }
+}
+
+/**
+ * A link to fetch and load more siblings in the dominator tree, when there are
+ * already many loaded above.
+ */
+class DominatorTreeSiblingLinkClass extends Component {
+ static get propTypes() {
+ return {
+ depth: PropTypes.number.isRequired,
+ focused: PropTypes.bool.isRequired,
+ item: PropTypes.instanceOf(DominatorTreeLazyChildren).isRequired,
+ onLoadMoreSiblings: PropTypes.func.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ this.props.depth !== nextProps.depth ||
+ this.props.focused !== nextProps.focused
+ );
+ }
+
+ render() {
+ const { depth, focused, item, onLoadMoreSiblings } = this.props;
+
+ return dom.div(
+ {
+ className: `heap-tree-item more-children ${focused ? "focused" : ""}`,
+ },
+ dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }),
+ dom.span({ className: "heap-tree-item-field heap-tree-item-bytes" }),
+ dom.span(
+ {
+ className: "heap-tree-item-field heap-tree-item-name",
+ style: { marginInlineStart: depth * TREE_ROW_HEIGHT },
+ },
+ dom.a(
+ {
+ className: "load-more-link",
+ onClick: () => onLoadMoreSiblings(item),
+ },
+ L10N.getStr("tree-item.load-more")
+ )
+ )
+ );
+ }
+}
+
+class DominatorTree extends Component {
+ static get propTypes() {
+ return {
+ dominatorTree: dominatorTreeModel.isRequired,
+ onLoadMoreSiblings: PropTypes.func.isRequired,
+ onViewSourceInDebugger: PropTypes.func.isRequired,
+ onExpand: PropTypes.func.isRequired,
+ onCollapse: PropTypes.func.isRequired,
+ onFocus: PropTypes.func.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ // Safe to use referential equality here because all of our mutations on
+ // dominator tree models use immutableUpdate in a persistent manner. The
+ // exception to the rule are mutations of the expanded set, however we take
+ // care that the dominatorTree model itself is still re-allocated when
+ // mutations to the expanded set occur. Because of the re-allocations, we
+ // can continue using referential equality here.
+ return this.props.dominatorTree !== nextProps.dominatorTree;
+ }
+
+ render() {
+ const { dominatorTree, onViewSourceInDebugger, onLoadMoreSiblings } =
+ this.props;
+
+ const parentMap = createParentMap(dominatorTree.root, node => node.nodeId);
+
+ return Tree({
+ key: "dominator-tree-tree",
+ autoExpandDepth: DOMINATOR_TREE_AUTO_EXPAND_DEPTH,
+ preventNavigationOnArrowRight: false,
+ focused: dominatorTree.focused,
+ getParent: node =>
+ node instanceof DominatorTreeLazyChildren
+ ? parentMap[node.parentNodeId()]
+ : parentMap[node.nodeId],
+ getChildren: node => {
+ const children = node.children ? node.children.slice() : [];
+ if (node.moreChildrenAvailable) {
+ children.push(
+ new DominatorTreeLazyChildren(node.nodeId, children.length)
+ );
+ }
+ return children;
+ },
+ isExpanded: node => {
+ return node instanceof DominatorTreeLazyChildren
+ ? false
+ : dominatorTree.expanded.has(node.nodeId);
+ },
+ onExpand: item => {
+ if (item instanceof DominatorTreeLazyChildren) {
+ return;
+ }
+
+ if (
+ item.moreChildrenAvailable &&
+ (!item.children || !item.children.length)
+ ) {
+ const startIndex = item.children ? item.children.length : 0;
+ onLoadMoreSiblings(
+ new DominatorTreeLazyChildren(item.nodeId, startIndex)
+ );
+ }
+
+ this.props.onExpand(item);
+ },
+ onCollapse: item => {
+ if (item instanceof DominatorTreeLazyChildren) {
+ return;
+ }
+
+ this.props.onCollapse(item);
+ },
+ onFocus: item => {
+ if (item instanceof DominatorTreeLazyChildren) {
+ return;
+ }
+
+ this.props.onFocus(item);
+ },
+ renderItem: (item, depth, focused, arrow, expanded) => {
+ if (item instanceof DominatorTreeLazyChildren) {
+ if (item.isFirstChild()) {
+ assert(
+ dominatorTree.state === dominatorTreeState.INCREMENTAL_FETCHING,
+ "If we are displaying a throbber for loading a subtree, " +
+ "then we should be INCREMENTAL_FETCHING those children right now"
+ );
+ return DominatorTreeSubtreeFetching({
+ key: item.key(),
+ depth,
+ focused,
+ });
+ }
+
+ return DominatorTreeSiblingLink({
+ key: item.key(),
+ item,
+ depth,
+ focused,
+ onLoadMoreSiblings,
+ });
+ }
+
+ return DominatorTreeItem({
+ item,
+ depth,
+ focused,
+ arrow,
+ expanded,
+ getPercentSize: size =>
+ (size / dominatorTree.root.retainedSize) * 100,
+ onViewSourceInDebugger,
+ });
+ },
+ getRoots: () => [dominatorTree.root],
+ getKey: node =>
+ node instanceof DominatorTreeLazyChildren ? node.key() : node.nodeId,
+ itemHeight: TREE_ROW_HEIGHT,
+ });
+ }
+}
+
+const DominatorTreeSubtreeFetching = createFactory(
+ DominatorTreeSubtreeFetchingClass
+);
+const DominatorTreeSiblingLink = createFactory(DominatorTreeSiblingLinkClass);
+
+module.exports = DominatorTree;
diff --git a/devtools/client/memory/components/DominatorTreeHeader.js b/devtools/client/memory/components/DominatorTreeHeader.js
new file mode 100644
index 0000000000..292b74ea60
--- /dev/null
+++ b/devtools/client/memory/components/DominatorTreeHeader.js
@@ -0,0 +1,51 @@
+/* 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 {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const { L10N } = require("resource://devtools/client/memory/utils.js");
+
+class DominatorTreeHeader extends Component {
+ static get propTypes() {
+ return {};
+ }
+
+ render() {
+ return dom.div(
+ {
+ className: "header",
+ },
+
+ dom.span(
+ {
+ className: "heap-tree-item-bytes",
+ title: L10N.getStr("heapview.field.retainedSize.tooltip"),
+ },
+ L10N.getStr("heapview.field.retainedSize")
+ ),
+
+ dom.span(
+ {
+ className: "heap-tree-item-bytes",
+ title: L10N.getStr("heapview.field.shallowSize.tooltip"),
+ },
+ L10N.getStr("heapview.field.shallowSize")
+ ),
+
+ dom.span(
+ {
+ className: "heap-tree-item-name",
+ title: L10N.getStr("dominatortree.field.label.tooltip"),
+ },
+ L10N.getStr("dominatortree.field.label")
+ )
+ );
+ }
+}
+
+module.exports = DominatorTreeHeader;
diff --git a/devtools/client/memory/components/DominatorTreeItem.js b/devtools/client/memory/components/DominatorTreeItem.js
new file mode 100644
index 0000000000..59cb542a3a
--- /dev/null
+++ b/devtools/client/memory/components/DominatorTreeItem.js
@@ -0,0 +1,174 @@
+/* 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 {
+ assert,
+ isSavedFrame,
+} = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ L10N,
+ formatNumber,
+ formatPercent,
+} = require("resource://devtools/client/memory/utils.js");
+const Frame = createFactory(
+ require("resource://devtools/client/shared/components/Frame.js")
+);
+const {
+ TREE_ROW_HEIGHT,
+} = require("resource://devtools/client/memory/constants.js");
+
+class SeparatorClass extends Component {
+ render() {
+ return dom.span({ className: "separator" }, "›");
+ }
+}
+
+const Separator = createFactory(SeparatorClass);
+
+class DominatorTreeItem extends Component {
+ static get propTypes() {
+ return {
+ item: PropTypes.object.isRequired,
+ depth: PropTypes.number.isRequired,
+ arrow: PropTypes.object,
+ expanded: PropTypes.bool.isRequired,
+ focused: PropTypes.bool.isRequired,
+ getPercentSize: PropTypes.func.isRequired,
+ onViewSourceInDebugger: PropTypes.func.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ this.props.item != nextProps.item ||
+ this.props.depth != nextProps.depth ||
+ this.props.expanded != nextProps.expanded ||
+ this.props.focused != nextProps.focused
+ );
+ }
+
+ render() {
+ const {
+ item,
+ depth,
+ arrow,
+ focused,
+ getPercentSize,
+ onViewSourceInDebugger,
+ } = this.props;
+
+ const retainedSize = formatNumber(item.retainedSize);
+ const percentRetainedSize = formatPercent(
+ getPercentSize(item.retainedSize)
+ );
+
+ const shallowSize = formatNumber(item.shallowSize);
+ const percentShallowSize = formatPercent(getPercentSize(item.shallowSize));
+
+ // Build up our label UI as an array of each label piece, which is either a
+ // string or a frame, and separators in between them.
+
+ assert(!!item.label.length, "Our label should not be empty");
+ const label = Array(item.label.length * 2 - 1);
+ label.fill(undefined);
+
+ for (let i = 0, length = item.label.length; i < length; i++) {
+ const piece = item.label[i];
+ const key = `${item.nodeId}-label-${i}`;
+
+ // `i` is the index of the label piece we are rendering, `label[i*2]` is
+ // where the rendered label piece belngs, and `label[i*2+1]` (if it isn't
+ // out of bounds) is where the separator belongs.
+
+ if (isSavedFrame(piece)) {
+ label[i * 2] = Frame({
+ key,
+ onClick: onViewSourceInDebugger,
+ frame: piece,
+ showFunctionName: true,
+ });
+ } else if (piece === "noStack") {
+ label[i * 2] = dom.span(
+ { key, className: "not-available" },
+ L10N.getStr("tree-item.nostack")
+ );
+ } else if (piece === "noFilename") {
+ label[i * 2] = dom.span(
+ { key, className: "not-available" },
+ L10N.getStr("tree-item.nofilename")
+ );
+ } else if (piece === "JS::ubi::RootList") {
+ // Don't use the usual labeling machinery for root lists: replace it
+ // with the "GC Roots" string.
+ label.splice(0, label.length);
+ label.push(L10N.getStr("tree-item.rootlist"));
+ break;
+ } else {
+ label[i * 2] = piece;
+ }
+
+ // If this is not the last piece of the label, add a separator.
+ if (i < length - 1) {
+ label[i * 2 + 1] = Separator({ key: `${item.nodeId}-separator-${i}` });
+ }
+ }
+
+ return dom.div(
+ {
+ className: `heap-tree-item ${focused ? "focused" : ""} node-${
+ item.nodeId
+ }`,
+ },
+
+ dom.span(
+ {
+ className: "heap-tree-item-field heap-tree-item-bytes",
+ },
+ dom.span(
+ {
+ className: "heap-tree-number",
+ },
+ retainedSize
+ ),
+ dom.span({ className: "heap-tree-percent" }, percentRetainedSize)
+ ),
+
+ dom.span(
+ {
+ className: "heap-tree-item-field heap-tree-item-bytes",
+ },
+ dom.span(
+ {
+ className: "heap-tree-number",
+ },
+ shallowSize
+ ),
+ dom.span({ className: "heap-tree-percent" }, percentShallowSize)
+ ),
+
+ dom.span(
+ {
+ className: "heap-tree-item-field heap-tree-item-name",
+ style: { marginInlineStart: depth * TREE_ROW_HEIGHT },
+ },
+ arrow,
+ label,
+ dom.span(
+ { className: "heap-tree-item-address" },
+ `@ 0x${item.nodeId.toString(16)}`
+ )
+ )
+ );
+ }
+}
+
+module.exports = DominatorTreeItem;
diff --git a/devtools/client/memory/components/Heap.js b/devtools/client/memory/components/Heap.js
new file mode 100644
index 0000000000..2c78a63749
--- /dev/null
+++ b/devtools/client/memory/components/Heap.js
@@ -0,0 +1,547 @@
+/* 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 {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ assert,
+ safeErrorString,
+} = require("resource://devtools/shared/DevToolsUtils.js");
+const Census = createFactory(
+ require("resource://devtools/client/memory/components/Census.js")
+);
+const CensusHeader = createFactory(
+ require("resource://devtools/client/memory/components/CensusHeader.js")
+);
+const DominatorTree = createFactory(
+ require("resource://devtools/client/memory/components/DominatorTree.js")
+);
+const DominatorTreeHeader = createFactory(
+ require("resource://devtools/client/memory/components/DominatorTreeHeader.js")
+);
+const TreeMap = createFactory(
+ require("resource://devtools/client/memory/components/TreeMap.js")
+);
+const HSplitBox = createFactory(
+ require("resource://devtools/client/shared/components/HSplitBox.js")
+);
+const Individuals = createFactory(
+ require("resource://devtools/client/memory/components/Individuals.js")
+);
+const IndividualsHeader = createFactory(
+ require("resource://devtools/client/memory/components/IndividualsHeader.js")
+);
+const ShortestPaths = createFactory(
+ require("resource://devtools/client/memory/components/ShortestPaths.js")
+);
+const {
+ getStatusTextFull,
+ L10N,
+} = require("resource://devtools/client/memory/utils.js");
+const {
+ snapshotState: states,
+ diffingState,
+ viewState,
+ censusState,
+ treeMapState,
+ dominatorTreeState,
+ individualsState,
+} = require("resource://devtools/client/memory/constants.js");
+const models = require("resource://devtools/client/memory/models.js");
+const { snapshot: snapshotModel, diffingModel } = models;
+
+/**
+ * Get the app state's current state atom.
+ *
+ * @see the relevant state string constants in `../constants.js`.
+ *
+ * @param {models.view} view
+ * @param {snapshotModel} snapshot
+ * @param {diffingModel} diffing
+ * @param {individualsModel} individuals
+ *
+ * @return {snapshotState|diffingState|dominatorTreeState}
+ */
+function getState(view, snapshot, diffing, individuals) {
+ switch (view.state) {
+ case viewState.CENSUS:
+ return snapshot.census ? snapshot.census.state : snapshot.state;
+
+ case viewState.DIFFING:
+ return diffing.state;
+
+ case viewState.TREE_MAP:
+ return snapshot.treeMap ? snapshot.treeMap.state : snapshot.state;
+
+ case viewState.DOMINATOR_TREE:
+ return snapshot.dominatorTree
+ ? snapshot.dominatorTree.state
+ : snapshot.state;
+
+ case viewState.INDIVIDUALS:
+ return individuals.state;
+ }
+
+ assert(false, `Unexpected view state: ${view.state}`);
+ return null;
+}
+
+/**
+ * Return true if we should display a status message when we are in the given
+ * state. Return false otherwise.
+ *
+ * @param {snapshotState|diffingState|dominatorTreeState} state
+ * @param {models.view} view
+ * @param {snapshotModel} snapshot
+ *
+ * @returns {Boolean}
+ */
+function shouldDisplayStatus(state, view, snapshot) {
+ switch (state) {
+ case states.IMPORTING:
+ case states.SAVING:
+ case states.SAVED:
+ case states.READING:
+ case censusState.SAVING:
+ case treeMapState.SAVING:
+ case diffingState.SELECTING:
+ case diffingState.TAKING_DIFF:
+ case dominatorTreeState.COMPUTING:
+ case dominatorTreeState.COMPUTED:
+ case dominatorTreeState.FETCHING:
+ case individualsState.COMPUTING_DOMINATOR_TREE:
+ case individualsState.FETCHING:
+ return true;
+ }
+ return view.state === viewState.DOMINATOR_TREE && !snapshot.dominatorTree;
+}
+
+/**
+ * Get the status text to display for the given state.
+ *
+ * @param {snapshotState|diffingState|dominatorTreeState} state
+ * @param {diffingModel} diffing
+ *
+ * @returns {String}
+ */
+function getStateStatusText(state, diffing) {
+ if (state === diffingState.SELECTING) {
+ return L10N.getStr(
+ diffing.firstSnapshotId === null
+ ? "diffing.prompt.selectBaseline"
+ : "diffing.prompt.selectComparison"
+ );
+ }
+
+ return getStatusTextFull(state);
+}
+
+/**
+ * Given that we should display a status message, return true if we should also
+ * display a throbber along with the status message. Return false otherwise.
+ *
+ * @param {diffingModel} diffing
+ *
+ * @returns {Boolean}
+ */
+function shouldDisplayThrobber(diffing) {
+ return !diffing || diffing.state !== diffingState.SELECTING;
+}
+
+/**
+ * Get the current state's error, or return null if there is none.
+ *
+ * @param {snapshotModel} snapshot
+ * @param {diffingModel} diffing
+ * @param {individualsModel} individuals
+ *
+ * @returns {Error|null}
+ */
+function getError(snapshot, diffing, individuals) {
+ if (diffing) {
+ if (diffing.state === diffingState.ERROR) {
+ return diffing.error;
+ }
+ if (diffing.census === censusState.ERROR) {
+ return diffing.census.error;
+ }
+ }
+
+ if (snapshot) {
+ if (snapshot.state === states.ERROR) {
+ return snapshot.error;
+ }
+
+ if (snapshot.census === censusState.ERROR) {
+ return snapshot.census.error;
+ }
+
+ if (snapshot.treeMap === treeMapState.ERROR) {
+ return snapshot.treeMap.error;
+ }
+
+ if (
+ snapshot.dominatorTree &&
+ snapshot.dominatorTree.state === dominatorTreeState.ERROR
+ ) {
+ return snapshot.dominatorTree.error;
+ }
+ }
+
+ if (individuals && individuals.state === individualsState.ERROR) {
+ return individuals.error;
+ }
+
+ return null;
+}
+
+/**
+ * Main view for the memory tool.
+ *
+ * The Heap component contains several panels for different states; an initial
+ * state of only a button to take a snapshot, loading states, the census view
+ * tree, the dominator tree, etc.
+ */
+class Heap extends Component {
+ static get propTypes() {
+ return {
+ onSnapshotClick: PropTypes.func.isRequired,
+ onLoadMoreSiblings: PropTypes.func.isRequired,
+ onCensusExpand: PropTypes.func.isRequired,
+ onCensusCollapse: PropTypes.func.isRequired,
+ onDominatorTreeExpand: PropTypes.func.isRequired,
+ onDominatorTreeCollapse: PropTypes.func.isRequired,
+ onCensusFocus: PropTypes.func.isRequired,
+ onDominatorTreeFocus: PropTypes.func.isRequired,
+ onShortestPathsResize: PropTypes.func.isRequired,
+ snapshot: snapshotModel,
+ onViewSourceInDebugger: PropTypes.func.isRequired,
+ onPopView: PropTypes.func.isRequired,
+ individuals: models.individuals,
+ onViewIndividuals: PropTypes.func.isRequired,
+ onFocusIndividual: PropTypes.func.isRequired,
+ diffing: diffingModel,
+ view: models.view.isRequired,
+ sizes: PropTypes.object.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this._renderHeapView = this._renderHeapView.bind(this);
+ this._renderInitial = this._renderInitial.bind(this);
+ this._renderStatus = this._renderStatus.bind(this);
+ this._renderError = this._renderError.bind(this);
+ this._renderCensus = this._renderCensus.bind(this);
+ this._renderTreeMap = this._renderTreeMap.bind(this);
+ this._renderIndividuals = this._renderIndividuals.bind(this);
+ this._renderDominatorTree = this._renderDominatorTree.bind(this);
+ }
+
+ /**
+ * Render the heap view's container panel with the given contents inside of
+ * it.
+ *
+ * @param {snapshotState|diffingState|dominatorTreeState} state
+ * @param {...Any} contents
+ */
+ _renderHeapView(state, ...contents) {
+ return dom.div(
+ {
+ id: "heap-view",
+ "data-state": state,
+ },
+ dom.div(
+ {
+ className: "heap-view-panel",
+ "data-state": state,
+ },
+ ...contents
+ )
+ );
+ }
+
+ _renderInitial(onSnapshotClick) {
+ return this._renderHeapView(
+ "initial",
+ dom.button(
+ {
+ className: "devtools-button take-snapshot",
+ onClick: onSnapshotClick,
+ "data-standalone": true,
+ },
+ L10N.getStr("take-snapshot")
+ )
+ );
+ }
+
+ _renderStatus(state, statusText, diffing) {
+ let throbber = "";
+ if (shouldDisplayThrobber(diffing)) {
+ throbber = "devtools-throbber";
+ }
+
+ return this._renderHeapView(
+ state,
+ dom.span(
+ {
+ className: `snapshot-status ${throbber}`,
+ },
+ statusText
+ )
+ );
+ }
+
+ _renderError(state, statusText, error) {
+ return this._renderHeapView(
+ state,
+ dom.span({ className: "snapshot-status error" }, statusText),
+ dom.pre({}, safeErrorString(error))
+ );
+ }
+
+ _renderCensus(
+ state,
+ census,
+ diffing,
+ onViewSourceInDebugger,
+ onViewIndividuals
+ ) {
+ assert(
+ census.report,
+ "Should not render census that does not have a report"
+ );
+
+ if (!census.report.children) {
+ const censusFilterMsg = census.filter
+ ? L10N.getStr("heapview.none-match")
+ : L10N.getStr("heapview.empty");
+ const msg = diffing
+ ? L10N.getStr("heapview.no-difference")
+ : censusFilterMsg;
+ return this._renderHeapView(state, dom.div({ className: "empty" }, msg));
+ }
+
+ const contents = [];
+
+ if (
+ census.display.breakdown.by === "allocationStack" &&
+ census.report.children &&
+ census.report.children.length === 1 &&
+ census.report.children[0].name === "noStack"
+ ) {
+ contents.push(
+ dom.div(
+ { className: "error no-allocation-stacks" },
+ L10N.getStr("heapview.noAllocationStacks")
+ )
+ );
+ }
+
+ contents.push(CensusHeader({ diffing }));
+ contents.push(
+ Census({
+ onViewSourceInDebugger,
+ onViewIndividuals,
+ diffing,
+ census,
+ onExpand: node => this.props.onCensusExpand(census, node),
+ onCollapse: node => this.props.onCensusCollapse(census, node),
+ onFocus: node => this.props.onCensusFocus(census, node),
+ })
+ );
+
+ return this._renderHeapView(state, ...contents);
+ }
+
+ _renderTreeMap(state, treeMap) {
+ return this._renderHeapView(state, TreeMap({ treeMap }));
+ }
+
+ _renderIndividuals(
+ state,
+ individuals,
+ dominatorTree,
+ onViewSourceInDebugger
+ ) {
+ assert(
+ individuals.state === individualsState.FETCHED,
+ "Should have fetched individuals"
+ );
+ assert(dominatorTree?.root, "Should have a dominator tree and its root");
+
+ const tree = dom.div(
+ {
+ className: "vbox",
+ style: {
+ overflowY: "auto",
+ },
+ },
+ IndividualsHeader(),
+ Individuals({
+ individuals,
+ dominatorTree,
+ onViewSourceInDebugger,
+ onFocus: this.props.onFocusIndividual,
+ })
+ );
+
+ const shortestPaths = ShortestPaths({
+ graph: individuals.focused ? individuals.focused.shortestPaths : null,
+ });
+
+ return this._renderHeapView(
+ state,
+ dom.div(
+ { className: "hbox devtools-toolbar" },
+ dom.label(
+ { id: "pop-view-button-label" },
+ dom.button(
+ {
+ id: "pop-view-button",
+ className: "devtools-button",
+ onClick: this.props.onPopView,
+ },
+ L10N.getStr("toolbar.pop-view")
+ ),
+ L10N.getStr("toolbar.pop-view.label")
+ ),
+ dom.span(
+ { className: "toolbar-text" },
+ L10N.getStr("toolbar.viewing-individuals")
+ )
+ ),
+ HSplitBox({
+ start: tree,
+ end: shortestPaths,
+ startWidth: this.props.sizes.shortestPathsSize,
+ onResize: this.props.onShortestPathsResize,
+ })
+ );
+ }
+
+ _renderDominatorTree(
+ state,
+ onViewSourceInDebugger,
+ dominatorTree,
+ onLoadMoreSiblings
+ ) {
+ const tree = dom.div(
+ {
+ className: "vbox",
+ style: {
+ overflowY: "auto",
+ },
+ },
+ DominatorTreeHeader(),
+ DominatorTree({
+ onViewSourceInDebugger,
+ dominatorTree,
+ onLoadMoreSiblings,
+ onExpand: this.props.onDominatorTreeExpand,
+ onCollapse: this.props.onDominatorTreeCollapse,
+ onFocus: this.props.onDominatorTreeFocus,
+ })
+ );
+
+ const shortestPaths = ShortestPaths({
+ graph: dominatorTree.focused ? dominatorTree.focused.shortestPaths : null,
+ });
+
+ return this._renderHeapView(
+ state,
+ HSplitBox({
+ start: tree,
+ end: shortestPaths,
+ startWidth: this.props.sizes.shortestPathsSize,
+ onResize: this.props.onShortestPathsResize,
+ })
+ );
+ }
+
+ render() {
+ const {
+ snapshot,
+ diffing,
+ onSnapshotClick,
+ onLoadMoreSiblings,
+ onViewSourceInDebugger,
+ onViewIndividuals,
+ individuals,
+ view,
+ } = this.props;
+
+ if (!diffing && !snapshot && !individuals) {
+ return this._renderInitial(onSnapshotClick);
+ }
+
+ const state = getState(view, snapshot, diffing, individuals);
+ const statusText = getStateStatusText(state, diffing);
+
+ if (shouldDisplayStatus(state, view, snapshot)) {
+ return this._renderStatus(state, statusText, diffing);
+ }
+
+ const error = getError(snapshot, diffing, individuals);
+ if (error) {
+ return this._renderError(state, statusText, error);
+ }
+
+ if (view.state === viewState.CENSUS || view.state === viewState.DIFFING) {
+ const census =
+ view.state === viewState.CENSUS ? snapshot.census : diffing.census;
+ if (!census) {
+ return this._renderStatus(state, statusText, diffing);
+ }
+ return this._renderCensus(
+ state,
+ census,
+ diffing,
+ onViewSourceInDebugger,
+ onViewIndividuals
+ );
+ }
+
+ if (view.state === viewState.TREE_MAP) {
+ return this._renderTreeMap(state, snapshot.treeMap);
+ }
+
+ if (view.state === viewState.INDIVIDUALS) {
+ assert(
+ individuals.state === individualsState.FETCHED,
+ "Should have fetched the individuals -- " +
+ "other states are rendered as statuses"
+ );
+ return this._renderIndividuals(
+ state,
+ individuals,
+ individuals.dominatorTree,
+ onViewSourceInDebugger
+ );
+ }
+
+ assert(
+ view.state === viewState.DOMINATOR_TREE,
+ "If we aren't in progress, looking at a census, or diffing, then we " +
+ "must be looking at a dominator tree"
+ );
+ assert(!diffing, "Should not have diffing");
+ assert(snapshot.dominatorTree, "Should have a dominator tree");
+
+ return this._renderDominatorTree(
+ state,
+ onViewSourceInDebugger,
+ snapshot.dominatorTree,
+ onLoadMoreSiblings
+ );
+ }
+}
+
+module.exports = Heap;
diff --git a/devtools/client/memory/components/Individuals.js b/devtools/client/memory/components/Individuals.js
new file mode 100644
index 0000000000..dd0e9acc81
--- /dev/null
+++ b/devtools/client/memory/components/Individuals.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";
+
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const Tree = createFactory(
+ require("resource://devtools/client/shared/components/VirtualizedTree.js")
+);
+const DominatorTreeItem = createFactory(
+ require("resource://devtools/client/memory/components/DominatorTreeItem.js")
+);
+const {
+ TREE_ROW_HEIGHT,
+} = require("resource://devtools/client/memory/constants.js");
+const models = require("resource://devtools/client/memory/models.js");
+
+/**
+ * The list of individuals in a census group.
+ */
+class Individuals extends Component {
+ static get propTypes() {
+ return {
+ onViewSourceInDebugger: PropTypes.func.isRequired,
+ onFocus: PropTypes.func.isRequired,
+ individuals: models.individuals,
+ dominatorTree: models.dominatorTreeModel,
+ };
+ }
+
+ render() {
+ const { individuals, dominatorTree, onViewSourceInDebugger, onFocus } =
+ this.props;
+
+ return Tree({
+ key: "individuals-tree",
+ autoExpandDepth: 0,
+ preventNavigationOnArrowRight: false,
+ focused: individuals.focused,
+ getParent: node => null,
+ getChildren: node => [],
+ isExpanded: node => false,
+ onExpand: () => {},
+ onCollapse: () => {},
+ onFocus,
+ renderItem: (item, depth, focused, _, expanded) => {
+ return DominatorTreeItem({
+ item,
+ depth,
+ focused,
+ arrow: undefined,
+ expanded,
+ getPercentSize: size =>
+ (size / dominatorTree.root.retainedSize) * 100,
+ onViewSourceInDebugger,
+ });
+ },
+ getRoots: () => individuals.nodes,
+ getKey: node => node.nodeId,
+ itemHeight: TREE_ROW_HEIGHT,
+ });
+ }
+}
+
+module.exports = Individuals;
diff --git a/devtools/client/memory/components/IndividualsHeader.js b/devtools/client/memory/components/IndividualsHeader.js
new file mode 100644
index 0000000000..abd5a81a36
--- /dev/null
+++ b/devtools/client/memory/components/IndividualsHeader.js
@@ -0,0 +1,51 @@
+/* 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 {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const { L10N } = require("resource://devtools/client/memory/utils.js");
+
+class IndividualsHeader extends Component {
+ static get propTypes() {
+ return {};
+ }
+
+ render() {
+ return dom.div(
+ {
+ className: "header",
+ },
+
+ dom.span(
+ {
+ className: "heap-tree-item-bytes",
+ title: L10N.getStr("heapview.field.retainedSize.tooltip"),
+ },
+ L10N.getStr("heapview.field.retainedSize")
+ ),
+
+ dom.span(
+ {
+ className: "heap-tree-item-bytes",
+ title: L10N.getStr("heapview.field.shallowSize.tooltip"),
+ },
+ L10N.getStr("heapview.field.shallowSize")
+ ),
+
+ dom.span(
+ {
+ className: "heap-tree-item-name",
+ title: L10N.getStr("individuals.field.node.tooltip"),
+ },
+ L10N.getStr("individuals.field.node")
+ )
+ );
+ }
+}
+
+module.exports = IndividualsHeader;
diff --git a/devtools/client/memory/components/List.js b/devtools/client/memory/components/List.js
new file mode 100644
index 0000000000..290b178b7e
--- /dev/null
+++ b/devtools/client/memory/components/List.js
@@ -0,0 +1,46 @@
+/* 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 {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+/**
+ * Generic list component that takes another react component to represent
+ * the children nodes as `itemComponent`, and a list of items to render
+ * as that component with a click handler.
+ */
+class List extends Component {
+ static get propTypes() {
+ return {
+ itemComponent: PropTypes.any.isRequired,
+ onClick: PropTypes.func,
+ items: PropTypes.array.isRequired,
+ };
+ }
+
+ render() {
+ const { items, onClick, itemComponent: Item } = this.props;
+
+ return dom.ul(
+ { className: "list" },
+ ...items.map((item, index) => {
+ return Item(
+ Object.assign({}, this.props, {
+ key: index,
+ item,
+ index,
+ onClick: () => onClick(item),
+ })
+ );
+ })
+ );
+ }
+}
+
+module.exports = List;
diff --git a/devtools/client/memory/components/ShortestPaths.js b/devtools/client/memory/components/ShortestPaths.js
new file mode 100644
index 0000000000..0dd6e22c33
--- /dev/null
+++ b/devtools/client/memory/components/ShortestPaths.js
@@ -0,0 +1,196 @@
+/* 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 {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const { isSavedFrame } = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ getSourceNames,
+} = require("resource://devtools/client/shared/source-utils.js");
+const { L10N } = require("resource://devtools/client/memory/utils.js");
+
+const GRAPH_DEFAULTS = {
+ translate: [20, 20],
+ scale: 1,
+};
+
+const NO_STACK = "noStack";
+const NO_FILENAME = "noFilename";
+const ROOT_LIST = "JS::ubi::RootList";
+
+function stringifyLabel(label, id) {
+ const sanitized = [];
+
+ for (let i = 0, length = label.length; i < length; i++) {
+ const piece = label[i];
+
+ if (isSavedFrame(piece)) {
+ const { short } = getSourceNames(piece.source);
+ sanitized[i] =
+ `${piece.functionDisplayName} @ ` +
+ `${short}:${piece.line}:${piece.column}`;
+ } else if (piece === NO_STACK) {
+ sanitized[i] = L10N.getStr("tree-item.nostack");
+ } else if (piece === NO_FILENAME) {
+ sanitized[i] = L10N.getStr("tree-item.nofilename");
+ } else if (piece === ROOT_LIST) {
+ // Don't use the usual labeling machinery for root lists: replace it
+ // with the "GC Roots" string.
+ sanitized.splice(0, label.length);
+ sanitized.push(L10N.getStr("tree-item.rootlist"));
+ break;
+ } else {
+ sanitized[i] = "" + piece;
+ }
+ }
+
+ return `${sanitized.join(" › ")} @ 0x${id.toString(16)}`;
+}
+
+class ShortestPaths extends Component {
+ static get propTypes() {
+ return {
+ graph: PropTypes.shape({
+ nodes: PropTypes.arrayOf(PropTypes.object),
+ edges: PropTypes.arrayOf(PropTypes.object),
+ }),
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = { zoom: null };
+ this._renderGraph = this._renderGraph.bind(this);
+ }
+
+ componentDidMount() {
+ if (this.props.graph) {
+ this._renderGraph(this.refs.container, this.props.graph);
+ }
+ }
+
+ shouldComponentUpdate(nextProps) {
+ return this.props.graph != nextProps.graph;
+ }
+
+ componentDidUpdate() {
+ if (this.props.graph) {
+ this._renderGraph(this.refs.container, this.props.graph);
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.state.zoom) {
+ this.state.zoom.on("zoom", null);
+ }
+ }
+
+ _renderGraph(container, { nodes, edges }) {
+ if (!container.firstChild) {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("id", "graph-svg");
+ svg.setAttribute("xlink", "http://www.w3.org/1999/xlink");
+ svg.style.width = "100%";
+ svg.style.height = "100%";
+
+ const target = document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "g"
+ );
+ target.setAttribute("id", "graph-target");
+ target.style.width = "100%";
+ target.style.height = "100%";
+
+ svg.appendChild(target);
+ container.appendChild(svg);
+ }
+
+ const graph = new dagreD3.Digraph();
+
+ for (let i = 0; i < nodes.length; i++) {
+ graph.addNode(nodes[i].id, {
+ id: nodes[i].id,
+ label: stringifyLabel(nodes[i].label, nodes[i].id),
+ });
+ }
+
+ for (let i = 0; i < edges.length; i++) {
+ graph.addEdge(null, edges[i].from, edges[i].to, {
+ label: edges[i].name,
+ });
+ }
+
+ const renderer = new dagreD3.Renderer();
+ renderer.drawNodes();
+ renderer.drawEdgePaths();
+
+ const svg = d3.select("#graph-svg");
+ const target = d3.select("#graph-target");
+
+ let zoom = this.state.zoom;
+ if (!zoom) {
+ zoom = d3.behavior.zoom().on("zoom", function () {
+ target.attr(
+ "transform",
+ `translate(${d3.event.translate}) scale(${d3.event.scale})`
+ );
+ });
+ svg.call(zoom);
+ this.setState({ zoom });
+ }
+
+ const { translate, scale } = GRAPH_DEFAULTS;
+ zoom.scale(scale);
+ zoom.translate(translate);
+ target.attr("transform", `translate(${translate}) scale(${scale})`);
+
+ const layout = dagreD3.layout();
+ renderer.layout(layout).run(graph, target);
+ }
+
+ render() {
+ let contents;
+ if (this.props.graph) {
+ // Let the componentDidMount or componentDidUpdate method draw the graph
+ // with DagreD3. We just provide the container for the graph here.
+ contents = dom.div({
+ ref: "container",
+ style: {
+ flex: 1,
+ height: "100%",
+ width: "100%",
+ },
+ });
+ } else {
+ contents = dom.div(
+ {
+ id: "shortest-paths-select-node-msg",
+ },
+ L10N.getStr("shortest-paths.select-node")
+ );
+ }
+
+ return dom.div(
+ {
+ id: "shortest-paths",
+ className: "vbox",
+ },
+ dom.label(
+ {
+ id: "shortest-paths-header",
+ className: "header",
+ },
+ L10N.getStr("shortest-paths.header")
+ ),
+ contents
+ );
+ }
+}
+
+module.exports = ShortestPaths;
diff --git a/devtools/client/memory/components/SnapshotListItem.js b/devtools/client/memory/components/SnapshotListItem.js
new file mode 100644
index 0000000000..5a2c24b4e4
--- /dev/null
+++ b/devtools/client/memory/components/SnapshotListItem.js
@@ -0,0 +1,142 @@
+/* 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 {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ L10N,
+ getSnapshotTitle,
+ getSnapshotTotals,
+ getStatusText,
+ snapshotIsDiffable,
+ getSavedCensus,
+} = require("resource://devtools/client/memory/utils.js");
+const {
+ diffingState,
+} = require("resource://devtools/client/memory/constants.js");
+const {
+ snapshot: snapshotModel,
+ app: appModel,
+} = require("resource://devtools/client/memory/models.js");
+
+class SnapshotListItem extends Component {
+ static get propTypes() {
+ return {
+ onClick: PropTypes.func.isRequired,
+ onSave: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired,
+ item: snapshotModel.isRequired,
+ index: PropTypes.number.isRequired,
+ diffing: appModel.diffing,
+ };
+ }
+
+ render() {
+ const { item: snapshot, onClick, onSave, onDelete, diffing } = this.props;
+ let className = `snapshot-list-item ${
+ snapshot.selected ? " selected" : ""
+ }`;
+ let statusText = getStatusText(snapshot.state);
+ let wantThrobber = !!statusText;
+ const title = getSnapshotTitle(snapshot);
+
+ const selectedForDiffing =
+ diffing &&
+ (diffing.firstSnapshotId === snapshot.id ||
+ diffing.secondSnapshotId === snapshot.id);
+
+ let checkbox;
+ if (diffing && snapshotIsDiffable(snapshot)) {
+ if (diffing.state === diffingState.SELECTING) {
+ wantThrobber = false;
+ }
+
+ const checkboxAttrs = {
+ type: "checkbox",
+ checked: false,
+ };
+
+ if (selectedForDiffing) {
+ checkboxAttrs.checked = true;
+ checkboxAttrs.disabled = true;
+ className += " selected";
+ statusText = L10N.getStr(
+ diffing.firstSnapshotId === snapshot.id
+ ? "diffing.baseline"
+ : "diffing.comparison"
+ );
+ }
+
+ if (selectedForDiffing || diffing.state == diffingState.SELECTING) {
+ checkbox = dom.input(checkboxAttrs);
+ }
+ }
+
+ let details;
+ if (!selectedForDiffing) {
+ // See if a tree map or census is in the read state.
+ const census = getSavedCensus(snapshot);
+
+ // If there is census data, fill in the total bytes.
+ if (census) {
+ const { bytes } = getSnapshotTotals(census);
+ const formatBytes = L10N.getFormatStr(
+ "aggregate.mb",
+ L10N.numberWithDecimals(bytes / 1000000, 2)
+ );
+
+ details = dom.span(
+ { className: "snapshot-totals" },
+ dom.span({ className: "total-bytes" }, formatBytes)
+ );
+ }
+ }
+ if (!details) {
+ details = dom.span({ className: "snapshot-state" }, statusText);
+ }
+
+ const saveLink = !snapshot.path
+ ? void 0
+ : dom.a(
+ {
+ onClick: () => onSave(snapshot),
+ className: "save",
+ },
+ L10N.getStr("snapshot.io.save")
+ );
+
+ const deleteButton = !snapshot.path
+ ? void 0
+ : dom.button({
+ onClick: event => {
+ event.stopPropagation();
+ onDelete(snapshot);
+ },
+ className: "delete",
+ title: L10N.getStr("snapshot.io.delete"),
+ });
+
+ return dom.li(
+ { className, onClick },
+ dom.span(
+ {
+ className: `snapshot-title ${
+ wantThrobber ? " devtools-throbber" : ""
+ }`,
+ },
+ checkbox,
+ title,
+ deleteButton
+ ),
+ dom.span({ className: "snapshot-info" }, details, saveLink)
+ );
+ }
+}
+
+module.exports = SnapshotListItem;
diff --git a/devtools/client/memory/components/Toolbar.js b/devtools/client/memory/components/Toolbar.js
new file mode 100644
index 0000000000..712a04abfe
--- /dev/null
+++ b/devtools/client/memory/components/Toolbar.js
@@ -0,0 +1,309 @@
+/* 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 { assert } = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const { L10N } = require("resource://devtools/client/memory/utils.js");
+const models = require("resource://devtools/client/memory/models.js");
+const { viewState } = require("resource://devtools/client/memory/constants.js");
+
+class Toolbar extends Component {
+ static get propTypes() {
+ return {
+ censusDisplays: PropTypes.arrayOf(models.censusDisplay).isRequired,
+ censusDisplay: models.censusDisplay.isRequired,
+ onTakeSnapshotClick: PropTypes.func.isRequired,
+ onImportClick: PropTypes.func.isRequired,
+ onClearSnapshotsClick: PropTypes.func.isRequired,
+ onCensusDisplayChange: PropTypes.func.isRequired,
+ onToggleRecordAllocationStacks: PropTypes.func.isRequired,
+ allocations: models.allocations,
+ filterString: PropTypes.string,
+ setFilterString: PropTypes.func.isRequired,
+ diffing: models.diffingModel,
+ onToggleDiffing: PropTypes.func.isRequired,
+ view: models.view.isRequired,
+ onViewChange: PropTypes.func.isRequired,
+ labelDisplays: PropTypes.arrayOf(models.labelDisplay).isRequired,
+ labelDisplay: models.labelDisplay.isRequired,
+ onLabelDisplayChange: PropTypes.func.isRequired,
+ treeMapDisplays: PropTypes.arrayOf(models.treeMapDisplay).isRequired,
+ onTreeMapDisplayChange: PropTypes.func.isRequired,
+ snapshots: PropTypes.arrayOf(models.snapshot).isRequired,
+ };
+ }
+
+ render() {
+ const {
+ onTakeSnapshotClick,
+ onImportClick,
+ onClearSnapshotsClick,
+ onCensusDisplayChange,
+ censusDisplays,
+ censusDisplay,
+ labelDisplays,
+ labelDisplay,
+ onLabelDisplayChange,
+ treeMapDisplays,
+ onTreeMapDisplayChange,
+ onToggleRecordAllocationStacks,
+ allocations,
+ filterString,
+ setFilterString,
+ snapshots,
+ diffing,
+ onToggleDiffing,
+ view,
+ onViewChange,
+ } = this.props;
+
+ let viewToolbarOptions;
+ if (view.state == viewState.CENSUS || view.state === viewState.DIFFING) {
+ viewToolbarOptions = dom.div(
+ {
+ className: "toolbar-group",
+ },
+
+ dom.label(
+ {
+ className: "display-by",
+ title: L10N.getStr("toolbar.displayBy.tooltip"),
+ },
+ L10N.getStr("toolbar.displayBy"),
+ dom.select(
+ {
+ id: "select-display",
+ className: "devtools-toolbar-select select-display",
+ onChange: e => {
+ const newDisplay = censusDisplays.find(
+ b => b.displayName === e.target.value
+ );
+ onCensusDisplayChange(newDisplay);
+ },
+ value: censusDisplay.displayName,
+ },
+ censusDisplays.map(({ tooltip, displayName }) =>
+ dom.option(
+ {
+ key: `display-${displayName}`,
+ value: displayName,
+ title: tooltip,
+ },
+ displayName
+ )
+ )
+ )
+ ),
+
+ dom.span({ className: "devtools-separator" }),
+
+ dom.input({
+ id: "filter",
+ type: "search",
+ className: "devtools-filterinput",
+ placeholder: L10N.getStr("filter.placeholder"),
+ title: L10N.getStr("filter.tooltip"),
+ onChange: event => setFilterString(event.target.value),
+ value: filterString || undefined,
+ })
+ );
+ } else if (view.state == viewState.TREE_MAP) {
+ assert(
+ treeMapDisplays.length >= 1,
+ "Should always have at least one tree map display"
+ );
+
+ // Only show the dropdown if there are multiple display options
+ viewToolbarOptions =
+ treeMapDisplays.length > 1
+ ? dom.div(
+ {
+ className: "toolbar-group",
+ },
+
+ dom.label(
+ {
+ className: "display-by",
+ title: L10N.getStr("toolbar.displayBy.tooltip"),
+ },
+ L10N.getStr("toolbar.displayBy"),
+ dom.select(
+ {
+ id: "select-tree-map-display",
+ onChange: e => {
+ const newDisplay = treeMapDisplays.find(
+ b => b.displayName === e.target.value
+ );
+ onTreeMapDisplayChange(newDisplay);
+ },
+ },
+ treeMapDisplays.map(({ tooltip, displayName }) =>
+ dom.option(
+ {
+ key: `tree-map-display-${displayName}`,
+ value: displayName,
+ title: tooltip,
+ },
+ displayName
+ )
+ )
+ )
+ )
+ )
+ : null;
+ } else {
+ assert(
+ view.state === viewState.DOMINATOR_TREE ||
+ view.state === viewState.INDIVIDUALS
+ );
+
+ viewToolbarOptions = dom.div(
+ {
+ className: "toolbar-group",
+ },
+
+ dom.label(
+ {
+ className: "label-by",
+ title: L10N.getStr("toolbar.labelBy.tooltip"),
+ },
+ L10N.getStr("toolbar.labelBy"),
+ dom.select(
+ {
+ id: "select-label-display",
+ className: "devtools-toolbar-select select-label-display",
+ onChange: e => {
+ const newDisplay = labelDisplays.find(
+ b => b.displayName === e.target.value
+ );
+ onLabelDisplayChange(newDisplay);
+ },
+ value: labelDisplay.displayName,
+ },
+ labelDisplays.map(({ tooltip, displayName }) =>
+ dom.option(
+ {
+ key: `label-display-${displayName}`,
+ value: displayName,
+ title: tooltip,
+ },
+ displayName
+ )
+ )
+ )
+ )
+ );
+ }
+
+ let viewSelect;
+ if (
+ view.state !== viewState.DIFFING &&
+ view.state !== viewState.INDIVIDUALS
+ ) {
+ viewSelect = dom.label(
+ {
+ title: L10N.getStr("toolbar.view.tooltip"),
+ },
+ L10N.getStr("toolbar.view"),
+ dom.select(
+ {
+ id: "select-view",
+ className: "devtools-toolbar-select select-view",
+ onChange: e => onViewChange(e.target.value),
+ value: view.state,
+ },
+ dom.option(
+ {
+ value: viewState.TREE_MAP,
+ title: L10N.getStr("toolbar.view.treemap.tooltip"),
+ },
+ L10N.getStr("toolbar.view.treemap")
+ ),
+ dom.option(
+ {
+ value: viewState.CENSUS,
+ title: L10N.getStr("toolbar.view.census.tooltip"),
+ },
+ L10N.getStr("toolbar.view.census")
+ ),
+ dom.option(
+ {
+ value: viewState.DOMINATOR_TREE,
+ title: L10N.getStr("toolbar.view.dominators.tooltip"),
+ },
+ L10N.getStr("toolbar.view.dominators")
+ )
+ )
+ );
+ }
+
+ return dom.div(
+ {
+ className: "devtools-toolbar",
+ },
+
+ dom.div(
+ {
+ className: "toolbar-group",
+ },
+
+ dom.button({
+ id: "clear-snapshots",
+ className: "clear-snapshots devtools-button",
+ disabled: !snapshots.length,
+ onClick: onClearSnapshotsClick,
+ title: L10N.getStr("clear-snapshots.tooltip"),
+ }),
+
+ dom.button({
+ id: "take-snapshot",
+ className: "take-snapshot devtools-button",
+ onClick: onTakeSnapshotClick,
+ title: L10N.getStr("take-snapshot"),
+ }),
+
+ dom.button({
+ id: "diff-snapshots",
+ className:
+ "devtools-button devtools-monospace" + (diffing ? " checked" : ""),
+ disabled: snapshots.length < 2,
+ onClick: onToggleDiffing,
+ title: L10N.getStr("diff-snapshots.tooltip"),
+ }),
+
+ dom.button({
+ id: "import-snapshot",
+ className: "import-snapshot devtools-button",
+ onClick: onImportClick,
+ title: L10N.getStr("import-snapshot"),
+ })
+ ),
+
+ dom.label(
+ {
+ id: "record-allocation-stacks-label",
+ title: L10N.getStr("checkbox.recordAllocationStacks.tooltip"),
+ },
+ dom.input({
+ id: "record-allocation-stacks-checkbox",
+ type: "checkbox",
+ checked: allocations.recording,
+ disabled: allocations.togglingInProgress,
+ onChange: onToggleRecordAllocationStacks,
+ }),
+ L10N.getStr("checkbox.recordAllocationStacks")
+ ),
+
+ viewSelect,
+ viewToolbarOptions
+ );
+ }
+}
+
+module.exports = Toolbar;
diff --git a/devtools/client/memory/components/TreeMap.js b/devtools/client/memory/components/TreeMap.js
new file mode 100644
index 0000000000..b9a3c39495
--- /dev/null
+++ b/devtools/client/memory/components/TreeMap.js
@@ -0,0 +1,77 @@
+/* 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 {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const { treeMapModel } = require("resource://devtools/client/memory/models.js");
+const startVisualization = require("resource://devtools/client/memory/components/tree-map/start.js");
+
+class TreeMap extends Component {
+ static get propTypes() {
+ return {
+ treeMap: treeMapModel,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {};
+ this._stopVisualization = this._stopVisualization.bind(this);
+ this._startVisualization = this._startVisualization.bind(this);
+ }
+
+ componentDidMount() {
+ const { treeMap } = this.props;
+ if (treeMap?.report) {
+ this._startVisualization();
+ }
+ }
+
+ shouldComponentUpdate(nextProps) {
+ const oldTreeMap = this.props.treeMap;
+ const newTreeMap = nextProps.treeMap;
+ return oldTreeMap !== newTreeMap;
+ }
+
+ componentDidUpdate(prevProps) {
+ this._stopVisualization();
+
+ if (this.props.treeMap && this.props.treeMap.report) {
+ this._startVisualization();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.state.stopVisualization) {
+ this.state.stopVisualization();
+ }
+ }
+
+ _stopVisualization() {
+ if (this.state.stopVisualization) {
+ this.state.stopVisualization();
+ this.setState({ stopVisualization: null });
+ }
+ }
+
+ _startVisualization() {
+ const { container } = this.refs;
+ const { report } = this.props.treeMap;
+ const stopVisualization = startVisualization(container, report);
+ this.setState({ stopVisualization });
+ }
+
+ render() {
+ return dom.div({
+ ref: "container",
+ className: "tree-map-container",
+ });
+ }
+}
+
+module.exports = TreeMap;
diff --git a/devtools/client/memory/components/moz.build b/devtools/client/memory/components/moz.build
new file mode 100644
index 0000000000..82739bf97f
--- /dev/null
+++ b/devtools/client/memory/components/moz.build
@@ -0,0 +1,25 @@
+# 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/.
+
+DIRS += [
+ "tree-map",
+]
+
+DevToolsModules(
+ "Census.js",
+ "CensusHeader.js",
+ "CensusTreeItem.js",
+ "DominatorTree.js",
+ "DominatorTreeHeader.js",
+ "DominatorTreeItem.js",
+ "Heap.js",
+ "Individuals.js",
+ "IndividualsHeader.js",
+ "List.js",
+ "ShortestPaths.js",
+ "SnapshotListItem.js",
+ "Toolbar.js",
+ "TreeMap.js",
+)
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..e1bf252057
--- /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("resource://devtools/shared/debounce.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+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() {
+ 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..034017e086
--- /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("resource://devtools/shared/debounce.js");
+const { lerp } = require("resource://devtools/client/memory/utils.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+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..12a5901332
--- /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("resource://devtools/client/memory/components/tree-map/color-coarse-type.js");
+const {
+ hslToStyle,
+ formatAbbreviatedBytes,
+ L10N,
+} = require("resource://devtools/client/memory/utils.js");
+
+// 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..80ae483903
--- /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("resource://devtools/client/memory/components/tree-map/draw.js");
+const DragZoom = require("resource://devtools/client/memory/components/tree-map/drag-zoom.js");
+const CanvasUtils = require("resource://devtools/client/memory/components/tree-map/canvas-utils.js");
+
+/**
+ * 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();
+ };
+};