Replace heatmap with treemap in memory page
diff --git a/packages/devtools_app/lib/src/charts/treemap.dart b/packages/devtools_app/lib/src/charts/treemap.dart
new file mode 100644
index 0000000..a093093
--- /dev/null
+++ b/packages/devtools_app/lib/src/charts/treemap.dart
@@ -0,0 +1,546 @@
+import 'package:flutter/material.dart';
+
+import '../common_widgets.dart';
+import '../trees.dart';
+import '../ui/colors.dart';
+import '../utils.dart';
+
+enum PivotType { pivotByMiddle, pivotBySize }
+
+class Treemap extends StatelessWidget {
+  const Treemap.fromRoot({
+    @required this.rootNode,
+    this.nodes,
+    @required this.levelsVisible,
+    @required this.isOutermostLevel,
+    @required this.height,
+    @required this.onRootChangedCallback,
+  });
+
+  const Treemap.fromNodes({
+    this.rootNode,
+    @required this.nodes,
+    @required this.levelsVisible,
+    @required this.isOutermostLevel,
+    @required this.height,
+    @required this.onRootChangedCallback,
+  });
+
+  final TreemapNode rootNode;
+
+  final List<TreemapNode> nodes;
+
+  /// The depth of children visible from this Treemap widget.
+  ///
+  /// A decremented level should be passed in when constructing [Treemap.fromRoot],
+  /// but not when constructing [Treemap.fromNodes]. This is because
+  /// when constructing from a root, [Treemap] either builds a nested [Treemap] to
+  /// show its node's children, or it shows its node. When constructing from a list
+  /// of nodes, however, [Treemap] is built to become part of a bigger treemap,
+  /// which means the level should not change.
+  ///
+  /// For example, levelsVisible = 2:
+  /// ```
+  /// _______________
+  /// |     Root    |
+  /// ---------------
+  /// |      1      |
+  /// |  ---------  |
+  /// |  |   2   |  |
+  /// |  |       |  |
+  /// |  |       |  |
+  /// |  ---------  |
+  /// ---------------
+  /// ```
+  final int levelsVisible;
+
+  /// Whether current levelsVisible matches the outermost level.
+  final bool isOutermostLevel;
+
+  final double height;
+
+  final void Function(TreemapNode node) onRootChangedCallback;
+
+  static const PivotType pivotType = PivotType.pivotBySize;
+
+  static const treeMapHeaderHeight = 20.0;
+
+  static const minHeightToDisplayTitleText = 20.0;
+
+  static const minHeightToDisplayCellText = 40.0;
+
+  /// Computes the total size of a given list of treemap nodes.
+  /// [endIndex] defaults to nodes.length - 1.
+  int computeByteSizeForNodes({
+    @required List<TreemapNode> nodes,
+    int startIndex = 0,
+    int endIndex,
+  }) {
+    endIndex ??= nodes.length - 1;
+    int sum = 0;
+    for (int i = startIndex; i <= endIndex; i++) {
+      sum += nodes[i].byteSize;
+    }
+    return sum;
+  }
+
+  int computePivot(List<TreemapNode> children) {
+    switch (pivotType) {
+      case PivotType.pivotByMiddle:
+        return (children.length / 2).floor();
+      case PivotType.pivotBySize:
+        int pivotIndex = -1;
+        double maxSize = double.negativeInfinity;
+        for (int i = 0; i < children.length; i++) {
+          if (children[i].byteSize > maxSize) {
+            maxSize = children[i].byteSize.toDouble();
+            pivotIndex = i;
+          }
+        }
+        return pivotIndex;
+      default:
+        return -1;
+    }
+  }
+
+  /// Implements the ordered treemap algorithm studied in [this research paper](https://www.cs.umd.edu/~ben/papers/Shneiderman2001Ordered.pdf).
+  ///
+  /// **Algorithm**
+  ///
+  /// Divides a given list of treemap nodes into four parts:
+  /// L1, P, L2, L3.
+  ///
+  /// P (pivot) is the treemap node chosen to be the pivot based on the pivot type.
+  /// L1 includes all treemap nodes before the pivot treemap node.
+  /// L2 and L3 combined include all treemap nodes after the pivot treemap node.
+  /// A combination of elements are put into L2 and L3 so that
+  /// the aspect ratio of the pivot cell (P) is as close to 1 as it can be.
+  ///
+  /// **Layout**
+  /// ```
+  /// ----------------------
+  /// |      |  P   |      |
+  /// |      |      |      |
+  /// |  L1  |------|  L3  |
+  /// |      |  L2  |      |
+  /// |      |      |      |
+  /// ----------------------
+  /// ```
+  List<Positioned> buildTreemaps({
+    @required List<TreemapNode> children,
+    @required double width,
+    @required double height,
+  }) {
+    final isHorizontalRectangle = width > height;
+
+    final totalByteSize = computeByteSizeForNodes(nodes: children);
+    if (children.isEmpty) {
+      return [];
+    }
+
+    // Sort the list of treemap nodes, descending in size.
+    children.sort((a, b) => b.byteSize.compareTo(a.byteSize));
+    if (children.length <= 2) {
+      final positionedChildren = <Positioned>[];
+      double offset = 0;
+
+      for (final child in children) {
+        final ratio = child.byteSize / totalByteSize;
+
+        positionedChildren.add(
+          Positioned(
+            left: isHorizontalRectangle ? offset : 0.0,
+            top: isHorizontalRectangle ? 0.0 : offset,
+            width: isHorizontalRectangle ? ratio * width : width,
+            height: isHorizontalRectangle ? height : ratio * height,
+            child: Treemap.fromRoot(
+              rootNode: child,
+              levelsVisible: levelsVisible - 1,
+              isOutermostLevel: false,
+              onRootChangedCallback: onRootChangedCallback,
+              height: height,
+            ),
+          ),
+        );
+        offset += isHorizontalRectangle ? ratio * width : ratio * height;
+      }
+
+      return positionedChildren;
+    }
+
+    final pivotIndex = computePivot(children);
+
+    final pivotNode = children[pivotIndex];
+    final pivotByteSize = pivotNode.byteSize;
+
+    final list1 = children.sublist(0, pivotIndex);
+    final list1ByteSize = computeByteSizeForNodes(nodes: list1);
+
+    var list2 = <TreemapNode>[];
+    int list2ByteSize = 0;
+    var list3 = <TreemapNode>[];
+    int list3ByteSize = 0;
+
+    // The maximum amount of data we can put in [list3].
+    final l3MaxLength = children.length - pivotIndex - 1;
+    int bestIndex = 0;
+    double pivotBestWidth = 0;
+    double pivotBestHeight = 0;
+
+    // We need to be able to put at least 3 elements in [list3] for this algorithm.
+    if (l3MaxLength >= 3) {
+      double pivotBestAspectRatio = double.infinity;
+      // Iterate through different combinations of [list2] and [list3] to find
+      // the combination where the aspect ratio of pivot is the lowest.
+      for (int i = pivotIndex + 1; i < children.length; i++) {
+        final list2Size = computeByteSizeForNodes(
+          nodes: children,
+          startIndex: pivotIndex + 1,
+          endIndex: i,
+        );
+
+        // Calculate the aspect ratio for the pivot treemap node.
+        final pivotAndList2Ratio = (pivotByteSize + list2Size) / totalByteSize;
+        final pivotRatio = pivotByteSize / (pivotByteSize + list2Size);
+
+        final pivotWidth = isHorizontalRectangle
+            ? pivotAndList2Ratio * width
+            : pivotRatio * width;
+
+        final pivotHeight = isHorizontalRectangle
+            ? pivotRatio * height
+            : pivotAndList2Ratio * height;
+
+        final pivotAspectRatio = pivotWidth / pivotHeight;
+
+        // Best aspect ratio that is the closest to 1.
+        if ((1 - pivotAspectRatio).abs() < (1 - pivotBestAspectRatio).abs()) {
+          pivotBestAspectRatio = pivotAspectRatio;
+          bestIndex = i;
+          // Kept track of width and height to construct the pivot cell.
+          pivotBestWidth = pivotWidth;
+          pivotBestHeight = pivotHeight;
+        }
+      }
+      // Split the rest of the data into [list2] and [list3].
+      list2 = children.sublist(pivotIndex + 1, bestIndex + 1);
+      list2ByteSize = computeByteSizeForNodes(nodes: list2);
+
+      list3 = children.sublist(bestIndex + 1);
+      list3ByteSize = computeByteSizeForNodes(nodes: list3);
+    } else {
+      // Put all data in [list2] and none in [list3].
+      list2 = children.sublist(pivotIndex + 1);
+      list2ByteSize = computeByteSizeForNodes(nodes: list2);
+
+      final pivotAndList2Ratio =
+          (pivotByteSize + list2ByteSize) / totalByteSize;
+      final pivotRatio = pivotByteSize / (pivotByteSize + list2ByteSize);
+      pivotBestWidth = isHorizontalRectangle
+          ? pivotAndList2Ratio * width
+          : pivotRatio * width;
+      pivotBestHeight = isHorizontalRectangle
+          ? pivotRatio * height
+          : pivotAndList2Ratio * height;
+    }
+
+    final positionedTreemaps = <Positioned>[];
+
+    // Contruct list 1 sub-treemap.
+    final list1SizeRatio = list1ByteSize / totalByteSize;
+    final list1Width = isHorizontalRectangle ? width * list1SizeRatio : width;
+    final list1Height =
+        isHorizontalRectangle ? height : height * list1SizeRatio;
+    if (list1.isNotEmpty) {
+      positionedTreemaps.add(
+        Positioned(
+          left: 0.0,
+          right: 0.0,
+          width: list1Width,
+          height: list1Height,
+          child: Treemap.fromNodes(
+            nodes: list1,
+            levelsVisible: levelsVisible,
+            isOutermostLevel: isOutermostLevel,
+            onRootChangedCallback: onRootChangedCallback,
+            height: height,
+          ),
+        ),
+      );
+    }
+
+    // Construct list 2 sub-treemap.
+    final list2Width =
+        isHorizontalRectangle ? pivotBestWidth : width - pivotBestWidth;
+    final list2Height =
+        isHorizontalRectangle ? height - pivotBestHeight : pivotBestHeight;
+    final list2XCoord = isHorizontalRectangle ? list1Width : 0.0;
+    final list2YCoord = isHorizontalRectangle ? pivotBestHeight : list1Height;
+    if (list2.isNotEmpty) {
+      positionedTreemaps.add(
+        Positioned(
+          left: list2XCoord,
+          top: list2YCoord,
+          width: list2Width,
+          height: list2Height,
+          child: Treemap.fromNodes(
+            nodes: list2,
+            levelsVisible: levelsVisible,
+            isOutermostLevel: isOutermostLevel,
+            onRootChangedCallback: onRootChangedCallback,
+            height: height,
+          ),
+        ),
+      );
+    }
+
+    // Construct pivot cell.
+    final pivotXCoord = isHorizontalRectangle ? list1Width : list2Width;
+    final pivotYCoord = isHorizontalRectangle ? 0.0 : list1Height;
+
+    positionedTreemaps.add(
+      Positioned(
+        left: pivotXCoord,
+        top: pivotYCoord,
+        width: pivotBestWidth,
+        height: pivotBestHeight,
+        child: Treemap.fromRoot(
+          rootNode: pivotNode,
+          levelsVisible: levelsVisible - 1,
+          isOutermostLevel: false,
+          onRootChangedCallback: onRootChangedCallback,
+          height: height,
+        ),
+      ),
+    );
+
+    // Construct list 3 sub-treemap.
+    final list3Ratio = list3ByteSize / totalByteSize;
+    final list3Width = isHorizontalRectangle ? list3Ratio * width : width;
+    final list3Height = isHorizontalRectangle ? height : list3Ratio * height;
+    final list3XCoord =
+        isHorizontalRectangle ? list1Width + pivotBestWidth : 0.0;
+    final list3YCoord =
+        isHorizontalRectangle ? 0.0 : list1Height + pivotBestHeight;
+
+    if (list3.isNotEmpty) {
+      positionedTreemaps.add(
+        Positioned(
+          left: list3XCoord,
+          top: list3YCoord,
+          width: list3Width,
+          height: list3Height,
+          child: Treemap.fromNodes(
+            nodes: list3,
+            levelsVisible: levelsVisible,
+            isOutermostLevel: isOutermostLevel,
+            onRootChangedCallback: onRootChangedCallback,
+            height: height,
+          ),
+        ),
+      );
+    }
+
+    return positionedTreemaps;
+  }
+
+  Text buildNameAndSizeText({
+    @required Color fontColor,
+    @required bool oneLine,
+  }) {
+    return Text(
+      rootNode.displayText(oneLine: oneLine),
+      style: TextStyle(color: fontColor),
+      textAlign: TextAlign.center,
+      overflow: TextOverflow.ellipsis,
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (rootNode == null && nodes.isNotEmpty) {
+      return buildSubTreemaps();
+    } else {
+      return buildTreemap(context);
+    }
+  }
+
+  /// **Treemap widget layout**
+  /// ```
+  /// ----------------------------
+  /// |        Title Text        |
+  /// |--------------------------|
+  /// |                          |
+  /// |           Cell           |
+  /// |                          |
+  /// |                          |
+  /// ----------------------------
+  /// ```
+  Widget buildTreemap(BuildContext context) {
+    if (levelsVisible > 0 && rootNode.children.isNotEmpty) {
+      return Padding(
+        padding: const EdgeInsets.all(1.0),
+        child: Column(
+          children: [
+            if (height > minHeightToDisplayTitleText) buildTitleText(context),
+            Expanded(
+              child: Treemap.fromNodes(
+                nodes: rootNode.children,
+                levelsVisible: levelsVisible,
+                isOutermostLevel: isOutermostLevel,
+                onRootChangedCallback: onRootChangedCallback,
+                height: height,
+              ),
+            ),
+          ],
+        ),
+      );
+    } else {
+      return Column(
+        children: [
+          Expanded(
+            child: buildSelectable(
+              child: Container(
+                decoration: BoxDecoration(
+                  color: mainUiColor,
+                  border: Border.all(color: Colors.black87),
+                ),
+                child: Center(
+                  child: height > minHeightToDisplayCellText
+                      ? buildNameAndSizeText(
+                          fontColor: Colors.black,
+                          oneLine: false,
+                        )
+                      : const SizedBox(),
+                ),
+              ),
+            ),
+          ),
+        ],
+      );
+    }
+  }
+
+  Widget buildTitleText(BuildContext context) {
+    if (isOutermostLevel) {
+      final pathFromRoot = rootNode.pathFromRoot();
+      // Build breadcrumbs navigator.
+      return Container(
+        height: treeMapHeaderHeight,
+        child: ListView.separated(
+          shrinkWrap: true,
+          scrollDirection: Axis.horizontal,
+          separatorBuilder: (context, index) {
+            return const Text(' > ');
+          },
+          itemCount: pathFromRoot.length,
+          itemBuilder: (BuildContext context, int index) {
+            return buildSelectable(
+              child: Text(
+                index < pathFromRoot.length - 1
+                    ? pathFromRoot[index].name
+                    : pathFromRoot[index].displayText(),
+              ),
+              newRoot: pathFromRoot[index],
+            );
+          },
+        ),
+      );
+    } else {
+      return buildSelectable(
+        child: Container(
+          height: treeMapHeaderHeight,
+          width: double.infinity,
+          decoration: BoxDecoration(
+            border: Border.all(color: Colors.black87),
+          ),
+          child: buildNameAndSizeText(
+            fontColor: Theme.of(context).textTheme.bodyText2.color,
+            oneLine: true,
+          ),
+        ),
+      );
+    }
+  }
+
+  /// Builds a selectable container with [child] as its child.
+  ///
+  /// Selecting this widget will trigger a re-root of the tree
+  /// to the associated [TreemapNode].
+  ///
+  /// The default value for newRoot is [rootNode].
+  Tooltip buildSelectable({@required Widget child, TreemapNode newRoot}) {
+    newRoot ??= rootNode;
+    return Tooltip(
+      message: rootNode.displayText(),
+      waitDuration: tooltipWait,
+      preferBelow: false,
+      child: InkWell(
+        onTap: () {
+          if (rootNode.children.isNotEmpty) onRootChangedCallback(newRoot);
+        },
+        child: child,
+      ),
+    );
+  }
+
+  Widget buildSubTreemaps() {
+    return LayoutBuilder(
+      builder: (context, constraints) {
+        return Stack(
+          children: buildTreemaps(
+            children: nodes,
+            width: constraints.maxWidth,
+            height: constraints.maxHeight,
+          ),
+        );
+      },
+    );
+  }
+}
+
+class TreemapNode extends TreeNode<TreemapNode> {
+  TreemapNode({
+    @required this.name,
+    this.byteSize = 0,
+    this.childrenMap = const <String, TreemapNode>{},
+  })  : assert(name != null),
+        assert(byteSize != null),
+        assert(childrenMap != null);
+
+  final String name;
+  final Map<String, TreemapNode> childrenMap;
+  int byteSize;
+
+  String displayText({bool oneLine = true}) {
+    final separator = oneLine ? ' ' : '\n';
+    return '$name$separator[${prettyPrintBytes(byteSize, includeUnit: true)}]';
+  }
+
+  /// Returns a list of [TreemapNode] in the path from root node to [this].
+  List<TreemapNode> pathFromRoot() {
+    TreemapNode node = this;
+    final path = <TreemapNode>[];
+    while (node != null) {
+      path.add(node);
+      node = node.parent;
+    }
+    return path.reversed.toList();
+  }
+
+  void printTree() {
+    printTreeHelper(this, '');
+  }
+
+  void printTreeHelper(TreemapNode root, String tabs) {
+    print(tabs + '$root');
+    for (final child in root.children) {
+      printTreeHelper(child, tabs + '\t');
+    }
+  }
+
+  @override
+  String toString() {
+    return '{name: $name, size: $byteSize}';
+  }
+}
diff --git a/packages/devtools_app/lib/src/logging/logging_controller.dart b/packages/devtools_app/lib/src/logging/logging_controller.dart
index 8960c04..eb01c98 100644
--- a/packages/devtools_app/lib/src/logging/logging_controller.dart
+++ b/packages/devtools_app/lib/src/logging/logging_controller.dart
@@ -386,7 +386,7 @@
 
     final String summary = '${isolateRef['name']} • '
         '${e.json['reason']} collection in $time ms • '
-        '${printMb(usedBytes)} MB used of ${printMb(capacityBytes)} MB';
+        '${printMB(usedBytes, includeUnit: true)} used of ${printMB(capacityBytes, includeUnit: true)}';
 
     final Map<String, dynamic> event = <String, dynamic>{
       'reason': e.json['reason'],
diff --git a/packages/devtools_app/lib/src/memory/memory_controller.dart b/packages/devtools_app/lib/src/memory/memory_controller.dart
index 5687d7f..77081ff 100644
--- a/packages/devtools_app/lib/src/memory/memory_controller.dart
+++ b/packages/devtools_app/lib/src/memory/memory_controller.dart
@@ -52,12 +52,12 @@
 
   static const logFilenamePrefix = 'memory_log_';
 
-  final _showHeatMap = ValueNotifier<bool>(false);
+  final _showTreemap = ValueNotifier<bool>(false);
 
-  ValueListenable<bool> get showHeatMap => _showHeatMap;
+  ValueListenable<bool> get showTreemap => _showTreemap;
 
-  void toggleShowHeatMap(bool value) {
-    _showHeatMap.value = value;
+  void toggleShowTreeMap(bool value) {
+    _showTreemap.value = value;
   }
 
   final snapshots = <Snapshot>[];
diff --git a/packages/devtools_app/lib/src/memory/memory_heap_tree_view.dart b/packages/devtools_app/lib/src/memory/memory_heap_tree_view.dart
index 70cf4f7..28f40a3 100644
--- a/packages/devtools_app/lib/src/memory/memory_heap_tree_view.dart
+++ b/packages/devtools_app/lib/src/memory/memory_heap_tree_view.dart
@@ -23,8 +23,8 @@
 import 'memory_controller.dart';
 import 'memory_filter.dart';
 import 'memory_graph_model.dart';
-import 'memory_heatmap.dart';
 import 'memory_snapshot_models.dart';
+import 'memory_treemap.dart';
 import 'memory_utils.dart';
 
 class HeapTree extends StatefulWidget {
@@ -227,12 +227,8 @@
                     : _isSnapshotComplete ? 'Done' : '...'),
       ]);
     } else if (controller.snapshotByLibraryData != null) {
-      if (controller.showHeatMap.value) {
-        snapshotDisplay = HeatMapSizeAnalyzer(
-          child: SizedBox.expand(
-            child: FlameChart(controller),
-          ),
-        );
+      if (controller.showTreemap.value) {
+        snapshotDisplay = MemoryTreemap(controller);
       } else {
         snapshotDisplay = MemorySnapshotTable();
       }
@@ -268,7 +264,7 @@
       mainAxisAlignment: MainAxisAlignment.end,
       children: [
         Expanded(child: snapshotDisplay),
-        if (hasDetails) const SizedBox(width: defaultSpacing),
+        hasDetails ? const SizedBox(width: defaultSpacing) : const SizedBox(),
         // TODO(terry): Need better focus handling between 2 tables & up/down
         //              arrows in the right-side field instance view table.
         controller.isLeafSelected
@@ -343,13 +339,13 @@
         const SizedBox(width: defaultSpacing),
         Row(
           children: [
-            const Text('Heat Map'),
+            const Text('Treemap'),
             Switch(
-              value: controller.showHeatMap.value,
+              value: controller.showTreemap.value,
               onChanged: (value) {
                 setState(() {
                   closeAutoCompleteOverlay();
-                  controller.toggleShowHeatMap(value);
+                  controller.toggleShowTreeMap(value);
                   controller.search = '';
                   controller.selectedLeaf = null;
                 });
@@ -358,13 +354,13 @@
           ],
         ),
         const SizedBox(width: defaultSpacing),
-        controller.showHeatMap.value
+        controller.showTreemap.value
             ? const SizedBox()
             : _groupByDropdown(textTheme),
         const SizedBox(width: defaultSpacing),
         // TODO(terry): Mechanism to handle expand/collapse on both
         // tables objects/fields. Maybe notion in table?
-        controller.showHeatMap.value
+        controller.showTreemap.value
             ? const SizedBox()
             : OutlineButton(
                 key: collapseAllButtonKey,
@@ -386,7 +382,7 @@
                     : null,
                 child: const Text('Collapse All'),
               ),
-        controller.showHeatMap.value
+        controller.showTreemap.value
             ? const SizedBox()
             : OutlineButton(
                 key: expandAllButtonKey,
@@ -463,7 +459,7 @@
       ),
     );
 
-    if (controller.showHeatMap.value && controller.snapshots.isNotEmpty) {
+    if (controller.showTreemap.value && controller.snapshots.isNotEmpty) {
       searchFieldFocusNode.requestFocus();
     }
 
diff --git a/packages/devtools_app/lib/src/memory/memory_heatmap.dart b/packages/devtools_app/lib/src/memory/memory_heatmap.dart
deleted file mode 100644
index 5d18241..0000000
--- a/packages/devtools_app/lib/src/memory/memory_heatmap.dart
+++ /dev/null
@@ -1,684 +0,0 @@
-// Copyright 2020 The Chromium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-import 'dart:math' as math;
-import 'dart:math';
-import 'dart:ui';
-
-import 'package:flutter/gestures.dart';
-import 'package:flutter/material.dart' hide TextStyle;
-import 'package:flutter/rendering.dart' hide TextStyle;
-import 'package:flutter/widgets.dart' hide TextStyle;
-import 'package:intl/intl.dart';
-import 'package:provider/provider.dart';
-
-import '../auto_dispose_mixin.dart';
-import '../ui/colors.dart';
-
-import 'memory_controller.dart';
-import 'memory_graph_model.dart';
-import 'memory_utils.dart';
-
-/// UX navigation in a heat map e.g.,
-///     ------------------------------------------------------------
-///     | ||| || |||| |       | new String | | || Foundation (38M) |
-///     ------------------------------------------------------------
-///     || |  |     | |  List | String | |  ||      src (42M)      |
-///     ------------------------------------------------------------
-///     | src (36.4M) |    dart:core (58M)   |package:flutter (42M)|
-///     ------------------------------------------------------------
-///     |                       Root (136M)                        |
-///     ------------------------------------------------------------
-/// Mouse action (click or mouse scroll wheel) over a rectangle for example
-/// package:flutter will to expand its contained children to more rectangles
-/// upwards. The sizes of the contained children (if an aggregated parent).
-/// Mouse action over, upward, children will further expand its children in
-/// a upward fashion too. To collapse a rectangle move the mouse below a child
-/// rectangle (its parent) and click or mouse scroll.
-
-class HeatMapSizeAnalyzer extends SingleChildRenderObjectWidget {
-  const HeatMapSizeAnalyzer({
-    Key key,
-    Widget child,
-  }) : super(key: key, child: child);
-
-  @override
-  RenderFlameChart createRenderObject(BuildContext context) {
-    return RenderFlameChart();
-  }
-}
-
-class RenderFlameChart extends RenderProxyBox {
-  @override
-  void paint(PaintingContext context, Offset offset) {
-    if (child != null) {
-      context.paintChild(child, offset);
-    }
-  }
-}
-
-class FlameChart extends StatefulWidget {
-  const FlameChart(
-    this.controller,
-  );
-
-  final MemoryController controller;
-
-  @override
-  FlameChartState createState() => FlameChartState(controller);
-}
-
-class FlameChartState extends State<FlameChart> with AutoDisposeMixin {
-  FlameChartState(this.controller);
-
-  InstructionsSize sizes;
-
-  Map<String, Function> callbacks = {};
-
-  MemoryController controller;
-
-  Widget snapshotDisplay;
-
-  _FlameChart _flameChart;
-
-  @override
-  void initState() {
-    super.initState();
-  }
-
-  @override
-  void didChangeDependencies() {
-    super.didChangeDependencies();
-
-    // TODO(terry): Unable to short-circuit need to investigate why?
-    controller = Provider.of<MemoryController>(context);
-
-    cancel();
-
-    addAutoDisposeListener(controller.selectedSnapshotNotifier, () {
-      setState(() {
-        controller.computeAllLibraries(true, true);
-
-        sizes = InstructionsSize.fromSnapshop(controller);
-      });
-    });
-
-    addAutoDisposeListener(controller.filterNotifier, () {
-      setState(() {
-        controller.computeAllLibraries(true, true);
-      });
-    });
-
-    addAutoDisposeListener(controller.selectTheSearchNotifier, () {
-      setState(() {
-        if (_trySelectItem()) {
-          closeAutoCompleteOverlay();
-        }
-      });
-    });
-
-    addAutoDisposeListener(controller.searchNotifier, () {
-      setState(() {
-        if (_trySelectItem()) {
-          closeAutoCompleteOverlay();
-        }
-      });
-    });
-
-    addAutoDisposeListener(controller.searchAutoCompleteNotifier, () {
-      setState(autoCompleteOverlaySetState(controller, context));
-    });
-  }
-
-  /// Returns true if node found and node selected.
-  bool _trySelectItem() {
-    if (controller.search.isNotEmpty) {
-      if (controller.selectTheSearch) {
-        // Select the node.
-        final node = findNode(controller.search);
-        selectNode(node);
-
-        closeAutoCompleteOverlay();
-
-        controller.selectTheSearch = false;
-        controller.search = '';
-        return true;
-      } else {
-        var matches = matchNames(controller.search)..sort();
-        // No exact match, return top matches.
-        matches = matches.sublist(0, min(topMatchesLimit, matches.length));
-        controller.searchAutoComplete.value = matches;
-      }
-    } else if (controller.selectTheSearch) {
-      // Escape hit, on empty search.
-      selectNode(null);
-      controller.selectTheSearch = false;
-    }
-
-    return false;
-  }
-
-  @override
-  void dispose() {
-    super.dispose();
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    sizes = InstructionsSize.fromSnapshop(controller);
-
-    if (sizes != null) {
-      _flameChart = _FlameChart(
-        sizes,
-        // TODO(terry): Can the color range match flame chart? Review with UX.
-        memoryHeatMapLightColor,
-        memoryHeatMapDarkColor,
-        callbacks,
-      );
-      return _flameChart;
-    } else {
-      return const SizedBox();
-    }
-  }
-
-  List<String> matchNames(String searchValue) {
-    final MatchNamesFunction callback = _flameChart.callbacks[matchNamesKey];
-    return callback(searchValue);
-  }
-
-  Node findNode(String searchValue) {
-    final FindNodeFunction callback = _flameChart.callbacks[findNodeKey];
-    return callback(searchValue);
-  }
-
-  void selectNode(Node nodeValue) {
-    final SelectNodeFunction callback = _flameChart.callbacks[selectNodeKey];
-    callback(nodeValue);
-  }
-}
-
-/// Definitions of exposed callback methods stored in callback Map the key
-/// is the function name (String) and the value a callback function signature.
-
-/// matchNames callback name.
-const matchNamesKey = 'matchNames';
-
-/// matchNames callback signature.
-typedef MatchNamesFunction = List<String> Function(String);
-
-/// findNode callback name.
-const findNodeKey = 'findNode';
-
-/// findNode callback signature.
-typedef FindNodeFunction = Node Function(String);
-
-/// selectNode callback name.
-const selectNodeKey = 'selectNode';
-
-/// selectNode callback signature.
-typedef SelectNodeFunction = void Function(Node);
-
-class _FlameChart extends LeafRenderObjectWidget {
-  const _FlameChart(
-      this.sizes, this.lightColor, this.darkColor, this.callbacks);
-
-  final InstructionsSize sizes;
-
-  final Color lightColor;
-
-  final Color darkColor;
-
-  final Map<String, Function> callbacks;
-
-  @override
-  FlameChartRenderObject createRenderObject(BuildContext context) {
-    return FlameChartRenderObject()
-      // callbacks must be before sizes as hookup is done in sizes setter.
-      ..callbacks = callbacks
-      ..sizes = sizes
-      ..lightColor = lightColor
-      ..darkColor = darkColor;
-  }
-
-  @override
-  void updateRenderObject(
-    BuildContext context,
-    FlameChartRenderObject renderObject,
-  ) {
-    renderObject
-      ..sizes = sizes
-      ..lightColor = lightColor
-      ..darkColor = darkColor;
-  }
-}
-
-class FlameChartRenderObject extends RenderBox {
-  FlameChartRenderObject();
-
-  InstructionsSize _sizes;
-
-  Offset paintOffset;
-
-  Map<String, Function> callbacks;
-
-  set sizes(InstructionsSize value) {
-    callbacks[matchNamesKey] = _exposeMatchNames;
-    callbacks[findNodeKey] = _exposeFindNode;
-    callbacks[selectNodeKey] = _exposeSelectNode;
-
-    if (value == _sizes) {
-      return;
-    }
-    _sizes = value;
-    _selectedNode ??= value.root;
-    markNeedsPaint();
-  }
-
-  Color _lightColor;
-
-  set lightColor(Color value) {
-    if (value == _lightColor) {
-      return;
-    }
-    _lightColor = value;
-    markNeedsPaint();
-  }
-
-  Color _darkColor;
-
-  set darkColor(Color value) {
-    if (value == _lightColor) {
-      return;
-    }
-    _darkColor = value;
-    markNeedsPaint();
-  }
-
-  List<String> _exposeMatchNames(String searchName) {
-    return matchNodeNames(_sizes.root.children, searchName);
-  }
-
-  /// Look for the node with a particular name (depth first traversal).
-  List<String> matchNodeNames(Map<String, Node> children, String searchName) {
-    final matches = <String>[];
-    final matchName = searchName.toLowerCase();
-    for (var child in children.entries) {
-      final node = child.value;
-
-      final lcNodeName = node.name.toLowerCase();
-      if (!lcNodeName.endsWith('.dart') && lcNodeName.startsWith(matchName)) {
-        matches.add(node.name);
-      }
-      if (node.children.isNotEmpty) {
-        final childMatches = matchNodeNames(node.children, searchName);
-        if (childMatches.isNotEmpty) {
-          matches.addAll(childMatches);
-        }
-      }
-    }
-
-    return matches;
-  }
-
-  Node _exposeFindNode(String searchName) {
-    return findNode(_sizes.root.children, searchName);
-  }
-
-  /// Look for the node with a particular name (depth first traversal).
-  Node findNode(Map<String, Node> children, String searchName) {
-    final matchName = searchName.toLowerCase();
-    for (var child in children.entries) {
-      final node = child.value;
-      if (node.children.isEmpty) {
-        return node.name.toLowerCase() == matchName ? node : null;
-      } else {
-        if (node.name.toLowerCase() == matchName) return node;
-        final foundNode = findNode(node.children, matchName);
-        if (foundNode != null) return foundNode;
-      }
-    }
-
-    return null;
-  }
-
-  void _exposeSelectNode(Node value) {
-    selectedNode = value;
-  }
-
-  Node _selectedNode;
-
-  set selectedNode(Node value) {
-    value ??= _sizes.root;
-    if (value == _selectedNode) {
-      return;
-    }
-    _selectedNode = value;
-
-    markNeedsPaint();
-  }
-
-  @override
-  bool get sizedByParent => true;
-
-  @override
-  bool hitTest(BoxHitTestResult result, {Offset position}) {
-    final computedPosition =
-        Offset(position.dx + paintOffset.dx, position.dy + paintOffset.dy);
-
-    final node = _selectedNode.findRect(computedPosition);
-    if (node != null) {
-      selectedNode = node;
-    }
-
-    return super.hitTest(result, position: position);
-  }
-
-  @override
-  void paint(PaintingContext context, Offset offset) {
-    paintOffset = offset;
-
-    final rootWidth = size.width;
-    final top = _paintAncestors(context, _selectedNode.ancestors);
-
-    _paintNode(
-        context, _selectedNode, 0 + paintOffset.dx, rootWidth, top - offset.dy);
-
-    _paintChildren(
-      context: context,
-      currentLeft: 1 + offset.dx,
-      parentSize: _selectedNode.byteSize,
-      children: _selectedNode.children.values,
-      topFactor: top + Node.nodeHeight + 1 - offset.dy,
-      maxWidth: rootWidth - 1,
-    );
-  }
-
-  final _logs = <int, double>{};
-  final _inverseColors = <Color, Color>{};
-
-  void _paintNode(
-    PaintingContext context,
-    Node node,
-    double left,
-    double width,
-    double top,
-  ) {
-    node.rect = Rect.fromLTWH(left, size.height - top, width, Node.nodeHeight);
-    final double t =
-        _logs.putIfAbsent(node.byteSize, () => math.log(node.byteSize)) /
-            _logs.putIfAbsent(
-                _sizes.root.byteSize, () => math.log(_sizes.root.byteSize));
-    final Color backgroundColor = Color.lerp(
-      _lightColor,
-      _darkColor,
-      t,
-    );
-
-    context.canvas.drawRRect(
-      RRect.fromRectAndRadius(node.rect, const Radius.circular(2.0)),
-      Paint()..color = backgroundColor,
-    );
-    // Don't bother figuring out the text length if the box is too small.
-    if (width < 100) {
-      return;
-    }
-
-    final formattedByteSize = NumberFormat.compact().format(node.byteSize);
-
-    final builder = ParagraphBuilder(
-      ParagraphStyle(
-        textAlign: TextAlign.center,
-        maxLines: 2,
-        ellipsis: '...',
-      ),
-    )
-      ..pushStyle(
-        TextStyle(
-          color: _inverseColors.putIfAbsent(backgroundColor, () {
-            return HSLColor.fromColor(backgroundColor).lightness > .7
-                ? const Color(0xFF000000)
-                : const Color(0xFFFFFFFF);
-          }),
-          fontFamily: 'Courier New',
-        ),
-      )
-      ..addText('${node.name}\n($formattedByteSize)');
-    final Paragraph paragraph = builder.build()
-      ..layout(ParagraphConstraints(width: width - 5));
-    context.canvas.drawParagraph(
-      paragraph,
-      Offset(10 + left, size.height - top + 10),
-    );
-  }
-
-  double _paintAncestors(PaintingContext context, List<Node> nodes) {
-    double top = Node.nodeHeight;
-    for (var node in nodes.reversed) {
-      _paintNode(
-          context, node, 0 + paintOffset.dx, size.width, top - paintOffset.dy);
-      top += Node.nodeHeight;
-    }
-    return top;
-  }
-
-  void _paintChildren({
-    PaintingContext context,
-    double currentLeft,
-    int parentSize,
-    Iterable<Node> children,
-    double topFactor,
-    double maxWidth,
-  }) {
-    double left = currentLeft;
-
-    for (var child in children) {
-      final double width = child.byteSize / parentSize * maxWidth;
-      _paintNode(context, child, left, width, topFactor);
-
-      if (!child.isLeaf) {
-        final double factor = math.max(
-          0.0001,
-          math.min(
-            math.log(maxWidth * .01),
-            math.log(width * .1),
-          ),
-        );
-        // Very minor chunk of memory, just skip display for now.  Smaller than 1 pixel in width.
-        if (factor < width) {
-          _paintChildren(
-            context: context,
-            currentLeft: left + factor,
-            parentSize: child.byteSize,
-            children: child.children.values,
-            topFactor: topFactor + Node.nodeHeight + 1,
-            maxWidth: width - (2 * factor),
-          );
-        }
-      }
-      left += width;
-    }
-  }
-}
-
-class InstructionsSize {
-  const InstructionsSize(this.root);
-
-  factory InstructionsSize.fromSnapshop(MemoryController controller) {
-    final Map<String, Node> rootChildren = <String, Node>{};
-    final Node root = Node(
-      'root',
-      children: rootChildren,
-    );
-    Node currentParent = root;
-
-    // TODO(terry): Should heat map be all memory or just the filtered group?
-    //              Using rawGroup not graph.groupByLibrary.
-    controller.heapGraph.rawGroupByLibrary.forEach(
-      (libraryGroup, value) {
-        final classes = value;
-        for (final theClass in classes) {
-          final shallowSize = theClass.instancesTotalShallowSizes;
-          var className = theClass.name;
-          if (shallowSize == 0 ||
-              libraryGroup == null ||
-              className == null ||
-              className == '::') {
-            continue;
-          }
-
-          // Ensure the empty library name is our group name e.g., '' -> 'src'.
-          String libraryName = theClass.libraryUri.toString();
-          if (libraryName.isEmpty) {
-            libraryName = libraryGroup;
-          }
-
-          // Map class names to familar user names.
-          final predefined =
-              predefinedClasses[LibraryClass(libraryName, className)];
-          if (predefined != null) {
-            className = predefined.prettyName;
-          }
-
-          final symbol = Symbol(
-            name: 'new $className',
-            size: shallowSize,
-            libraryUri: libraryName,
-            className: className,
-          );
-
-          Map<String, Node> currentChildren = rootChildren;
-          final Node parentReset = currentParent;
-          for (String pathPart in symbol.parts) {
-            currentChildren.putIfAbsent(
-              pathPart,
-              () => Node(pathPart,
-                  parent: currentParent, children: <String, Node>{}),
-            );
-            currentChildren[pathPart].byteSize += symbol.size;
-            currentParent = currentChildren[pathPart];
-            currentChildren = currentChildren[pathPart].children;
-          }
-          currentParent = parentReset;
-        }
-      },
-    );
-
-    root.byteSize = root.children.values
-        .fold(0, (int current, Node node) => current + node.byteSize);
-
-    final snapshotGraph = controller.snapshots.last.snapshotGraph;
-
-    // Add the external heap to the heat map.
-    root.children.putIfAbsent('External Heap', () {
-      final node = Node(
-        'External Heap',
-        parent: root,
-        children: <String, Node>{},
-      );
-      node.byteSize = snapshotGraph.externalSize;
-      return node;
-    });
-
-    // Add the filtered libraries/classes to the heat map.
-    root.children.putIfAbsent('All Filtered Libraries', () {
-      final node = Node(
-        'All Filtered Libraries',
-        parent: root,
-        children: <String, Node>{},
-      );
-      node.byteSize = snapshotGraph.shallowSize - root.byteSize;
-      return node;
-    });
-
-    root.byteSize = snapshotGraph.shallowSize + snapshotGraph.externalSize;
-
-    return InstructionsSize(root);
-  }
-
-  final Node root;
-}
-
-class Node {
-  Node(
-    this.name, {
-    this.byteSize = 0,
-    this.parent,
-    this.children = const <String, Node>{},
-  })  : assert(name != null),
-        assert(byteSize != null),
-        assert(children != null);
-
-  static const double nodeHeight = 50.0;
-
-  final String name;
-  int byteSize;
-  Rect rect = Rect.zero;
-  final Node parent;
-  final Map<String, Node> children;
-
-  Iterable<Node> get ancestors {
-    final nodes = <Node>[];
-    Node current = this;
-    while (current.parent != null) {
-      nodes.add(current.parent);
-      current = current.parent;
-    }
-    return nodes;
-  }
-
-  bool get isLeaf => children.isEmpty;
-
-  Node findRect(Offset offset) {
-    if (rect.contains(offset)) {
-      return this;
-    }
-    for (var ancestor in ancestors) {
-      if (ancestor.rect.contains(offset)) {
-        return ancestor;
-      }
-    }
-    for (var child in children.values) {
-      final value = child.findRect(offset);
-      if (value != null) {
-        return value;
-      }
-    }
-    return null;
-  }
-
-  @override
-  String toString() => 'Node($name, $byteSize, $rect)';
-}
-
-class Symbol {
-  const Symbol({
-    @required this.name,
-    @required this.size,
-    this.libraryUri,
-    this.className,
-  })  : assert(name != null),
-        assert(size != null);
-
-  static Symbol fromMap(Map<String, dynamic> json) {
-    return Symbol(
-      name: json['n'] as String,
-      size: json['s'] as int,
-      className: json['c'] as String,
-      libraryUri: json['l'] as String,
-    );
-  }
-
-  final String name;
-  final int size;
-  final String libraryUri;
-  final String className;
-
-  List<String> get parts {
-    return <String>[
-      if (libraryUri != null) ...libraryUri.split('/') else '@stubs',
-      if (className != null && className.isNotEmpty) className,
-      name,
-    ];
-  }
-}
diff --git a/packages/devtools_app/lib/src/memory/memory_treemap.dart b/packages/devtools_app/lib/src/memory/memory_treemap.dart
new file mode 100644
index 0000000..a810167
--- /dev/null
+++ b/packages/devtools_app/lib/src/memory/memory_treemap.dart
@@ -0,0 +1,266 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart' hide TextStyle;
+import 'package:flutter/widgets.dart' hide TextStyle;
+import 'package:provider/provider.dart';
+
+import '../auto_dispose_mixin.dart';
+import '../charts/treemap.dart';
+
+import 'memory_controller.dart';
+import 'memory_graph_model.dart';
+import 'memory_utils.dart';
+
+class MemoryTreemap extends StatefulWidget {
+  const MemoryTreemap(this.controller);
+
+  final MemoryController controller;
+
+  @override
+  MemoryTreemapState createState() => MemoryTreemapState(controller);
+}
+
+class MemoryTreemapState extends State<MemoryTreemap> with AutoDisposeMixin {
+  MemoryTreemapState(this.controller);
+
+  InstructionsSize sizes;
+
+  Map<String, Function> callbacks = {};
+
+  MemoryController controller;
+
+  Widget snapshotDisplay;
+
+  TreemapNode root;
+
+  @override
+  void didChangeDependencies() {
+    super.didChangeDependencies();
+
+    // TODO(terry): Unable to short-circuit need to investigate why?
+    controller = Provider.of<MemoryController>(context);
+
+    sizes = InstructionsSize.fromSnapshot(controller);
+
+    root = sizes.root;
+
+    cancel();
+
+    addAutoDisposeListener(controller.selectedSnapshotNotifier, () {
+      setState(() {
+        controller.computeAllLibraries(true, true);
+
+        sizes = InstructionsSize.fromSnapshot(controller);
+      });
+    });
+
+    addAutoDisposeListener(controller.filterNotifier, () {
+      setState(() {
+        controller.computeAllLibraries(true, true);
+      });
+    });
+    // TODO(peterdjlee): Need to check if applicable to treemap.
+    // addAutoDisposeListener(controller.selectTheSearchNotifier, () {
+    //   setState(() {
+    //     if (_trySelectItem()) {
+    //       closeAutoCompleteOverlay();
+    //     }
+    //   });
+    // });
+
+    // addAutoDisposeListener(controller.searchNotifier, () {
+    //   setState(() {
+    //     if (_trySelectItem()) {
+    //       closeAutoCompleteOverlay();
+    //     }
+    //   });
+    // });
+
+    addAutoDisposeListener(controller.searchAutoCompleteNotifier, () {
+      setState(autoCompleteOverlaySetState(controller, context));
+    });
+  }
+
+  void _onRootChanged(TreemapNode newRoot) {
+    setState(() {
+      root = newRoot;
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (sizes != null) {
+      return LayoutBuilder(
+        builder: (context, constraints) {
+          return Treemap.fromRoot(
+            rootNode: root,
+            levelsVisible: 2,
+            isOutermostLevel: true,
+            height: constraints.maxHeight,
+            onRootChangedCallback: _onRootChanged,
+          );
+        },
+      );
+    } else {
+      return const SizedBox();
+    }
+  }
+}
+
+/// Definitions of exposed callback methods stored in callback Map the key
+/// is the function name (String) and the value a callback function signature.
+
+/// matchNames callback name.
+const matchNamesKey = 'matchNames';
+
+/// matchNames callback signature.
+typedef MatchNamesFunction = List<String> Function(String);
+
+/// findNode callback name.
+const findNodeKey = 'findNode';
+
+/// findNode callback signature.
+typedef FindNodeFunction = TreemapNode Function(String);
+
+/// selectNode callback name.
+const selectNodeKey = 'selectNode';
+
+/// selectNode callback signature.
+typedef SelectNodeFunction = void Function(TreemapNode);
+
+class InstructionsSize {
+  const InstructionsSize(this.root);
+
+  factory InstructionsSize.fromSnapshot(MemoryController controller) {
+    final rootChildren = <String, TreemapNode>{};
+    final root = TreemapNode(
+      name: 'root',
+      childrenMap: rootChildren,
+    );
+    TreemapNode currentParent = root;
+
+    // TODO(terry): Should treemap be all memory or just the filtered group?
+    //              Using rawGroup not graph.groupByLibrary.
+
+    controller.heapGraph.rawGroupByLibrary.forEach(
+      (libraryGroup, value) {
+        final classes = value;
+        for (final theClass in classes) {
+          final shallowSize = theClass.instancesTotalShallowSizes;
+          var className = theClass.name;
+          if (shallowSize == 0 ||
+              libraryGroup == null ||
+              className == null ||
+              className == '::') {
+            continue;
+          }
+
+          // Ensure the empty library name is our group name e.g., '' -> 'src'.
+          String libraryName = theClass.libraryUri.toString();
+          if (libraryName.isEmpty) {
+            libraryName = libraryGroup;
+          }
+
+          // Map class names to familar user names.
+          final predefined =
+              predefinedClasses[LibraryClass(libraryName, className)];
+          if (predefined != null) {
+            className = predefined.prettyName;
+          }
+
+          final symbol = Symbol(
+            name: 'new $className',
+            size: shallowSize,
+            libraryUri: libraryName,
+            className: className,
+          );
+
+          Map<String, TreemapNode> currentChildren = rootChildren;
+          final parentReset = currentParent;
+          for (String pathPart in symbol.parts) {
+            currentChildren.putIfAbsent(
+              pathPart,
+              () {
+                final node = TreemapNode(
+                  name: pathPart,
+                  childrenMap: <String, TreemapNode>{},
+                );
+                currentParent.addChild(node);
+                return node;
+              },
+            );
+            currentChildren[pathPart].byteSize += symbol.size;
+            currentParent = currentChildren[pathPart];
+            currentChildren = currentChildren[pathPart].childrenMap;
+          }
+          currentParent = parentReset;
+        }
+      },
+    );
+
+    // Get sum of children's sizes.
+    root.byteSize = root.childrenMap.values
+        .fold(0, (int current, TreemapNode node) => current + node.byteSize);
+
+    final snapshotGraph = controller.snapshots.last.snapshotGraph;
+    // Add the external heap to the treemap.
+    root.childrenMap.putIfAbsent('External Heap', () {
+      final node = TreemapNode(
+        name: 'External Heap',
+        childrenMap: <String, TreemapNode>{},
+      )..byteSize = snapshotGraph.externalSize;
+      root.addChild(node);
+      return node;
+    });
+
+    // Add the filtered libraries/classes to the treemap.
+    root.childrenMap.putIfAbsent('All Filtered Libraries', () {
+      final node = TreemapNode(
+        name: 'All Filtered Libraries',
+        childrenMap: <String, TreemapNode>{},
+      )..byteSize = snapshotGraph.shallowSize - root.byteSize;
+      root.addChild(node);
+      return node;
+    });
+
+    root.byteSize = snapshotGraph.shallowSize + snapshotGraph.externalSize;
+
+    return InstructionsSize(root);
+  }
+
+  final TreemapNode root;
+}
+
+class Symbol {
+  const Symbol({
+    @required this.name,
+    @required this.size,
+    this.libraryUri,
+    this.className,
+  })  : assert(name != null),
+        assert(size != null);
+
+  static Symbol fromMap(Map<String, dynamic> json) {
+    return Symbol(
+      name: json['n'] as String,
+      size: json['s'] as int,
+      className: json['c'] as String,
+      libraryUri: json['l'] as String,
+    );
+  }
+
+  final String name;
+  final int size;
+  final String libraryUri;
+  final String className;
+
+  List<String> get parts {
+    return <String>[
+      if (libraryUri != null) ...libraryUri.split('/') else '@stubs',
+      if (className != null && className.isNotEmpty) className,
+      name,
+    ];
+  }
+}
diff --git a/packages/devtools_app/lib/src/ui/colors.dart b/packages/devtools_app/lib/src/ui/colors.dart
index 3096989..3a7e00b 100644
--- a/packages/devtools_app/lib/src/ui/colors.dart
+++ b/packages/devtools_app/lib/src/ui/colors.dart
@@ -13,6 +13,7 @@
 Color memoryHeatMapLightColor = const Color(0xFFBBDEFB); // Material BLUE 100
 Color memoryHeatMapDarkColor = const Color(0xFF0D47A1); // Material BLUE 900
 
+// TODO(peterdjlee): Rename mainUiColor to something that more broadly matches where the color is used.
 const mainUiColor = Color(0xFF88B1DE);
 const mainRasterColor = Color(0xFF2C5DAA);
 const mainUnknownColor = Color(0xFFCAB8E9);
diff --git a/packages/devtools_app/lib/src/utils.dart b/packages/devtools_app/lib/src/utils.dart
index 51d86e8..ed39357 100644
--- a/packages/devtools_app/lib/src/utils.dart
+++ b/packages/devtools_app/lib/src/utils.dart
@@ -64,17 +64,34 @@
 
 String percent2(double d) => '${(d * 100).toStringAsFixed(2)}%';
 
+String prettyPrintBytes(int size, {bool includeUnit = false}) {
+  final sizeInKB = size / 1024.0;
+  if (sizeInKB < 1024.0) {
+    return '${printKB(size, includeUnit: includeUnit)}';
+  } else {
+    return '${printMB(size, fractionDigits: 2, includeUnit: includeUnit)}';
+  }
+}
+
 final NumberFormat _kbPattern = NumberFormat.decimalPattern()
   ..maximumFractionDigits = 0;
 
-String printKb(num bytes) {
+String printKB(num bytes, {bool includeUnit = false}) {
   // We add ((1024/2)-1) to the value before formatting so that a non-zero byte
   // value doesn't round down to 0.
-  return _kbPattern.format((bytes + 511) / 1024);
+  var output = _kbPattern.format((bytes + 511) / 1024);
+  if (includeUnit) {
+    output += ' KB';
+  }
+  return output;
 }
 
-String printMb(num bytes, [int fractionDigits = 1]) {
-  return (bytes / (1024 * 1024.0)).toStringAsFixed(fractionDigits);
+String printMB(num bytes, {int fractionDigits = 1, bool includeUnit = false}) {
+  var output = (bytes / (1024 * 1024.0)).toStringAsFixed(fractionDigits);
+  if (includeUnit) {
+    output += ' MB';
+  }
+  return output;
 }
 
 String msText(
diff --git a/packages/devtools_app/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/devtools_app/macos/Flutter/GeneratedPluginRegistrant.swift
index 8236f57..4618f38 100644
--- a/packages/devtools_app/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/packages/devtools_app/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -5,8 +5,10 @@
 import FlutterMacOS
 import Foundation
 
+import path_provider_macos
 import url_launcher_macos
 
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+  PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
   UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
 }
diff --git a/packages/devtools_app/test/timeline_flame_chart_test.dart b/packages/devtools_app/test/timeline_flame_chart_test.dart
index 4052631..5a43356 100644
--- a/packages/devtools_app/test/timeline_flame_chart_test.dart
+++ b/packages/devtools_app/test/timeline_flame_chart_test.dart
@@ -69,10 +69,7 @@
       await pumpTimelineBody(tester, TimelineController());
       await tester.pumpAndSettle();
       expect(find.byType(TimelineFlameChart), findsNothing);
-      expect(
-        find.byKey(TimelineScreen.emptyTimelineKey),
-        findsOneWidget,
-      );
+      expect(find.byKey(TimelineScreen.emptyTimelineKey), findsOneWidget);
     });
 
     testWidgetsWithWindowSize(
@@ -85,6 +82,7 @@
       await tester.pumpAndSettle();
 
       expect(find.byType(TimelineFlameChart), findsOneWidget);
+<<<<<<< HEAD
       await expectLater(
           find.byType(TimelineFlameChart),
           matchesGoldenFile(
@@ -93,5 +91,12 @@
       // Await delay for golden comparison.
       await tester.pumpAndSettle(const Duration(seconds: 2));
     }, skip: kIsWeb || !Platform.isMacOS);
+=======
+      expect(
+          find.byType(TimelineFlameChart),
+          matchesGoldenFile(
+              'goldens/timeline_flame_chart_with_selected_frame.png'));
+    });
+>>>>>>> 4191e172... Fix merge conflict
   });
 }
diff --git a/packages/devtools_app/test/utils_test.dart b/packages/devtools_app/test/utils_test.dart
index daaf198..91dd59a 100644
--- a/packages/devtools_app/test/utils_test.dart
+++ b/packages/devtools_app/test/utils_test.dart
@@ -11,27 +11,40 @@
 
 void main() {
   group('utils', () {
+    test('prettyPrintBytes', () {
+      const int kb = 1024;
+      const int mb = 1024 * kb;
+
+      expect(prettyPrintBytes(kb), '1');
+      expect(prettyPrintBytes(kb, includeUnit: true), '1 KB');
+      expect(prettyPrintBytes(kb * 1000, includeUnit: true), '1,000 KB');
+
+      expect(prettyPrintBytes(mb), '1.00');
+      expect(prettyPrintBytes(mb, includeUnit: true), '1.00 MB');
+      expect(prettyPrintBytes(mb - kb, includeUnit: true), '1,023 KB');
+    });
+
     test('printKb', () {
       const int kb = 1024;
 
-      expect(printKb(0), '0');
-      expect(printKb(1), '1');
-      expect(printKb(kb - 1), '1');
-      expect(printKb(kb), '1');
-      expect(printKb(kb + 1), '2');
-      expect(printKb(2000), '2');
+      expect(printKB(0), '0');
+      expect(printKB(1), '1');
+      expect(printKB(kb - 1), '1');
+      expect(printKB(kb), '1');
+      expect(printKB(kb + 1), '2');
+      expect(printKB(2000), '2');
     });
 
     test('printMb', () {
-      const int MB = 1024 * 1024;
+      const int mb = 1024 * 1024;
 
-      expect(printMb(10 * MB, 0), '10');
-      expect(printMb(10 * MB), '10.0');
-      expect(printMb(10 * MB, 2), '10.00');
+      expect(printMB(10 * mb, fractionDigits: 0), '10');
+      expect(printMB(10 * mb), '10.0');
+      expect(printMB(10 * mb, fractionDigits: 2), '10.00');
 
-      expect(printMb(1000 * MB, 0), '1000');
-      expect(printMb(1000 * MB), '1000.0');
-      expect(printMb(1000 * MB, 2), '1000.00');
+      expect(printMB(1000 * mb, fractionDigits: 0), '1000');
+      expect(printMB(1000 * mb), '1000.0');
+      expect(printMB(1000 * mb, fractionDigits: 2), '1000.00');
     });
 
     test('msAsText', () {