blob: fce6d425bde512c30cad2c7b5c5f573493fd069a [file] [log] [blame]
// 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/gestures.dart';
import 'package:flutter/material.dart';
import '../primitives/trees.dart';
import '../primitives/utils.dart';
import '../shared/common_widgets.dart';
import '../shared/theme.dart';
import '../ui/colors.dart';
enum PivotType { pivotByMiddle, pivotBySize }
class Treemap extends StatefulWidget {
// TODO(peterdjlee): Consider auto-expanding rootNode named 'src'.
const Treemap.fromRoot({
required this.rootNode,
this.nodes,
required this.levelsVisible,
this.isOutermostLevel = false,
required this.width,
required this.height,
required this.onRootChangedCallback,
}) : assert((rootNode == null) != (nodes == null));
const Treemap.fromNodes({
this.rootNode,
required this.nodes,
required this.levelsVisible,
this.isOutermostLevel = false,
required this.width,
required this.height,
required this.onRootChangedCallback,
}) : assert((rootNode == null) != (nodes == null));
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 width;
final double height;
final void Function(TreemapNode? node) onRootChangedCallback;
static const PivotType pivotType = PivotType.pivotByMiddle;
static const treeMapHeaderHeight = 20.0;
static const minHeightToDisplayTitleText = 100.0;
static const minWidthToDisplayCellText = 40.0;
static const minHeightToDisplayCellText = 50.0;
@override
_TreemapState createState() => _TreemapState();
}
class _TreemapState extends State<Treemap> {
TreemapNode? hoveredNode;
/// 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].unsignedByteSize;
}
return sum;
}
int computePivot(List<TreemapNode> children) {
switch (Treemap.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].unsignedByteSize > maxSize) {
maxSize = children[i].unsignedByteSize.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<PositionedCell> buildTreemaps({
required List<TreemapNode> children,
required double x,
required double y,
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.unsignedByteSize.compareTo(a.unsignedByteSize));
if (children.length <= 2) {
final positionedChildren = <PositionedCell>[];
double offset = 0;
for (final child in children) {
final ratio = child.unsignedByteSize / totalByteSize;
final newWidth = isHorizontalRectangle ? ratio * width : width;
final newHeight = isHorizontalRectangle ? height : ratio * height;
positionedChildren.add(
PositionedCell(
rect: Rect.fromLTWH(
isHorizontalRectangle ? x + offset : x,
isHorizontalRectangle ? y : y + offset,
newWidth,
newHeight,
),
node: child,
child: Treemap.fromRoot(
rootNode: child,
levelsVisible: widget.levelsVisible - 1,
onRootChangedCallback: widget.onRootChangedCallback,
width: newWidth,
height: newHeight,
),
),
);
offset += isHorizontalRectangle ? ratio * width : ratio * height;
}
return positionedChildren;
}
final pivotIndex = computePivot(children);
final pivotNode = children[pivotIndex];
final pivotByteSize = pivotNode.unsignedByteSize;
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 = <PositionedCell>[];
// 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.addAll(
buildTreemaps(
children: list1,
x: x,
y: y,
width: list1Width,
height: list1Height,
),
);
}
// 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.addAll(
buildTreemaps(
children: list2,
x: x + list2XCoord,
y: y + list2YCoord,
width: list2Width,
height: list2Height,
),
);
}
// Construct pivot cell.
final pivotXCoord = isHorizontalRectangle ? list1Width : list2Width;
final pivotYCoord = isHorizontalRectangle ? 0.0 : list1Height;
positionedTreemaps.add(
PositionedCell(
rect: Rect.fromLTWH(
x + pivotXCoord,
y + pivotYCoord,
pivotBestWidth,
pivotBestHeight,
),
node: pivotNode,
child: Treemap.fromRoot(
rootNode: pivotNode,
levelsVisible: widget.levelsVisible - 1,
onRootChangedCallback: widget.onRootChangedCallback,
width: width,
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.addAll(
buildTreemaps(
children: list3,
x: x + list3XCoord,
y: y + list3YCoord,
width: list3Width,
height: list3Height,
),
);
}
return positionedTreemaps;
}
@override
Widget build(BuildContext context) {
if (widget.rootNode == null && widget.nodes!.isNotEmpty) {
// If constructed with Treemap.fromNodes
return buildSubTreemaps();
} else {
// If constructed with Treemap.fromRoot
return buildTreemap(context);
}
}
Widget buildSubTreemaps() {
assert(widget.nodes != null && widget.nodes!.isNotEmpty);
return LayoutBuilder(
builder: (context, constraints) {
// TODO(peterdjlee): Investigate why exception is thrown without this check
// and if there are any other cases.
if (constraints.maxHeight == 0 || constraints.maxWidth == 0) {
return const SizedBox();
}
final positionedChildren = buildTreemaps(
children: widget.nodes!,
x: 0,
y: 0,
width: constraints.maxWidth,
height: constraints.maxHeight,
);
if (widget.levelsVisible == 1) {
// If this is the second to the last level, paint all cells in the last level
// instead of creating widgets to improve performance.
final tooltipMessage = hoveredNode?.displayText();
return DevToolsTooltip(
// A key is required to force a rebuild of the tooltips for each cell.
// Use tooltipMessage as the key to prevent rebuilds within a cell.
key: Key(tooltipMessage?.toPlainText() ?? ''),
richMessage: tooltipMessage ?? const TextSpan(text: ''),
waitDuration: tooltipWaitLong,
child: MouseRegion(
onHover: (event) => _onHover(event, positionedChildren),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTapDown: (details) {
widget.onRootChangedCallback(hoveredNode);
},
child: CustomPaint(
painter: MultiCellPainter(nodes: positionedChildren),
size: Size(constraints.maxWidth, constraints.maxHeight),
),
),
),
);
} else {
// Else all widgets should still be positioned Treemap widgets.
return Stack(children: positionedChildren);
}
},
);
}
/// **Treemap widget layout**
/// ```
/// ----------------------------
/// | Title Text |
/// |--------------------------|
/// | |
/// | Cell |
/// | |
/// | |
/// ----------------------------
/// ```
Widget buildTreemap(BuildContext context) {
assert(widget.rootNode != null);
if (widget.rootNode!.children.isNotEmpty) {
return Padding(
padding: const EdgeInsets.all(1.0),
child: buildTreemapFromNodes(context),
);
} else {
// If given a root node but its children are empty, draw itself.
return buildTreemapFromRoot(context);
}
}
Widget buildTreemapFromNodes(BuildContext context) {
return Column(
children: [
if (widget.height > Treemap.minHeightToDisplayTitleText)
buildTitleText(context),
Expanded(
child: Treemap.fromNodes(
nodes: widget.rootNode!.children,
levelsVisible: widget.levelsVisible,
onRootChangedCallback: widget.onRootChangedCallback,
width: widget.width,
height: widget.height,
),
),
],
);
}
Column buildTreemapFromRoot(BuildContext context) {
return Column(
children: [
if (widget.isOutermostLevel) buildTitleText(context),
Expanded(
child: widget.isOutermostLevel
? buildCell()
: buildSelectable(child: buildCell()),
),
],
);
}
Container buildCell() {
return Container(
decoration: BoxDecoration(
color: widget.rootNode!.displayColor,
border: Border.all(color: Colors.black87),
),
child: Center(
child: widget.height > Treemap.minHeightToDisplayCellText
? buildNameAndSizeText(
textColor:
widget.rootNode!.showDiff ? Colors.white : Colors.black,
oneLine: false,
)
: const SizedBox(),
),
);
}
Widget buildTitleText(BuildContext context) {
if (widget.isOutermostLevel) {
return buildBreadcrumbNavigator();
} else {
return buildSelectable(
child: Container(
height: Treemap.treeMapHeaderHeight,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.black87),
),
child: buildNameAndSizeText(
textColor:
(Theme.of(context).textTheme.bodyText2 ?? const TextStyle())
.color,
oneLine: true,
),
),
);
}
}
RichText buildNameAndSizeText({
required bool oneLine,
required Color? textColor,
}) {
return RichText(
text: widget.rootNode!.displayText(oneLine: oneLine, color: textColor),
selectionColor: textColor,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
);
}
Widget buildBreadcrumbNavigator() {
final pathFromRoot = widget.rootNode!.pathFromRoot();
return Padding(
padding: const EdgeInsets.only(bottom: denseRowSpacing),
child: BreadcrumbNavigator.builder(
itemCount: pathFromRoot.length,
builder: (context, index) {
final node = pathFromRoot[index];
return Breadcrumb(
text: index < pathFromRoot.length - 1
? node.name
: node.displayText().toPlainText(),
isRoot: index == 0,
onPressed: () => widget.onRootChangedCallback(node),
);
},
),
);
}
/// 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 [widget.rootNode].
Widget buildSelectable({required Widget child, TreemapNode? newRoot}) {
newRoot ??= widget.rootNode;
return DevToolsTooltip(
message: widget.rootNode!.displayText().toPlainText(),
waitDuration: tooltipWaitLong,
child: InkWell(
onTap: () {
widget.onRootChangedCallback(newRoot);
},
child: child,
),
);
}
/// Checks if the touch point of the given [details] is overlapping with
/// a cell in [positionedCells].
///
/// If so, saves the matching hoveredNode.
void _onHover(
PointerHoverEvent event,
List<PositionedCell> positionedCells,
) {
final x = event.localPosition.dx;
final y = event.localPosition.dy;
final touchPoint = Offset(x, y);
// TODO(peterdjlee): Optimize with more efficient algorithm to find the overlapping cell.
// Currently O(positionedCells.length) but an optimized algorithm with
// O(log(positionedCells.length)) is possible.
for (final cell in positionedCells) {
if (cell.rect.contains(touchPoint)) {
setState(() {
hoveredNode = cell.node;
});
}
}
}
}
class TreemapNode extends TreeNode<TreemapNode> {
TreemapNode({
required this.name,
this.byteSize = 0,
this.childrenMap = const <String, TreemapNode>{},
this.showDiff = false,
this.backgroundColor,
this.caption,
});
final String name;
final Map<String, TreemapNode> childrenMap;
int byteSize;
final bool showDiff;
final Color? backgroundColor;
final String? caption;
int get unsignedByteSize => byteSize.abs();
Color get displayColor {
if (!showDiff) {
return backgroundColor ?? mainUiColor;
}
if (byteSize < 0) {
return treemapDecreaseColor;
} else {
return treemapIncreaseColor;
}
}
TextSpan displayText({Color? color, bool oneLine = true}) {
var displayName = name;
final textColor = color == null
? showDiff
? Colors.white
: Colors.black
: color;
// Trim beginning of the name of [this] if it starts with its parent's name.
// If the parent node and the child node's name are exactly the same,
// do not trim in order to avoid empty names.
if (parent != null &&
displayName.startsWith(parent!.name) &&
displayName != parent!.name) {
displayName = displayName.replaceFirst(parent!.name, '');
if (displayName.startsWith('/')) {
displayName = displayName.replaceFirst('/', '');
}
}
final separator = oneLine ? ' ' : '\n';
return TextSpan(
text: '$displayName$separator[${prettyByteSize()}]',
children: [
if (caption != null)
TextSpan(
text: '$separator$caption',
style: TextStyle(fontStyle: FontStyle.italic, color: textColor),
)
],
style: TextStyle(color: textColor),
);
}
String prettyByteSize() {
// Negative sign isn't explicitly added since a regular print of a negative number includes it.
final plusSign = showDiff && byteSize > 0 ? '+' : '';
return '$plusSign${prettyPrintBytes(byteSize, kbFractionDigits: 1, 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();
}
/// Returns the package path for this node.
///
/// Includes only package nodes (nodes that start with 'package:').
List<String> packagePath() {
final reversedPath = <String>[];
TreemapNode? current = this;
while (current != null) {
if (current.name.contains('(Dart AOT)')) {
// This as far up the tree as we want to go, since this is the root of
// the Dart AOT snapshot.
return reversedPath.reversed.toList();
}
// Prevent duplicated package names from being added to the path.
// Sometimes the hierarchy can look like this:
// package:a
// package: a
// <Type>
// <OneByteString>
if ((current.name.startsWith('package:') ||
current.name.startsWith('dart:')) &&
(reversedPath.isEmpty || reversedPath.last != current.name)) {
reversedPath.add(current.name);
}
current = current.parent;
}
return [];
}
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}';
}
@override
TreemapNode shallowCopy() {
throw UnimplementedError(
'This method is not implemented. Implement if you '
'need to call `shallowCopy` on an instance of this class.',
);
}
}
class PositionedCell extends Positioned {
PositionedCell({
required this.rect,
required this.node,
required child,
}) : super(
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
child: child,
);
final Rect rect;
final TreemapNode node;
}
class MultiCellPainter extends CustomPainter {
const MultiCellPainter({required this.nodes});
final List<PositionedCell> nodes;
@override
void paint(Canvas canvas, Size size) {
for (final positionedCell in nodes) {
paintCell(
canvas,
Size(positionedCell.width!, positionedCell.height!),
positionedCell,
);
}
}
void paintCell(Canvas canvas, Size size, PositionedCell positionedCell) {
final node = positionedCell.node;
final bounds = Rect.fromLTWH(
positionedCell.left!,
positionedCell.top!,
size.width,
size.height,
);
final rectPaint = Paint();
rectPaint.color = node.displayColor;
canvas.drawRect(bounds, rectPaint);
final borderPaint = Paint()
..color = Colors.black45
..style = PaintingStyle.stroke;
canvas.drawRect(bounds, borderPaint);
if (positionedCell.width! > Treemap.minWidthToDisplayCellText &&
positionedCell.height! > Treemap.minHeightToDisplayCellText) {
final textPainter = TextPainter(
text: node.displayText(oneLine: false),
textDirection: TextDirection.ltr,
textAlign: TextAlign.center,
ellipsis: '...',
)..layout(maxWidth: size.width);
final centerX =
positionedCell.left! + bounds.width / 2 - textPainter.width / 2;
final centerY =
positionedCell.top! + bounds.height / 2 - textPainter.height / 2;
textPainter.paint(
canvas,
Offset(centerX, centerY),
);
}
}
@override
bool shouldRepaint(MultiCellPainter oldDelegate) {
return false;
}
}