blob: d3706601178e7e50415ea24008579bda0ad16b68 [file] [log] [blame]
// Copyright 2019 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:async';
import 'dart:convert';
import 'dart:html';
import 'dart:math' as math;
import 'package:meta/meta.dart';
import '../ui/colors.dart';
import '../ui/drag_scroll.dart';
import '../ui/elements.dart';
import '../ui/fake_flutter/fake_flutter.dart';
import '../ui/flutter_html_shim.dart';
import '../ui/theme.dart';
import '../ui/viewport_canvas.dart';
import '../utils.dart';
// TODO(kenzie): add tooltips to nodes on hover.
// We use the same color in light and dark mode because it aligns well with both
// color schemes.
const _selectedNodeColor = ThemedColor(
mainUiColorSelectedLight,
mainUiColorSelectedLight,
);
const _shadedBackgroundColor =
ThemedColor(Color(0xFFF6F6F6), Color(0xFF202124));
const double _fontSize = 14.0;
const double _textOffsetY = 18.0;
const double rowPadding = 2.0;
const double rowHeight = 25.0;
const double rowHeightWithPadding = rowHeight + rowPadding;
const double topOffset = rowHeightWithPadding;
const double sideInset = 70.0;
List<num> _asciiMeasurements;
abstract class FlameChart<T> {
FlameChart({
@required this.data,
@required this.duration,
@required this.width,
@required this.height,
}) : timelineGrid = TimelineGrid(duration, width) {
initRows();
}
final T data;
final Duration duration;
// These values are not final because the flame chart viewport can change in
// size.
double width;
double height;
double get widthWithInsets => calculatedWidth + 2 * sideInset;
final _nodeSelectedController = StreamController<FlameChartNode>.broadcast();
Stream<FlameChartNode> get onNodeSelected => _nodeSelectedController.stream;
FlameChartNode selectedNode;
List<FlameChartRow> rows = [];
TimelineGrid timelineGrid;
num zoomLevel = 1;
num get _zoomMultiplier => zoomLevel * 0.003;
// The DOM doesn't allow floating point scroll offsets so we track a
// theoretical floating point scroll offset corresponding to the current
// scroll offset to reduce floating point error when zooming.
num floatingPointScrollLeft = 0;
void initRows();
double get calculatedWidth;
void selectNodeAtOffset(Offset offset) {
final node = nodeAtOffset(offset);
// Do nothing if the tap did not occur on any nodes, if the tap was to
// select the already selected node.
if (node == null || node == selectedNode) {
return;
}
// Un-select the currently selected node if there is one.
if (selectedNode != null) {
selectedNode.selected = false;
}
// Select the new selected node.
node.selected = true;
selectedNode = node;
_nodeSelectedController.add(node);
}
FlameChartNode nodeAtOffset(Offset offset) {
final int rowIndex = rowIndexForY(offset.dy);
if (rowIndex < 0 || rowIndex >= rows.length) {
return null;
}
return nodeInRow(rowIndex, offset.dx);
}
FlameChartNode nodeInRow(int rowIndex, double x) {
final row = rows[rowIndex];
final nodes = row.nodes;
FlameChartNode binarySearchForNode() {
int min = 0;
int max = nodes.length;
while (min < max) {
final mid = min + ((max - min) >> 1);
final node = nodes[mid];
if (x >= node.rect.left && x <= node.rect.right) {
return node;
}
if (x < node.rect.left) {
max = mid;
}
if (x > node.rect.right) {
min = mid + 1;
}
}
return null;
}
return nodes.isEmpty ? null : binarySearchForNode();
}
double relativeYPosition(double absoluteY) => absoluteY - topOffset;
int rowIndexForY(double y) {
if (y < topOffset) {
return -1;
}
return math.max((relativeYPosition(y)) ~/ rowHeightWithPadding, 0);
}
}
abstract class FlameChartCanvas<T> extends FlameChart {
FlameChartCanvas({
@required T data,
@required Duration duration,
@required double width,
@required double height,
String classes,
}) : super(
data: data,
duration: duration,
width: width,
height: height,
) {
_viewportCanvas = ViewportCanvas(
paintCallback: _paintCallback,
onTap: _onTap,
classes: 'fill-section $classes',
)..element.element.style.overflow = 'hidden';
_viewportCanvas.setContentSize(width, height);
_dragScroll.enableDragScrolling(_viewportCanvas.element);
_dragScroll.onVerticalScroll = () {
_viewportCanvas.rebuild(force: true);
};
_viewportCanvas.element.element.onMouseWheel.listen(_handleMouseWheel);
_initAsciiMeasurements();
}
final DragScroll _dragScroll = DragScroll();
ViewportCanvas _viewportCanvas;
CoreElement get element => _viewportCanvas.element;
/// Maximum scroll delta allowed for scrollwheel based zooming.
///
/// This isn't really needed but is a reasonable for safety in case we
/// aren't handling some mouse based scroll wheel behavior well, etc.
final num maxScrollWheelDelta = 20;
/// Maximum zoom level we should allow.
///
/// Arbitrary large number to accommodate spacing for some of the shortest
/// events when zoomed in to [_maxZoomLevel].
final _maxZoomLevel = 150;
final _minZoomLevel = 1;
void _initAsciiMeasurements() {
// We have already initialized the list of Ascii measurements.
if (_asciiMeasurements != null) return;
final measurementCanvas = CanvasElement().context2D
..font = fontStyleToCss(const TextStyle(fontSize: _fontSize));
_asciiMeasurements = List.generate(
128,
(i) => measurementCanvas.measureText(ascii.decode([i])).width,
);
}
// TODO(kenzie): optimize painting to canvas by grouping paints with the same
// canvas settings.
void _paintCallback(CanvasRenderingContext2D canvas, Rect rect) {
final int startRow = math.max(rowIndexForY(rect.top), 0);
final int endRow = math.min(
rowIndexForY(rect.bottom) + 1,
rows.length - 1,
);
for (int i = startRow; i < endRow; i++) {
paintRow(canvas, i, rect);
}
timelineGrid.paint(canvas, _viewportCanvas.viewport, rect);
}
void paintRow(
CanvasRenderingContext2D canvas,
int index,
Rect visible,
) {
final row = rows[index];
for (FlameChartNode node in row.nodes) {
if (node.rect.left + node.rect.width < visible.left) continue;
if (node.rect.left > visible.right) break;
node.paint(canvas);
}
}
void _onTap(Offset offset) {
// Prevent clicks when the chart was being dragged.
if (!_dragScroll.wasDragged) {
selectNodeAtOffset(offset);
_viewportCanvas.rebuild(force: true);
}
}
void _handleMouseWheel(WheelEvent e) {
e.preventDefault();
if (e.deltaY.abs() >= e.deltaX.abs()) {
final mouseX = e.client.x -
_viewportCanvas.element.element.getBoundingClientRect().left;
_zoom(e.deltaY, mouseX);
} else {
// Manually perform horizontal scrolling.
_viewportCanvas.element.element.scrollLeft += e.deltaX.round();
}
}
void _zoom(num deltaY, num mouseX) {
assert(data != null);
deltaY = deltaY.clamp(-maxScrollWheelDelta, maxScrollWheelDelta);
num newZoomLevel = zoomLevel + deltaY * _zoomMultiplier;
newZoomLevel = newZoomLevel.clamp(_minZoomLevel, _maxZoomLevel);
if (newZoomLevel == zoomLevel) return;
// Store current scroll values for re-calculating scroll location on zoom.
num lastScrollLeft = _viewportCanvas.element.element.scrollLeft;
// Test whether the scroll offset has changed by more than rounding error
// since the last time an exact scroll offset was calculated.
if ((floatingPointScrollLeft - lastScrollLeft).abs() < 0.5) {
lastScrollLeft = floatingPointScrollLeft;
}
// Position in the zoomable coordinate space that we want to keep fixed.
final num fixedX = mouseX + lastScrollLeft - sideInset;
// Calculate and set our new horizontal scroll position.
if (fixedX >= 0) {
floatingPointScrollLeft =
fixedX * newZoomLevel / zoomLevel + sideInset - mouseX;
} else {
// No need to transform as we are in the fixed portion of the window.
floatingPointScrollLeft = lastScrollLeft;
}
zoomLevel = newZoomLevel;
_updateChartForZoom();
}
void _updateChartForZoom() {
for (FlameChartRow row in rows) {
for (FlameChartNode node in row.nodes) {
node.updateForZoom(zoom: zoomLevel);
}
}
timelineGrid.updateForZoom(zoomLevel, calculatedWidth);
forceRebuildForSize(widthWithInsets, height);
_viewportCanvas.element.element.scrollLeft =
math.max(0, floatingPointScrollLeft.round());
}
void forceRebuildForSize(double width, double height) {
this.width = width;
this.height = height;
_viewportCanvas.setContentSize(width, height);
_viewportCanvas.rebuild(force: true);
}
}
class FlameChartRow {
const FlameChartRow({
@required this.nodes,
@required this.index,
});
final List<FlameChartNode> nodes;
final int index;
}
class FlameChartNode<T> {
FlameChartNode(
this.rect,
this.backgroundColor,
this.textColor,
this.selectedTextColor,
this.data,
this.displayTextProvider, {
this.rounded = false,
}) : startingLeft = rect.left,
startingWidth = rect.width;
static const horizontalPadding = 4.0;
static const borderRadius = 2.0;
static const selectedBorderWidth = 1.0;
static const selectedBorderColor = ThemedColor(
Color(0x5A1B1F23),
Color(0x5A1B1F23),
);
static const minWidthForText = 20;
/// Left value for the flame chart item at zoom level 1.
final num startingLeft;
/// Width value for the flame chart item at zoom level 1;
final num startingWidth;
final Color backgroundColor;
final Color textColor;
final Color selectedTextColor;
final T data;
final String Function(T) displayTextProvider;
final bool rounded;
final Map<String, num> textMeasurements = {};
Rect rect;
String get text => displayTextProvider(data);
String get tooltip => '$data';
num get maxTextWidth => rect.width - horizontalPadding * 2;
bool selected = false;
void paint(CanvasRenderingContext2D canvas) {
canvas.fillStyle =
colorToCss(selected ? _selectedNodeColor : backgroundColor);
if (rounded) {
canvas
..beginPath()
..moveTo(rect.left + borderRadius, rect.top)
..lineTo(rect.right - borderRadius, rect.top)
..quadraticCurveTo(
rect.right,
rect.top,
rect.right,
rect.top + borderRadius,
)
..lineTo(rect.right, rect.bottom - borderRadius)
..quadraticCurveTo(
rect.right,
rect.bottom,
rect.right - borderRadius,
rect.bottom,
)
..lineTo(rect.left + borderRadius, rect.bottom)
..quadraticCurveTo(
rect.left,
rect.bottom,
rect.left,
rect.bottom - borderRadius,
)
..lineTo(rect.left, rect.top + borderRadius)
..quadraticCurveTo(
rect.left,
rect.top,
rect.left + borderRadius,
rect.top,
)
..closePath()
..fill();
} else {
canvas.fillRect(rect.left, rect.top, rect.width, rect.height);
}
if (selected) {
canvas
..strokeStyle = colorToCss(selectedBorderColor)
..lineWidth = selectedBorderWidth
..stroke();
}
if (rect.width > minWidthForText) {
canvas
..fillStyle = colorToCss(selected ? selectedTextColor : textColor)
..font = fontStyleToCss(const TextStyle(fontSize: _fontSize));
String displayText = text;
// TODO(kenzie): further optimize text painting by setting a budget for
// how much text can be measured each frame, and incrementally updating
// the text in the UI.
if (!_textFitsInRect(displayText, canvas)) {
displayText = longestFittingSubstring(
text,
maxTextWidth,
_asciiMeasurements,
(int value) => canvas.measureText(String.fromCharCode(value)).width,
);
}
canvas.fillText(
displayText,
rect.left + horizontalPadding,
rect.top + _textOffsetY,
maxTextWidth,
);
}
}
bool _textFitsInRect(String text, CanvasRenderingContext2D canvas) {
final textWidth = textMeasurements[text] ??= canvas.measureText(text).width;
return textWidth <= maxTextWidth;
}
void updateForZoom({@required num zoom}) {
// TODO(kenzie): this comment may be dated now that we are drawing to
// canvas. Look into it and delete it if necessary.
// Do not round these values. Rounding the left could cause us to have
// inaccurately placed events on the chart. Rounding the width could cause
// us to lose very small events if the width rounds to zero.
final newLeft = (startingLeft - sideInset) * zoom + sideInset;
final newWidth = startingWidth * zoom;
final updatedRect = Rect.fromLTWH(newLeft, rect.top, newWidth, rect.height);
rect = updatedRect;
}
}
class TimelineGrid {
TimelineGrid(this._duration, this._flameChartWidth);
static const baseGridIntervalPx = 150;
static const gridLineWidth = 0.4;
static const gridLineColor = ThemedColor(
Color(0xFFCCCCCC),
Color(0xFF585858),
);
static const timestampOffsetX = 6.0;
static const timestampColor = ThemedColor(
Color(0xFF24292E),
Color(0xFFFAFBFC),
);
final Duration _duration;
num currentInterval = baseGridIntervalPx;
num _flameChartWidth;
num _zoomLevel = 1;
void paint(CanvasRenderingContext2D canvas, Rect viewport, Rect visible) {
// Draw the background for the section that will contain the timestamps.
// This section will be sticky to the top of the viewport.
canvas.fillStyle = colorToCss(_shadedBackgroundColor);
canvas.fillRect(
visible.left,
viewport.top,
visible.width,
rowHeight,
);
num left = (visible.left - sideInset) ~/ currentInterval * currentInterval +
sideInset;
final firstGridNodeText = msText(
const Duration(microseconds: 0),
fractionDigits: 1,
);
// Set canvas styles and handle the first grid node since it will have a
// different width than the rest.
canvas
..font = fontStyleToCss(const TextStyle(fontSize: _fontSize))
..fillStyle = colorToCss(timestampColor)
..fillText(
firstGridNodeText,
_timestampLeft(firstGridNodeText, 0, sideInset, canvas),
viewport.top + _textOffsetY,
)
..strokeStyle = colorToCss(gridLineColor)
..lineWidth = gridLineWidth
..beginPath()
..moveTo(sideInset, visible.top)
..lineTo(sideInset, visible.bottom)
..closePath()
..stroke();
while (left < visible.right) {
if (left + currentInterval < visible.left || left > visible.right) {
// We do not need to draw the grid node because it is out of view.
return;
}
// TODO(kenzie): Instead of calculating timestamp based on position, track
// timestamp var and increment it by time interval represented by each
// grid item. See comment on https://github.com/flutter/devtools/pull/325.
final timestamp =
Duration(microseconds: timestampForPosition(left + currentInterval));
final timestampText = msText(
timestamp,
fractionDigits: timestamp.inMicroseconds == 0 ? 1 : 3,
);
final timestampX = _timestampLeft(
timestampText,
left,
currentInterval,
canvas,
);
// Paint the timestamps and compute the line to stroke for the grid lines.
canvas
..fillText(timestampText, timestampX, viewport.top + _textOffsetY)
..beginPath()
..moveTo(left + currentInterval, viewport.top)
..lineTo(left + currentInterval, viewport.bottom)
..closePath()
..stroke();
left += currentInterval;
}
}
num _timestampLeft(
String timestampText,
num left,
num width,
CanvasRenderingContext2D canvas,
) {
return left +
width -
canvas.measureText(timestampText).width -
timestampOffsetX;
}
/// Returns the timestamp rounded to the nearest microsecond for the
/// x-position.
int timestampForPosition(num gridItemEnd) {
return ((gridItemEnd - sideInset) /
_flameChartWidth *
_duration.inMicroseconds)
.round();
}
void updateForZoom(num newZoomLevel, num newFlameChartWidth) {
if (_zoomLevel == newZoomLevel) {
return;
}
_flameChartWidth = newFlameChartWidth;
final log2NewZoomLevel = log2(newZoomLevel);
final gridZoomFactor = math.pow(2, log2NewZoomLevel);
final gridIntervalPx = baseGridIntervalPx / gridZoomFactor;
/// The physical pixel width of the grid interval at [newZoomLevel].
currentInterval = gridIntervalPx * newZoomLevel;
_zoomLevel = newZoomLevel;
}
}