diff options
Diffstat (limited to 'devtools/client/memory/components')
21 files changed, 2835 insertions, 0 deletions
diff --git a/devtools/client/memory/components/census-header.js b/devtools/client/memory/components/census-header.js new file mode 100644 index 000000000..d897ed132 --- /dev/null +++ b/devtools/client/memory/components/census-header.js @@ -0,0 +1,72 @@ +/* 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/. */ + +const { DOM: dom, createClass } = require("devtools/client/shared/vendor/react"); +const { L10N } = require("../utils"); +const models = require("../models"); + +const CensusHeader = module.exports = createClass({ + displayName: "CensusHeader", + + propTypes: { + 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") + ) + ); + } +}); diff --git a/devtools/client/memory/components/census-tree-item.js b/devtools/client/memory/components/census-tree-item.js new file mode 100644 index 000000000..c5d08cefc --- /dev/null +++ b/devtools/client/memory/components/census-tree-item.js @@ -0,0 +1,134 @@ +/* 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("devtools/shared/DevToolsUtils"); +const { DOM: dom, createClass, createFactory } = require("devtools/client/shared/vendor/react"); +const { L10N, formatNumber, formatPercent } = require("../utils"); +const Frame = createFactory(require("devtools/client/shared/components/frame")); +const { TREE_ROW_HEIGHT } = require("../constants"); + +const CensusTreeItem = module.exports = createClass({ + displayName: "CensusTreeItem", + + 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; + }, + + render() { + let { + 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 && 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) + ) + ); + }, + + toLabel(name, linkToDebugger) { + if (isSavedFrame(name)) { + return Frame({ + frame: name, + onClick: () => linkToDebugger(name), + 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); + }, +}); diff --git a/devtools/client/memory/components/census.js b/devtools/client/memory/components/census.js new file mode 100644 index 000000000..3274b26bb --- /dev/null +++ b/devtools/client/memory/components/census.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/. */ + +const { DOM: dom, createClass, PropTypes, createFactory } = require("devtools/client/shared/vendor/react"); +const Tree = createFactory(require("devtools/client/shared/components/tree")); +const CensusTreeItem = createFactory(require("./census-tree-item")); +const { createParentMap } = require("../utils"); +const { TREE_ROW_HEIGHT } = require("../constants"); +const { censusModel, diffingModel } = require("../models"); + +const Census = module.exports = createClass({ + displayName: "Census", + + propTypes: { + 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() { + let { + census, + onExpand, + onCollapse, + onFocus, + diffing, + onViewSourceInDebugger, + onViewIndividuals, + } = this.props; + + const report = census.report; + let 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, + 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, + }); + } +}); diff --git a/devtools/client/memory/components/dominator-tree-header.js b/devtools/client/memory/components/dominator-tree-header.js new file mode 100644 index 000000000..ae1c7520e --- /dev/null +++ b/devtools/client/memory/components/dominator-tree-header.js @@ -0,0 +1,44 @@ +/* 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/. */ + +const { DOM: dom, createClass, PropTypes } = require("devtools/client/shared/vendor/react"); +const { L10N } = require("../utils"); + +const DominatorTreeHeader = module.exports = createClass({ + displayName: "DominatorTreeHeader", + + propTypes: { }, + + 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") + ) + ); + } +}); diff --git a/devtools/client/memory/components/dominator-tree-item.js b/devtools/client/memory/components/dominator-tree-item.js new file mode 100644 index 000000000..bf76ee9b4 --- /dev/null +++ b/devtools/client/memory/components/dominator-tree-item.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/. */ + +const { assert, isSavedFrame } = require("devtools/shared/DevToolsUtils"); +const { DOM: dom, createClass, createFactory, PropTypes } = require("devtools/client/shared/vendor/react"); +const { L10N, formatNumber, formatPercent } = require("../utils"); +const Frame = createFactory(require("devtools/client/shared/components/frame")); +const { TREE_ROW_HEIGHT } = require("../constants"); + +const Separator = createFactory(createClass({ + displayName: "Separator", + + render() { + return dom.span({ className: "separator" }, "›"); + } +})); + +const DominatorTreeItem = module.exports = createClass({ + displayName: "DominatorTreeItem", + + propTypes: { + item: PropTypes.object.isRequired, + depth: PropTypes.number.isRequired, + arrow: PropTypes.object, + 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() { + let { + 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 > 0, + "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(piece), + 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)}`) + ) + ); + }, +}); diff --git a/devtools/client/memory/components/dominator-tree.js b/devtools/client/memory/components/dominator-tree.js new file mode 100644 index 000000000..a1105a4fc --- /dev/null +++ b/devtools/client/memory/components/dominator-tree.js @@ -0,0 +1,216 @@ +/* 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/. */ + +const { DOM: dom, createClass, PropTypes, createFactory } = require("devtools/client/shared/vendor/react"); +const { assert, safeErrorString } = require("devtools/shared/DevToolsUtils"); +const { createParentMap } = require("devtools/shared/heapsnapshot/CensusUtils"); +const Tree = createFactory(require("devtools/client/shared/components/tree")); +const DominatorTreeItem = createFactory(require("./dominator-tree-item")); +const { L10N } = require("../utils"); +const { TREE_ROW_HEIGHT, dominatorTreeState } = require("../constants"); +const { dominatorTreeModel } = require("../models"); +const DominatorTreeLazyChildren = require("../dominator-tree-lazy-children"); + +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`. + */ +const DominatorTreeSubtreeFetching = createFactory(createClass({ + displayName: "DominatorTreeSubtreeFetching", + + shouldComponentUpdate(nextProps, nextState) { + return this.props.depth !== nextProps.depth + || this.props.focused !== nextProps.focused; + }, + + propTypes: { + depth: PropTypes.number.isRequired, + focused: PropTypes.bool.isRequired, + }, + + render() { + let { + 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. + */ +const DominatorTreeSiblingLink = createFactory(createClass({ + displayName: "DominatorTreeSiblingLink", + + propTypes: { + 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() { + let { + 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( + { + onClick: () => onLoadMoreSiblings(item) + }, + L10N.getStr("tree-item.load-more") + ) + ) + ); + } +})); + +/** + * The actual dominator tree rendered as an expandable and collapsible tree. + */ +const DominatorTree = module.exports = createClass({ + displayName: "DominatorTree", + + propTypes: { + dominatorTree: dominatorTreeModel.isRequired, + onLoadMoreSiblings: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + onExpand: PropTypes.func.isRequired, + onCollapse: 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, + 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, + }); + } +}); diff --git a/devtools/client/memory/components/heap.js b/devtools/client/memory/components/heap.js new file mode 100644 index 000000000..786f37ae1 --- /dev/null +++ b/devtools/client/memory/components/heap.js @@ -0,0 +1,455 @@ +/* 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/. */ + +const { DOM: dom, createClass, PropTypes, createFactory } = require("devtools/client/shared/vendor/react"); +const { assert, safeErrorString } = require("devtools/shared/DevToolsUtils"); +const Census = createFactory(require("./census")); +const CensusHeader = createFactory(require("./census-header")); +const DominatorTree = createFactory(require("./dominator-tree")); +const DominatorTreeHeader = createFactory(require("./dominator-tree-header")); +const TreeMap = createFactory(require("./tree-map")); +const HSplitBox = createFactory(require("devtools/client/shared/components/h-split-box")); +const Individuals = createFactory(require("./individuals")); +const IndividualsHeader = createFactory(require("./individuals-header")); +const ShortestPaths = createFactory(require("./shortest-paths")); +const { getStatusTextFull, L10N } = require("../utils"); +const { + snapshotState: states, + diffingState, + viewState, + censusState, + treeMapState, + dominatorTreeState, + individualsState, +} = require("../constants"); +const models = require("../models"); +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. + */ +const Heap = module.exports = createClass({ + displayName: "Heap", + + propTypes: { + 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, + }, + + render() { + let { + 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); + }, + + /** + * 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-toolbarbutton take-snapshot", + onClick: onSnapshotClick, + "data-standalone": true, + "data-text-only": 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 msg = diffing ? L10N.getStr("heapview.no-difference") + : census.filter ? L10N.getStr("heapview.none-match") + : L10N.getStr("heapview.empty"); + 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 && 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") + ), + 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, + }) + ); + }, +}); diff --git a/devtools/client/memory/components/individuals-header.js b/devtools/client/memory/components/individuals-header.js new file mode 100644 index 000000000..7cacd163e --- /dev/null +++ b/devtools/client/memory/components/individuals-header.js @@ -0,0 +1,44 @@ +/* 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/. */ + +const { DOM: dom, createClass, PropTypes } = require("devtools/client/shared/vendor/react"); +const { L10N } = require("../utils"); + +const IndividualsHeader = module.exports = createClass({ + displayName: "IndividualsHeader", + + propTypes: { }, + + 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") + ) + ); + } +}); diff --git a/devtools/client/memory/components/individuals.js b/devtools/client/memory/components/individuals.js new file mode 100644 index 000000000..56c784820 --- /dev/null +++ b/devtools/client/memory/components/individuals.js @@ -0,0 +1,61 @@ +/* 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/. */ + +const { DOM: dom, createClass, PropTypes, createFactory } = require("devtools/client/shared/vendor/react"); +const { assert } = require("devtools/shared/DevToolsUtils"); +const { createParentMap } = require("devtools/shared/heapsnapshot/CensusUtils"); +const Tree = createFactory(require("devtools/client/shared/components/tree")); +const DominatorTreeItem = createFactory(require("./dominator-tree-item")); +const { L10N } = require("../utils"); +const { TREE_ROW_HEIGHT } = require("../constants"); +const models = require("../models"); + +/** + * The list of individuals in a census group. + */ +const Individuals = module.exports = createClass({ + displayName: "Individuals", + + propTypes: { + 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, + 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, + }); + } +}); diff --git a/devtools/client/memory/components/list.js b/devtools/client/memory/components/list.js new file mode 100644 index 000000000..3aa27da69 --- /dev/null +++ b/devtools/client/memory/components/list.js @@ -0,0 +1,35 @@ +/* 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/. */ + +const { DOM: dom, createClass, PropTypes } = require("devtools/client/shared/vendor/react"); + +/** + * 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. + */ +const List = module.exports = createClass({ + displayName: "List", + + propTypes: { + itemComponent: PropTypes.any.isRequired, + onClick: PropTypes.func, + items: PropTypes.array.isRequired, + }, + + render() { + let { 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), + })); + })) + ); + } +}); diff --git a/devtools/client/memory/components/moz.build b/devtools/client/memory/components/moz.build new file mode 100644 index 000000000..529454dd9 --- /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-header.js', + 'census-tree-item.js', + 'census.js', + 'dominator-tree-header.js', + 'dominator-tree-item.js', + 'dominator-tree.js', + 'heap.js', + 'individuals-header.js', + 'individuals.js', + 'list.js', + 'shortest-paths.js', + 'snapshot-list-item.js', + 'toolbar.js', + 'tree-map.js', +) diff --git a/devtools/client/memory/components/shortest-paths.js b/devtools/client/memory/components/shortest-paths.js new file mode 100644 index 000000000..23cb8134b --- /dev/null +++ b/devtools/client/memory/components/shortest-paths.js @@ -0,0 +1,184 @@ +/* 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 { + DOM: dom, + createClass, + PropTypes, +} = require("devtools/client/shared/vendor/react"); +const { isSavedFrame } = require("devtools/shared/DevToolsUtils"); +const { getSourceNames } = require("devtools/client/shared/source-utils"); +const { L10N } = require("../utils"); + +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)}`; +} + +module.exports = createClass({ + displayName: "ShortestPaths", + + propTypes: { + graph: PropTypes.shape({ + nodes: PropTypes.arrayOf(PropTypes.object), + edges: PropTypes.arrayOf(PropTypes.object), + }), + }, + + getInitialState() { + return { zoom: null }; + }, + + shouldComponentUpdate(nextProps) { + return this.props.graph != nextProps.graph; + }, + + componentDidMount() { + if (this.props.graph) { + this._renderGraph(this.refs.container, this.props.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); + } + }, + + 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 + ); + }, + + _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); + }, +}); diff --git a/devtools/client/memory/components/snapshot-list-item.js b/devtools/client/memory/components/snapshot-list-item.js new file mode 100644 index 000000000..37db81d13 --- /dev/null +++ b/devtools/client/memory/components/snapshot-list-item.js @@ -0,0 +1,114 @@ +/* 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/. */ + +const { assert } = require("devtools/shared/DevToolsUtils"); +const { DOM: dom, createClass, PropTypes } = require("devtools/client/shared/vendor/react"); +const { + L10N, + getSnapshotTitle, + getSnapshotTotals, + getStatusText, + snapshotIsDiffable, + getSavedCensus +} = require("../utils"); +const { + snapshotState: states, + diffingState, + censusState, + treeMapState +} = require("../constants"); +const { snapshot: snapshotModel } = require("../models"); + +const SnapshotListItem = module.exports = createClass({ + displayName: "SnapshotListItem", + + propTypes: { + onClick: PropTypes.func.isRequired, + onSave: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, + item: snapshotModel.isRequired, + index: PropTypes.number.isRequired, + }, + + render() { + let { index, item: snapshot, onClick, onSave, onDelete, diffing } = this.props; + let className = `snapshot-list-item ${snapshot.selected ? " selected" : ""}`; + let statusText = getStatusText(snapshot.state); + let wantThrobber = !!statusText; + let 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. + let census = getSavedCensus(snapshot); + + // If there is census data, fill in the total bytes. + if (census) { + let { bytes } = getSnapshotTotals(census); + let 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); + } + + let saveLink = !snapshot.path ? void 0 : dom.a({ + onClick: () => onSave(snapshot), + className: "save", + }, L10N.getStr("snapshot.io.save")); + + let deleteButton = !snapshot.path ? void 0 : dom.button({ + onClick: () => onDelete(snapshot), + className: "devtools-button 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 + ) + ) + ); + } +}); diff --git a/devtools/client/memory/components/toolbar.js b/devtools/client/memory/components/toolbar.js new file mode 100644 index 000000000..de60b2af9 --- /dev/null +++ b/devtools/client/memory/components/toolbar.js @@ -0,0 +1,300 @@ +/* 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("devtools/shared/DevToolsUtils"); +const { DOM: dom, createClass, PropTypes } = require("devtools/client/shared/vendor/react"); +const { L10N } = require("../utils"); +const models = require("../models"); +const { viewState } = require("../constants"); + +module.exports = createClass({ + displayName: "Toolbar", + + propTypes: { + censusDisplays: PropTypes.arrayOf(PropTypes.shape({ + displayName: PropTypes.string.isRequired, + })).isRequired, + censusDisplay: PropTypes.shape({ + displayName: PropTypes.string.isRequired, + }).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(PropTypes.shape({ + displayName: PropTypes.string.isRequired, + })).isRequired, + labelDisplay: PropTypes.shape({ + displayName: PropTypes.string.isRequired, + }).isRequired, + onLabelDisplayChange: PropTypes.func.isRequired, + treeMapDisplays: PropTypes.arrayOf(PropTypes.shape({ + displayName: PropTypes.string.isRequired, + })).isRequired, + onTreeMapDisplayChange: PropTypes.func.isRequired, + snapshots: PropTypes.arrayOf(models.snapshot).isRequired, + }, + + render() { + let { + 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: "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.div({ id: "toolbar-spacer", className: "spacer" }), + + 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", + 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", + onChange: e => onViewChange(e.target.value), + defaultValue: view, + 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: "devtools-toolbarbutton 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 + ) + ); + } +}); diff --git a/devtools/client/memory/components/tree-map.js b/devtools/client/memory/components/tree-map.js new file mode 100644 index 000000000..b6764605e --- /dev/null +++ b/devtools/client/memory/components/tree-map.js @@ -0,0 +1,71 @@ +/* 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 { DOM: dom, createClass } = require("devtools/client/shared/vendor/react"); +const { treeMapModel } = require("../models"); +const startVisualization = require("./tree-map/start"); + +module.exports = createClass({ + propTypes: { + treeMap: treeMapModel + }, + + displayName: "TreeMap", + + getInitialState() { + return {}; + }, + + componentDidMount() { + const { treeMap } = this.props; + if (treeMap && 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" + } + ); + } +}); 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 000000000..c7d67a0bf --- /dev/null +++ b/devtools/client/memory/components/tree-map/canvas-utils.js @@ -0,0 +1,134 @@ +/* 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("sdk/lang/functional"); +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) { + let 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) { + let window = container.ownerDocument.defaultView; + let 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" + }); + + let 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) { + let { container, main, zoom } = canvases; + let window = container.ownerDocument.defaultView; + + function resize() { + let width = container.offsetWidth * window.devicePixelRatio; + let 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 + let debouncedResize = debounceRate > 0 + ? debounce(resize, debounceRate) + : resize; + + window.addEventListener("resize", debouncedResize, false); + resize(); + + return function removeResizeHandlers() { + window.removeEventListener("resize", debouncedResize, false); + }; +} 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 000000000..5f033ea26 --- /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" ]; + +// 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) { + let 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) { + let h = Math.min(1, H + typeColorFactor(node)); + let s = Math.min(1, S); + let 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 000000000..3de970725 --- /dev/null +++ b/devtools/client/memory/components/tree-map/drag-zoom.js @@ -0,0 +1,316 @@ +/* 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("sdk/lang/functional"); +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 devicePixelRatio 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; + + let 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() { + let isScrollChanging = ( + Math.abs(dragZoom.smoothZoom - dragZoom.zoom) > ZOOM_EPSILON + ); + let 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; + } + + let zoom = 1 + dragZoom.smoothZoom; + let x = dragZoom.smoothTranslateX; + let 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) { + let emitChanged = debounce(() => dragZoom.emit("change"), debounceRate); + + let removeDragHandlers = + setDragHandlers(container, dragZoom, emitChanged, update); + let 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) { + let parentEl = container.parentElement; + + function startDrag() { + dragZoom.isDragging = true; + container.style.cursor = "grabbing"; + } + + function stopDrag() { + dragZoom.isDragging = false; + container.style.cursor = "grab"; + } + + function drag(event) { + let prevMouseX = dragZoom.mouseX; + let 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, false); + parentEl.addEventListener("mouseup", stopDrag, false); + parentEl.addEventListener("mouseout", stopDrag, false); + parentEl.addEventListener("mousemove", drag, false); + + return function removeListeners() { + parentEl.removeEventListener("mousedown", startDrag, false); + parentEl.removeEventListener("mouseup", stopDrag, false); + parentEl.removeEventListener("mouseout", stopDrag, false); + parentEl.removeEventListener("mousemove", drag, false); + }; +} + +/** + * 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) { + let window = container.ownerDocument.defaultView; + + function handleWheel(event) { + event.preventDefault(); + + if (dragZoom.isDragging) { + return; + } + + // Update the zoom level + let scrollDelta = getScrollDelta(event, window); + let prevZoom = dragZoom.zoom; + dragZoom.zoom = Math.max(0, dragZoom.zoom - scrollDelta * ZOOM_SPEED); + let deltaZoom = dragZoom.zoom - prevZoom; + + // Calculate the updated width and height + let prevZoomedWidth = container.offsetWidth * (1 + prevZoom); + let prevZoomedHeight = container.offsetHeight * (1 + prevZoom); + dragZoom.zoomedWidth = container.offsetWidth * (1 + dragZoom.zoom); + dragZoom.zoomedHeight = container.offsetHeight * (1 + dragZoom.zoom); + let deltaWidth = dragZoom.zoomedWidth - prevZoomedWidth; + let deltaHeight = dragZoom.zoomedHeight - prevZoomedHeight; + + let mouseOffsetX = dragZoom.mouseX - container.offsetWidth / 2; + let mouseOffsetY = dragZoom.mouseY - container.offsetHeight / 2; + + // The ratio of where the center of the mouse is in regards to the total + // zoomed width/height + let ratioZoomX = (prevZoomedWidth / 2 + mouseOffsetX - dragZoom.translateX) + / prevZoomedWidth; + let 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, false); + + return function removeListener() { + container.removeEventListener("wheel", handleWheel, false); + }; +} + +/** + * 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) { + let { devicePixelRatio } = container.ownerDocument.defaultView; + let overdrawX = (dragZoom.zoomedWidth - container.offsetWidth) / 2; + let 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 000000000..a55c1eae6 --- /dev/null +++ b/devtools/client/memory/components/tree-map/draw.js @@ -0,0 +1,295 @@ +/* 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("./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) { + let 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) { + let window = canvas.ownerDocument.defaultView; + let ratio = window.devicePixelRatio; + let 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) { + let 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) { + let truncated = name.substr(0, Math.floor(name.length / 2)); + let 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; + let 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) { + let margin = borderWidth(node) * 1.5 + ratio * TEXT_MARGIN; + let x = margin + (node.x - padding[0]) * scale - dragZoom.offsetX; + let y = margin + (node.y - padding[1]) * scale - dragZoom.offsetY; + let innerWidth = dx - margin * 2; + let 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 { + let bytesFormatted = formatAbbreviatedBytes(totalBytes); + let countFormatted = `${totalCount} ${COUNT_LABEL}`; + let byteSize = ctx.measureText(bytesFormatted).width; + let countSize = ctx.measureText(countFormatted).width; + let 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) { + let border = borderWidth(node); + let fillHSL = colorCoarseType(node); + let strokeHSL = [fillHSL[0], fillHSL[1], fillHSL[2] * 0.5]; + let scale = 1 + dragZoom.zoom; + + // Offset the draw so that box strokes don't overlap + let x = scale * (node.x - padding[0]) - dragZoom.offsetX + border / 2; + let y = scale * (node.y - padding[1]) - dragZoom.offsetY + border / 2; + let dx = scale * node.dx - border; + let 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) { + let window = canvas.ownerDocument.defaultView; + let ratio = window.devicePixelRatio; + let canvasArea = canvas.width * canvas.height; + // Subtract the outer padding from the tree map layout. + let 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) { + let areaRatio = Math.sqrt(node.area / canvasArea); + return ratio * Math.max(1, LINE_WIDTH * areaRatio); + } + + for (let i = 0; i < nodes.length; i++) { + let 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) { + let scale = 1 / (1 + dragZoom.zoom); + let x = -dragZoom.translateX; + let 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 000000000..aab193191 --- /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 000000000..9cebe2a9d --- /dev/null +++ b/devtools/client/memory/components/tree-map/start.js @@ -0,0 +1,32 @@ +/* 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("./draw"); +const DragZoom = require("./drag-zoom"); +const CanvasUtils = require("./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) { + let window = parentEl.ownerDocument.defaultView; + let canvases = new CanvasUtils(parentEl, debounceRate); + let dragZoom = new DragZoom(canvases.container, debounceRate, + window.requestAnimationFrame); + + setupDraw(report, canvases, dragZoom); + + return function stopVisualization() { + canvases.destroy(); + dragZoom.destroy(); + }; +}; |