blob: bd8a843684b3b6185c4b60cdfa636999beb295d7 [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 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import '../config_specific/logger/logger.dart' as logger;
import '../primitives/auto_dispose_mixin.dart';
import '../primitives/utils.dart';
import '../shared/theme.dart';
import 'chart_controller.dart';
import 'chart_trace.dart';
typedef DrawCodeCallback = void Function(Canvas canvas);
/// Perform some draw operations on a canvas after applying translate.
///
/// This helper function performs basic booking for translate tasks:
/// 1. save canvas state
/// 2. translate coordinates
/// 3. draw to the canvas with respect to translation coordinates
/// 4. restore canvas state back to the saved state.
void drawTranslate(
Canvas canvas,
double x,
double y,
DrawCodeCallback drawCode,
) {
canvas.save();
canvas.translate(x, y);
drawCode(canvas);
canvas.restore();
}
class Chart extends StatefulWidget {
Chart(
this.controller, {
String title = '',
}) {
controller.title = title;
}
final ChartController controller;
@override
ChartState createState() => ChartState();
}
class ChartState extends State<Chart> with AutoDisposeMixin {
ChartState();
ChartController get controller => widget.controller;
/// Helper to hookup notifiers.
void _initSetup() {
addAutoDisposeListener(controller.traceChanged, () {
setState(() {
if (controller.isZoomAll) {
controller.computeZoomRatio();
}
controller.computeChartArea();
});
});
}
@override
void initState() {
super.initState();
_initSetup();
}
@override
void didUpdateWidget(Chart oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != controller) {
_initSetup();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Chart's Custom Painter (paint) can be expensive for lots of data points (10,000s).
// A repaint boundary is necessary.
// TODO(terry): Optimize the 10,000s of data points to just the number of pixels in the
// chart - this will make paint very fast.
return RepaintBoundary(
child: LayoutBuilder(
// Inner container
builder: (_, constraints) => GestureDetector(
onTapDown: (TapDownDetails details) {
final xLocalPosition = details.localPosition.dx;
final timestampIndex =
controller.xCoordToTimestampIndex(xLocalPosition);
final timestamp = controller.xCoordToTimestamp(xLocalPosition);
controller.tapLocation.value = TapLocation(
details,
timestamp,
timestampIndex,
);
},
child: Container(
width: constraints.widthConstraints().maxWidth,
height: constraints.widthConstraints().maxHeight,
child: CustomPaint(
painter: ChartPainter(controller, colorScheme),
),
),
),
),
);
}
}
/// Used to track current and previous x,y position. The parameter yBase
/// is for stacked traces. If the Trace is stacked then yBase is the previous
/// trace's Y position. If a trace is not stacked then yBase is always 0.
class PointAndBase {
PointAndBase(
this.x,
this.y, {
double? yBase,
}) : base = yBase;
final double x;
final double y;
final double? base;
}
class ChartPainter extends CustomPainter {
ChartPainter(this.chartController, this.colorScheme) {
// marginTopY = createText(chartController.title, 1.5).height + paddingY;
}
final debugTrackPaintTime = false;
final ChartController chartController;
final ColorScheme colorScheme;
static const double axisWidth = 2;
@override
void paint(Canvas canvas, Size size) {
// TODO(terry): Used to monitor total painting time. For large
// datasets e.g., offline files of 10,000s of data
// points time can be kind of slows. Consider only
// sampling 1 point per horizontal pixel.
final startTime = DateTime.now();
final axis = Paint()
..strokeWidth = axisWidth
..color = Colors.grey;
if (size != chartController.size) {
chartController.size = size;
chartController.computeChartArea();
}
drawTranslate(
canvas,
chartController.xCanvasChart,
chartController.yCanvasChart,
(canavas) {
drawAxes(
canvas,
size,
axis,
displayX: chartController.displayXAxis,
displayTopLine: chartController.displayTopLine,
);
},
);
final traces = chartController.traces;
final tracesDataIndex = List<int>.generate(
traces.length,
(int index) {
final length = traces[index].data.length;
return length > 0 ? length - 1 : -1;
},
);
/// Key is trace index and value is x,y point.
final previousTracesData = <int, PointAndBase>{};
/// Key is trace index and value is x,y point.
final currentTracesData = <int, PointAndBase>{};
// Visible Y max.
var visibleYMax = 0.0;
// TODO(terry): Need to compute x-axis left-most position for last timestamp.
// May need to do the other direction so it looks better.
final endVisibleIndex =
chartController.timestampsLength - chartController.visibleXAxisTicks;
final xTranslation = chartController.xCoordLeftMostVisibleTimestamp;
final yTranslation = chartController.zeroYPosition;
int xTickIndex = chartController.timestampsLength;
while (--xTickIndex >= 0) {
final currentTimestamp = chartController.timestamps[xTickIndex];
if (xTickIndex < endVisibleIndex) {
// Once outside of visible range of data skip the rest of the collected data.
break;
}
final tracesLength = traces.length;
// Short-circuit if no traceDataIndexes left (all are -1) then we're done.
if (!tracesDataIndex.any((element) => element >= 0)) continue;
// Remember old cliprect.
canvas.save();
// Clip to the just the area being plotted. This is important so symbols
// larger than the tick area doesn't spill out on the left-side.
clipChart(canvas);
// Y base (stackedY) used for stacked traces each Y base for stack traces
// starts at zero. Then Y base for the current data point is the previous Y
// base (previous Y's datapoint). Then current data point's Y is added to Y base.
double stackedY = 0.0;
for (var index = 0; index < tracesLength; index++) {
final traceDataIndex = tracesDataIndex[index];
if (traceDataIndex >= 0) {
final trace = traces[index];
final traceData = trace.data[traceDataIndex];
final yValue = (trace.stacked) ? stackedY + traceData.y : traceData.y;
final xTimestamp = traceData.timestamp;
final xCanvasCoord =
chartController.timestampToXCanvasCoord(xTimestamp);
if (currentTimestamp == xTimestamp) {
// Get ready to render on canvas. Remember old canvas state
// and setup translations for x,y coordinates into the rendering
// area of the chart.
drawTranslate(
canvas,
xTranslation,
yTranslation,
(canvas) {
final xCoord = xCanvasCoord;
final yCoord = chartController.yPositionToYCanvasCoord(yValue);
final hasMultipleExtensionEvents =
traceData is DataAggregate ? traceData.count > 1 : false;
// Is the visible Y-axis max larger.
if (yValue > visibleYMax) {
visibleYMax = yValue;
}
currentTracesData[index] = PointAndBase(
xCoord,
yCoord,
yBase: chartController.yPositionToYCanvasCoord(stackedY),
);
if (trace.chartType == ChartType.symbol) {
assert(!trace.stacked);
drawSymbol(
canvas,
trace.characteristics,
xCoord,
yCoord,
hasMultipleExtensionEvents,
trace.symbolPath,
);
} else if (trace.chartType == ChartType.line) {
if (trace.characteristics.symbol == ChartSymbol.dashedLine) {
// TODO(terry): Collect all points and draw a dashed line using
// path_drawing package.
drawDashed(
canvas,
trace.characteristics,
xCoord,
yCoord,
chartController.tickWidth - 4,
);
} else if (previousTracesData[index] != null) {
final previous = previousTracesData[index]!;
final current = currentTracesData[index]!;
// Stacked lines.
// Drawline from previous plotted point to new point.
drawConnectedLine(
canvas,
trace.characteristics,
xCoord,
yCoord,
previous.x,
previous.y,
);
drawSymbol(
canvas,
trace.characteristics,
xCoord,
yCoord,
hasMultipleExtensionEvents,
trace.symbolPath,
);
// TODO(terry): Honor z-order and also maybe path just on the traces e.g.,
// fill from top of trace 0 to top of trace 1 don't origin
// from zero.
// Fill area between traces.
drawFillArea(
canvas,
trace.characteristics,
previous.x,
previous.y,
previous.base!,
current.x,
current.y,
current.base!,
);
} else {
// Draw point
drawSymbol(
canvas,
trace.characteristics,
xCoord,
yCoord,
hasMultipleExtensionEvents,
trace.symbolPath,
);
}
}
tracesDataIndex[index]--;
},
);
final tapLocation = chartController.tapLocation.value;
if (tapLocation?.index == xTickIndex ||
tapLocation?.timestamp == currentTimestamp) {
drawTranslate(
canvas,
xTranslation,
yTranslation,
(canavas) {
drawSelection(
canvas,
xCanvasCoord,
chartController.canvasChartHeight,
);
},
);
}
}
if (trace.stacked) {
stackedY += traceData.y;
}
}
previousTracesData.addAll(currentTracesData);
currentTracesData.clear();
}
// Undo the clipRect at beginning of for loop.
canvas.restore();
}
chartController.computeChartArea();
chartController.buildLabelTimestamps();
if (chartController.displayXAxis || chartController.displayXLabels) {
// Y translation is below X-axis line.
drawTranslate(
canvas,
xTranslation,
chartController.zeroYPosition + 1,
(canvas) {
// Draw the X-axis labels.
for (var timestamp in chartController.labelTimestamps) {
final xCoord = chartController.timestampToXCanvasCoord(timestamp);
drawXTick(canvas, timestamp, xCoord, axis, displayTime: true);
}
},
);
// X translation is left-most edge of chart widget.
drawTranslate(
canvas,
chartController.xCanvasChart,
yTranslation,
(canvas) {
// Rescale Y-axis to max visible Y range.
chartController.resetYMaxValue(visibleYMax);
// Draw Y-axis ticks and labels.
// TODO(terry): Optimization add a listener for Y-axis range changing
// only need to redraw Y-axis if the range changed.
if (chartController.displayYLabels) {
drawYTicks(canvas, chartController, axis);
}
},
);
}
drawTitle(canvas, size, chartController.title);
final elapsedTime = DateTime.now().difference(startTime).inMilliseconds;
if (debugTrackPaintTime && elapsedTime > 500) {
logger.log(
'${chartController.name} ${chartController.timestampsLength} '
'CustomPainter paint elapsed time $elapsedTime',
);
}
// Once painted we're not dirty anymore.
chartController.dirty = false;
}
void clipChart(Canvas canvas, {ClipOp op = ClipOp.intersect}) {
final leftSideSide = chartController.xCanvasChart;
final topChartSide = chartController.yCanvasChart;
final r = Rect.fromLTRB(
leftSideSide,
topChartSide,
chartController.canvasChartWidth +
leftSideSide -
chartController.xPaddingRight,
topChartSide + chartController.canvasChartHeight,
);
canvas.clipRect(r, clipOp: op);
}
// TODO(terry): Use drawText?
void drawTitle(Canvas canvas, Size size, String title) {
final tp = createText(title, 1.5);
tp.paint(canvas, Offset(size.width / 2 - tp.width / 2, 0));
}
void drawAxes(
Canvas canvas,
Size size,
Paint axis, {
bool displayX = true,
bool displayY = true,
bool displayTopLine = true,
}) {
final chartWidthPosition =
chartController.canvasChartWidth - chartController.xPaddingRight;
final chartHeight = chartController.canvasChartHeight;
// Top line of chart.
if (displayTopLine) {
canvas.drawLine(const Offset(0, 0), Offset(chartWidthPosition, 0), axis);
}
// Left-side of chart
if (displayY) {
canvas.drawLine(
const Offset(0, 0),
Offset(0, chartHeight),
axis,
);
}
// Bottom line of chart.
if (displayX) {
canvas.drawLine(
Offset(0, chartHeight),
Offset(chartWidthPosition, chartHeight),
axis,
);
}
}
void drawSelection(Canvas canvas, double x, double y) {
final paint = Paint()
..strokeWidth = 2.0
..color = colorScheme.hoverSelectionBarColor;
// Draw the vertical selection bar.
canvas.drawLine(
Offset(x, 0), // zero y-position of chart.
Offset(x, -chartController.canvasChartHeight),
paint,
);
}
/// Separated out from drawAxis because we don't know range until plotted.
void drawYTicks(Canvas canvas, ChartController chartController, Paint axis) {
final yScale = chartController.yScale;
for (var labelIndex = yScale.labelTicks; labelIndex >= 0; labelIndex--) {
final unit = pow(10, yScale.labelUnitExponent).floor();
final y = labelIndex * unit;
// Need to be zero based
final yCoord = chartController.yPositionToYCanvasCoord(y);
final labelName = constructLabel(
labelIndex.floor(),
yScale.labelUnitExponent.floor(),
);
// Label starts at left edge.
drawText(labelName, canvas, -chartController.xCanvasChart / 2, yCoord);
// Draw horizontal tick 6 pixels from Y-axis line.
canvas.drawLine(
Offset(0, yCoord),
Offset(-6, yCoord),
axis,
);
}
}
/// Return Y axis labels using the exponent to signal unit type and the
/// label value e.g.
static String constructLabel(int labelValue, int unitExponent) {
var unit = '';
switch (unitExponent) {
case 0:
case 1:
case 2:
case 3:
labelValue = labelValue * (pow(10, unitExponent) as int);
break;
// Return units in K e.g., 10K, 80K, 100K, 700K, etc.
// Notice that anything < 10K will return as 500, 2050, 5000, 9000, etc.
case 4:
case 5:
labelValue = labelValue * (pow(10, unitExponent - 4) as int);
unit = 'K';
break;
// Return units in M e.g., 1M, 8M, 10M, 30M, 100M, 400M, etc.
case 6:
case 7:
case 8:
labelValue = labelValue * (pow(10, unitExponent - 6) as int);
unit = 'M';
break;
// Return units in B e.g., 1B, 7B, 10B, 50B, 100B, 900B, etc.
case 9:
case 10:
case 11:
labelValue = labelValue * (pow(10, unitExponent - 9) as int);
unit = 'B';
break;
// Return units in T e.g., 1T, 5T, 10T, 40T, 100T, 300T, etc.
case 12:
case 13:
case 14:
labelValue = labelValue * (pow(10, unitExponent - 12) as int);
unit = 'T';
break;
default:
unit = 'e+$unitExponent';
}
final label = labelValue.toInt();
return label == 0 ? '0' : '$label$unit';
}
void drawXTick(
Canvas canvas,
int timestamp,
double xTickCoord,
Paint axis, {
shortTick = true,
displayTime = false,
}) {
if (displayTime) {
// Draw vertical tick (short or long).
canvas.drawLine(
Offset(xTickCoord, 0),
Offset(xTickCoord, shortTick ? 2 : 6),
axis,
);
final tp = createText(prettyTimestamp(timestamp), 1);
tp.paint(
canvas,
Offset(
xTickCoord - tp.width ~/ 2,
15.0 - tp.height ~/ 2,
),
);
}
}
void drawText(String textValue, Canvas canvas, double x, double y) {
final tp = createText(textValue, 1);
tp.paint(canvas, Offset(x + -tp.width / 2, y - tp.height / 2));
}
TextPainter createText(String textValue, double scale) {
final span = TextSpan(
// TODO(terry): All text in a chart is grey. A chart like a Trace
// should have PaintCharacteristics.
style: TextStyle(
color: Colors.grey[600],
fontSize: chartTextFontSize,
),
text: textValue,
);
final tp = TextPainter(
text: span,
textAlign: TextAlign.right,
textScaleFactor: scale,
textDirection: TextDirection.ltr,
);
tp.layout();
return tp;
}
void drawSymbol(
Canvas canvas,
PaintCharacteristics characteristics,
double x,
double y,
bool aggregateEvents,
Path? symbolPathToDraw,
) {
late PaintingStyle firstStyle;
late PaintingStyle secondStyle;
switch (characteristics.symbol) {
case ChartSymbol.disc:
case ChartSymbol.filledSquare:
case ChartSymbol.filledTriangle:
case ChartSymbol.filledTriangleDown:
firstStyle = PaintingStyle.fill;
break;
case ChartSymbol.ring:
case ChartSymbol.square:
case ChartSymbol.triangle:
case ChartSymbol.triangleDown:
firstStyle = PaintingStyle.stroke;
break;
case ChartSymbol.concentric:
firstStyle = PaintingStyle.stroke;
secondStyle = PaintingStyle.fill;
break;
case ChartSymbol.dashedLine:
break;
}
final paintFirst = Paint()
..style = firstStyle
..strokeWidth = characteristics.strokeWidth
..color = aggregateEvents
? characteristics.colorAggregate!
: characteristics.color;
switch (characteristics.symbol) {
case ChartSymbol.dashedLine:
drawDashed(
canvas,
characteristics,
x,
y,
chartController.tickWidth - 4,
);
break;
case ChartSymbol.disc:
case ChartSymbol.ring:
canvas.drawCircle(Offset(x, y), characteristics.diameter, paintFirst);
break;
case ChartSymbol.concentric:
// Outer ring.
canvas.drawCircle(Offset(x, y), characteristics.diameter, paintFirst);
// Inner disc.
final paintSecond = Paint()
..style = secondStyle
..strokeWidth = 0
// TODO(terry): Aggregate for concentric maybe needed someday.
..color = aggregateEvents
? characteristics.colorAggregate!
: characteristics.concentricCenterColor;
canvas.drawCircle(
Offset(x, y),
characteristics.concentricCenterDiameter,
paintSecond,
); // Circle
break;
case ChartSymbol.filledSquare:
case ChartSymbol.filledTriangle:
case ChartSymbol.filledTriangleDown:
case ChartSymbol.square:
case ChartSymbol.triangle:
case ChartSymbol.triangleDown:
// Draw symbol centered on [x,y] point (*).
final path = symbolPathToDraw!.shift(
Offset(
x - characteristics.width / 2,
y - characteristics.height / 2,
),
);
canvas.drawPath(path, paintFirst);
break;
default:
final message = 'Unknown symbol ${characteristics.symbol}';
assert(false, message);
logger.log(message, logger.LogLevel.error);
}
}
void drawCircle(
Canvas canvas,
PaintCharacteristics characteristics,
double x,
double y,
) {
final paint = Paint()
..style = characteristics.symbol == ChartSymbol.disc
? PaintingStyle.fill
: PaintingStyle.stroke
..strokeWidth = characteristics.strokeWidth
..color = characteristics.color;
canvas.drawCircle(Offset(x, y), characteristics.diameter, paint);
}
// TODO(terry): Use bezier path.
void drawDashed(
Canvas canvas,
PaintCharacteristics characteristics,
double x,
double y,
double tickWidth,
) {
assert(characteristics.symbol == ChartSymbol.dashedLine);
drawLine(
canvas,
characteristics,
x,
y,
tickWidth,
);
}
void drawConnectedLine(
Canvas canvas,
PaintCharacteristics characteristics,
double startX,
double startY,
double endX,
double endY,
) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = characteristics.strokeWidth
..color = characteristics.color;
canvas.drawLine(Offset(startX, startY), Offset(endX, endY), paint);
}
void drawLine(
Canvas canvas,
PaintCharacteristics characteristics,
double x,
double y,
double tickWidth,
) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = characteristics.strokeWidth
..color = characteristics.color;
canvas.drawLine(Offset(x, y), Offset(x + tickWidth, y), paint);
}
/// Used to fill in the area for a tick from X-coordinate 0 to the tick's
/// Y-coordinate with the current tick's width.
void drawFillArea(
Canvas canvas,
PaintCharacteristics characteristics,
double x0,
double y0,
double y0Bottom,
double x1,
double y1,
double y1Bottom,
) {
final paint = Paint()
..style = PaintingStyle.fill
..strokeWidth = characteristics.strokeWidth
..color = characteristics.color.withAlpha(140);
final fillArea = Path()
..moveTo(x0, y0Bottom)
..lineTo(x0, y0)
..lineTo(x1, y1)
..lineTo(x1, y1Bottom)
..lineTo(x0, y0Bottom);
fillArea.close();
canvas.drawPath(fillArea, paint);
fillArea.reset();
}
@override
bool shouldRepaint(ChartPainter oldDelegate) => chartController.isDirty;
Data _reduceHelper(Data curr, Data next) => curr.y > next.y ? curr : next;
/// Return the largest Y value in a particular trace if traceIndex is passed or
/// all traces if traceIndex is not passed in.
double maxValue({int? traceIndex}) {
var maxValue = 0.0;
if (traceIndex == null) {
for (var index = 0; index < chartController.traces.length; index++) {
final trace = chartController.traces[index];
final traceMax = trace.data.reduce(_reduceHelper).y;
maxValue = max(maxValue, traceMax);
}
} else {
final trace = chartController.traces[traceIndex];
maxValue =
trace.data.reduce((curr, next) => curr.y > next.y ? curr : next).y;
}
return maxValue;
}
}