1.0.0
Some checks failed
Code Coverage / upload (push) Has been cancelled
Gh-Pages / build (push) Has been cancelled
Code Verification / verify (push) Has been cancelled

This commit is contained in:
zypherift
2025-08-09 18:17:34 +02:00
commit c7e3f36b06
438 changed files with 79192 additions and 0 deletions

18
lib/fl_chart.dart Normal file
View File

@@ -0,0 +1,18 @@
export 'src/chart/bar_chart/bar_chart.dart';
export 'src/chart/bar_chart/bar_chart_data.dart';
export 'src/chart/base/axis_chart/axis_chart_data.dart';
export 'src/chart/base/axis_chart/axis_chart_widgets.dart';
export 'src/chart/base/axis_chart/scale_axis.dart';
export 'src/chart/base/axis_chart/transformation_config.dart';
export 'src/chart/base/base_chart/base_chart_data.dart';
export 'src/chart/base/base_chart/fl_touch_event.dart';
export 'src/chart/candlestick_chart/candlestick_chart.dart';
export 'src/chart/candlestick_chart/candlestick_chart_data.dart';
export 'src/chart/line_chart/line_chart.dart';
export 'src/chart/line_chart/line_chart_data.dart';
export 'src/chart/pie_chart/pie_chart.dart';
export 'src/chart/pie_chart/pie_chart_data.dart';
export 'src/chart/radar_chart/radar_chart.dart';
export 'src/chart/radar_chart/radar_chart_data.dart';
export 'src/chart/scatter_chart/scatter_chart.dart';
export 'src/chart/scatter_chart/scatter_chart_data.dart';

View File

@@ -0,0 +1,168 @@
import 'package:fl_chart/src/chart/bar_chart/bar_chart_data.dart';
import 'package:fl_chart/src/chart/bar_chart/bar_chart_helper.dart';
import 'package:fl_chart/src/chart/bar_chart/bar_chart_renderer.dart';
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart';
import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart';
import 'package:fl_chart/src/chart/base/axis_chart/transformation_config.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_data.dart';
import 'package:fl_chart/src/chart/base/base_chart/fl_touch_event.dart';
import 'package:flutter/cupertino.dart';
/// Renders a bar chart as a widget, using provided [BarChartData].
class BarChart extends ImplicitlyAnimatedWidget {
/// [data] determines how the [BarChart] should be look like,
/// when you make any change in the [BarChartData], it updates
/// new values with animation, and duration is [duration].
/// also you can change the [curve]
/// which default is [Curves.linear].
BarChart(
this.data, {
this.chartRendererKey,
super.key,
@Deprecated('Please use [duration] instead')
Duration? swapAnimationDuration,
Duration duration = const Duration(milliseconds: 150),
@Deprecated('Please use [curve] instead') Curve? swapAnimationCurve,
Curve curve = Curves.linear,
this.transformationConfig = const FlTransformationConfig(),
}) : assert(
switch (data.alignment) {
BarChartAlignment.center ||
BarChartAlignment.end ||
BarChartAlignment.start =>
transformationConfig.scaleAxis != FlScaleAxis.horizontal &&
transformationConfig.scaleAxis != FlScaleAxis.free,
_ => true,
},
'Can not scale horizontally when BarChartAlignment is center, '
'end or start',
),
super(
duration: swapAnimationDuration ?? duration,
curve: swapAnimationCurve ?? curve,
);
/// Determines how the [BarChart] should be look like.
final BarChartData data;
/// {@macro fl_chart.AxisChartScaffoldWidget.transformationConfig}
final FlTransformationConfig transformationConfig;
/// We pass this key to our renderers which are supposed to
/// render the chart itself (without anything around the chart).
final Key? chartRendererKey;
/// Creates a [_BarChartState]
@override
_BarChartState createState() => _BarChartState();
}
class _BarChartState extends AnimatedWidgetBaseState<BarChart> {
/// we handle under the hood animations (implicit animations) via this tween,
/// it lerps between the old [BarChartData] to the new one.
BarChartDataTween? _barChartDataTween;
/// If [BarTouchData.handleBuiltInTouches] is true, we override the callback to handle touches internally,
/// but we need to keep the provided callback to notify it too.
BaseTouchCallback<BarTouchResponse>? _providedTouchCallback;
final Map<int, List<int>> _showingTouchedTooltips = {};
final _barChartHelper = BarChartHelper();
@override
Widget build(BuildContext context) {
final showingData = _getData();
return AxisChartScaffoldWidget(
data: showingData,
transformationConfig: widget.transformationConfig,
chartBuilder: (context, chartVirtualRect) => BarChartLeaf(
data: _withTouchedIndicators(_barChartDataTween!.evaluate(animation)),
targetData: _withTouchedIndicators(showingData),
key: widget.chartRendererKey,
chartVirtualRect: chartVirtualRect,
canBeScaled: widget.transformationConfig.scaleAxis != FlScaleAxis.none,
),
);
}
BarChartData _withTouchedIndicators(BarChartData barChartData) {
if (!barChartData.barTouchData.enabled ||
!barChartData.barTouchData.handleBuiltInTouches) {
return barChartData;
}
final newGroups = <BarChartGroupData>[];
for (var i = 0; i < barChartData.barGroups.length; i++) {
final group = barChartData.barGroups[i];
newGroups.add(
group.copyWith(
showingTooltipIndicators: _showingTouchedTooltips[i],
),
);
}
return barChartData.copyWith(
barGroups: newGroups,
);
}
BarChartData _getData() {
var newData = widget.data;
if (newData.minY.isNaN || newData.maxY.isNaN) {
final (minY, maxY) =
_barChartHelper.calculateMaxAxisValues(newData.barGroups);
newData = newData.copyWith(
minY: newData.minY.isNaN ? minY : newData.minY,
maxY: newData.maxY.isNaN ? maxY : newData.maxY,
);
}
final barTouchData = newData.barTouchData;
if (barTouchData.enabled && barTouchData.handleBuiltInTouches) {
_providedTouchCallback = barTouchData.touchCallback;
return newData.copyWith(
barTouchData:
newData.barTouchData.copyWith(touchCallback: _handleBuiltInTouch),
);
}
return newData;
}
void _handleBuiltInTouch(
FlTouchEvent event,
BarTouchResponse? touchResponse,
) {
if (!mounted) {
return;
}
_providedTouchCallback?.call(event, touchResponse);
if (!event.isInterestedForInteractions ||
touchResponse == null ||
touchResponse.spot == null) {
setState(_showingTouchedTooltips.clear);
return;
}
setState(() {
final spot = touchResponse.spot!;
final groupIndex = spot.touchedBarGroupIndex;
final rodIndex = spot.touchedRodDataIndex;
_showingTouchedTooltips.clear();
_showingTouchedTooltips[groupIndex] = [rodIndex];
});
}
@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_barChartDataTween = visitor(
_barChartDataTween,
_getData(),
(dynamic value) =>
BarChartDataTween(begin: value as BarChartData, end: widget.data),
) as BarChartDataTween?;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import 'dart:math';
import 'package:fl_chart/src/chart/bar_chart/bar_chart_data.dart';
/// Contains anything that helps BarChart works
class BarChartHelper {
/// Calculates minY, and maxY based on [barGroups],
/// returns cached values, to prevent redundant calculations.
(double minY, double maxY) calculateMaxAxisValues(
List<BarChartGroupData> barGroups,
) {
if (barGroups.isEmpty) {
return (0, 0);
}
final BarChartGroupData barGroup;
try {
barGroup = barGroups.firstWhere((element) => element.barRods.isNotEmpty);
} catch (_) {
// There is no barChartGroupData with at least one barRod
return (0, 0);
}
var maxY = max(barGroup.barRods[0].fromY, barGroup.barRods[0].toY);
var minY = min(barGroup.barRods[0].fromY, barGroup.barRods[0].toY);
for (var i = 0; i < barGroups.length; i++) {
final barGroup = barGroups[i];
for (var j = 0; j < barGroup.barRods.length; j++) {
final rod = barGroup.barRods[j];
maxY = max(maxY, rod.fromY);
minY = min(minY, rod.fromY);
maxY = max(maxY, rod.toY);
minY = min(minY, rod.toY);
if (rod.backDrawRodData.show) {
maxY = max(maxY, rod.backDrawRodData.fromY);
minY = min(minY, rod.backDrawRodData.fromY);
maxY = max(maxY, rod.backDrawRodData.toY);
minY = min(minY, rod.backDrawRodData.toY);
}
}
}
return (minY, maxY);
}
}

View File

@@ -0,0 +1,841 @@
import 'dart:core';
import 'dart:math';
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_painter.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/extensions/bar_chart_data_extension.dart';
import 'package:fl_chart/src/extensions/paint_extension.dart';
import 'package:fl_chart/src/extensions/path_extension.dart';
import 'package:fl_chart/src/extensions/rrect_extension.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:fl_chart/src/utils/utils.dart';
import 'package:flutter/material.dart';
/// Paints [BarChartData] in the canvas, it can be used in a [CustomPainter]
class BarChartPainter extends AxisChartPainter<BarChartData> {
/// Paints [dataList] into canvas, it is the animating [BarChartData],
/// [targetData] is the animation's target and remains the same
/// during animation, then we should use it when we need to show
/// tooltips or something like that, because [dataList] is changing constantly.
///
/// [textScale] used for scaling texts inside the chart,
/// parent can use [MediaQuery.textScaleFactor] to respect
/// the system's font size.
BarChartPainter() : super() {
_barPaint = Paint()..style = PaintingStyle.fill;
_barStrokePaint = Paint()..style = PaintingStyle.stroke;
_bgTouchTooltipPaint = Paint()
..style = PaintingStyle.fill
..color = Colors.white;
_borderTouchTooltipPaint = Paint()
..style = PaintingStyle.stroke
..color = Colors.transparent
..strokeWidth = 1.0;
_clipPaint = Paint();
}
late Paint _barPaint;
late Paint _barStrokePaint;
late Paint _bgTouchTooltipPaint;
late Paint _borderTouchTooltipPaint;
late Paint _clipPaint;
List<GroupBarsPosition>? _groupBarsPosition;
/// Paints [BarChartData] into the provided canvas.
@override
void paint(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<BarChartData> holder,
) {
if (holder.chartVirtualRect != null) {
final canvasRect = Offset.zero & canvasWrapper.size;
canvasWrapper
..saveLayer(
canvasRect,
_clipPaint,
)
..clipRect(canvasRect);
}
super.paint(context, canvasWrapper, holder);
final data = holder.data;
final targetData = holder.targetData;
if (data.barGroups.isEmpty) {
return;
}
final usableSize = holder.getChartUsableSize(canvasWrapper.size);
final groupsX = data.calculateGroupsX(usableSize.width);
final adjustment = holder.chartVirtualRect?.left ?? 0;
final groupsXAdjusted = groupsX.map((e) => e + adjustment).toList();
_groupBarsPosition = calculateGroupAndBarsPosition(
usableSize,
groupsXAdjusted,
data.barGroups,
);
if (!data.extraLinesData.extraLinesOnTop) {
super.drawHorizontalLines(
context,
canvasWrapper,
holder,
usableSize,
);
}
drawBars(canvasWrapper, _groupBarsPosition!, holder);
drawErrorIndicatorData(canvasWrapper, _groupBarsPosition!, holder);
if (data.extraLinesData.extraLinesOnTop) {
super.drawHorizontalLines(
context,
canvasWrapper,
holder,
usableSize,
);
}
if (holder.chartVirtualRect != null) {
canvasWrapper.restore();
}
for (var i = 0; i < data.barGroups.length; i++) {
final barGroup = data.barGroups[i];
for (var j = 0; j < barGroup.barRods.length; j++) {
if (!barGroup.showingTooltipIndicators.contains(j)) {
continue;
}
final barRod = barGroup.barRods[j];
drawTouchTooltip(
context,
canvasWrapper,
_groupBarsPosition!,
targetData.barTouchData.touchTooltipData,
barGroup,
i,
barRod,
j,
holder,
);
}
}
}
/// Calculates bars position alongside group positions.
@visibleForTesting
List<GroupBarsPosition> calculateGroupAndBarsPosition(
Size viewSize,
List<double> groupsX,
List<BarChartGroupData> barGroups,
) {
if (groupsX.length != barGroups.length) {
throw Exception('inconsistent state groupsX.length != barGroups.length');
}
final groupBarsPosition = <GroupBarsPosition>[];
for (var i = 0; i < barGroups.length; i++) {
final barGroup = barGroups[i];
final groupX = groupsX[i];
if (barGroup.groupVertically) {
groupBarsPosition.add(
GroupBarsPosition(
groupX,
List.generate(barGroup.barRods.length, (index) => groupX),
),
);
continue;
}
var tempX = 0.0;
final barsX = <double>[];
barGroup.barRods.asMap().forEach((barIndex, barRod) {
final widthHalf = barRod.width / 2;
barsX.add(groupX - (barGroup.width / 2) + tempX + widthHalf);
tempX += barRod.width + barGroup.barsSpace;
});
groupBarsPosition.add(GroupBarsPosition(groupX, barsX));
}
return groupBarsPosition;
}
@visibleForTesting
void drawBars(
CanvasWrapper canvasWrapper,
List<GroupBarsPosition> groupBarsPosition,
PaintHolder<BarChartData> holder,
) {
final data = holder.data;
final viewSize = canvasWrapper.size;
for (var i = 0; i < data.barGroups.length; i++) {
final barGroup = data.barGroups[i];
for (var j = 0; j < barGroup.barRods.length; j++) {
final barRod = barGroup.barRods[j];
final widthHalf = barRod.width / 2;
final borderRadius =
barRod.borderRadius ?? BorderRadius.circular(barRod.width / 2);
final borderSide = barRod.borderSide;
final x = groupBarsPosition[i].barsX[j];
final left = x - widthHalf;
final right = x + widthHalf;
final cornerHeight =
max(borderRadius.topLeft.y, borderRadius.topRight.y) +
max(borderRadius.bottomLeft.y, borderRadius.bottomRight.y);
RRect barRRect;
/// Draw [BackgroundBarChartRodData]
if (barRod.backDrawRodData.show &&
barRod.backDrawRodData.toY != barRod.backDrawRodData.fromY) {
if (barRod.backDrawRodData.toY > barRod.backDrawRodData.fromY) {
// positive
final bottom = getPixelY(
max(data.minY, barRod.backDrawRodData.fromY),
viewSize,
holder,
);
final top = min(
getPixelY(barRod.backDrawRodData.toY, viewSize, holder),
bottom - cornerHeight,
);
barRRect = RRect.fromLTRBAndCorners(
left,
top,
right,
bottom,
topLeft: borderRadius.topLeft,
topRight: borderRadius.topRight,
bottomLeft: borderRadius.bottomLeft,
bottomRight: borderRadius.bottomRight,
);
} else {
// negative
final top = getPixelY(
min(data.maxY, barRod.backDrawRodData.fromY),
viewSize,
holder,
);
final bottom = max(
getPixelY(barRod.backDrawRodData.toY, viewSize, holder),
top + cornerHeight,
);
barRRect = RRect.fromLTRBAndCorners(
left,
top,
right,
bottom,
topLeft: borderRadius.topLeft,
topRight: borderRadius.topRight,
bottomLeft: borderRadius.bottomLeft,
bottomRight: borderRadius.bottomRight,
);
}
final backDraw = barRod.backDrawRodData;
_barPaint.setColorOrGradient(
backDraw.color,
backDraw.gradient,
barRRect.getRect(),
);
canvasWrapper.drawRRect(barRRect, _barPaint);
}
// draw Main Rod
if (barRod.toY != barRod.fromY) {
if (barRod.toY > barRod.fromY) {
// positive
final bottom =
getPixelY(max(data.minY, barRod.fromY), viewSize, holder);
final top = min(
getPixelY(barRod.toY, viewSize, holder),
bottom - cornerHeight,
);
barRRect = RRect.fromLTRBAndCorners(
left,
top,
right,
bottom,
topLeft: borderRadius.topLeft,
topRight: borderRadius.topRight,
bottomLeft: borderRadius.bottomLeft,
bottomRight: borderRadius.bottomRight,
);
} else {
// negative
final top =
getPixelY(min(data.maxY, barRod.fromY), viewSize, holder);
final bottom = max(
getPixelY(barRod.toY, viewSize, holder),
top + cornerHeight,
);
barRRect = RRect.fromLTRBAndCorners(
left,
top,
right,
bottom,
topLeft: borderRadius.topLeft,
topRight: borderRadius.topRight,
bottomLeft: borderRadius.bottomLeft,
bottomRight: borderRadius.bottomRight,
);
}
_barPaint.setColorOrGradient(
barRod.color,
barRod.gradient,
barRRect.getRect(),
);
canvasWrapper.drawRRect(barRRect, _barPaint);
// draw rod stack
if (barRod.rodStackItems.isNotEmpty) {
for (var i = 0; i < barRod.rodStackItems.length; i++) {
final stackItem = barRod.rodStackItems[i];
final stackFromY = getPixelY(stackItem.fromY, viewSize, holder);
final stackToY = getPixelY(stackItem.toY, viewSize, holder);
final isNegative = stackItem.toY < stackItem.fromY;
_barPaint.color = stackItem.color;
final rect = isNegative
? Rect.fromLTRB(left, stackFromY, right, stackToY)
: Rect.fromLTRB(left, stackToY, right, stackFromY);
canvasWrapper
..save()
..clipRect(rect)
..drawRRect(barRRect, _barPaint)
..restore();
// draw border stroke for each stack item
drawStackItemBorderStroke(
canvasWrapper,
stackItem,
i,
barRod.rodStackItems.length,
barRod.width,
barRRect,
viewSize,
holder,
);
}
}
// draw border stroke
if (borderSide.width > 0 && borderSide.color.a > 0) {
_barStrokePaint
..color = borderSide.color
..strokeWidth = borderSide.width;
final borderPath = Path()..addRRect(barRRect);
canvasWrapper.drawPath(
borderPath.toDashedPath(
barRod.borderDashArray,
),
_barStrokePaint,
);
}
}
}
}
}
@visibleForTesting
void drawErrorIndicatorData(
CanvasWrapper canvasWrapper,
List<GroupBarsPosition> groupBarsPosition,
PaintHolder<BarChartData> holder,
) {
final data = holder.data;
final errorIndicatorData = data.errorIndicatorData;
if (!errorIndicatorData.show) {
return;
}
final viewSize = canvasWrapper.size;
for (var i = 0; i < data.barGroups.length; i++) {
final barGroup = data.barGroups[i];
for (var j = 0; j < barGroup.barRods.length; j++) {
final barRod = barGroup.barRods[j];
if (barRod.toYErrorRange == null) {
continue;
}
final x = groupBarsPosition[i].barsX[j];
final y = getPixelY(barRod.toY, viewSize, holder);
final top = getPixelY(
barRod.toY + barRod.toYErrorRange!.upperBy,
viewSize,
holder,
) -
y;
final bottom = getPixelY(
barRod.toY - barRod.toYErrorRange!.lowerBy,
viewSize,
holder,
) -
y;
final relativeErrorPixelsRect = Rect.fromLTRB(
0,
top,
0,
bottom,
);
final painter = errorIndicatorData.painter(
BarChartSpotErrorRangeCallbackInput(
group: barGroup,
groupIndex: i,
rod: barRod,
barRodIndex: j,
),
);
canvasWrapper.drawErrorIndicator(
painter,
FlSpot(
barGroup.x.toDouble(),
barRod.toY,
yError: barRod.toYErrorRange,
),
Offset(x, y),
relativeErrorPixelsRect,
holder.data,
);
}
}
}
@visibleForTesting
void drawTouchTooltip(
BuildContext context,
CanvasWrapper canvasWrapper,
List<GroupBarsPosition> groupPositions,
BarTouchTooltipData tooltipData,
BarChartGroupData showOnBarGroup,
int barGroupIndex,
BarChartRodData showOnRodData,
int barRodIndex,
PaintHolder<BarChartData> holder,
) {
final viewSize = canvasWrapper.size;
const textsBelowMargin = 4;
final tooltipItem = tooltipData.getTooltipItem(
showOnBarGroup,
barGroupIndex,
showOnRodData,
barRodIndex,
);
if (tooltipItem == null) {
return;
}
final span = TextSpan(
style: Utils().getThemeAwareTextStyle(context, tooltipItem.textStyle),
text: tooltipItem.text,
children: tooltipItem.children,
);
final tp = TextPainter(
text: span,
textAlign: tooltipItem.textAlign,
textDirection: tooltipItem.textDirection,
textScaler: holder.textScaler,
)..layout(maxWidth: tooltipData.maxContentWidth);
/// creating TextPainters to calculate the width and height of the tooltip
final drawingTextPainter = tp;
/// biggerWidth
/// some texts maybe larger, then we should
/// draw the tooltip' width as wide as biggerWidth
///
/// sumTextsHeight
/// sum up all Texts height, then we should
/// draw the tooltip's height as tall as sumTextsHeight
final textWidth = drawingTextPainter.width;
final textHeight = drawingTextPainter.height + textsBelowMargin;
final barX = groupPositions[barGroupIndex].barsX[barRodIndex];
/// if we have multiple bar lines,
/// there are more than one FlCandidate on touch area,
/// we should get the most top FlSpot Offset to draw the tooltip on top of it
final barToYPixel = Offset(
barX,
getPixelY(showOnRodData.toY, viewSize, holder),
);
final barFromYPixel = Offset(
barX,
getPixelY(showOnRodData.fromY, viewSize, holder),
);
final tooltipWidth = textWidth + tooltipData.tooltipPadding.horizontal;
final tooltipHeight = textHeight + tooltipData.tooltipPadding.vertical;
final barTopY = min(barToYPixel.dy, barFromYPixel.dy);
final barBottomY = max(barToYPixel.dy, barFromYPixel.dy);
final drawTooltipOnTop = tooltipData.direction == TooltipDirection.top ||
(tooltipData.direction == TooltipDirection.auto &&
showOnRodData.isUpward());
final tooltipOriginPoint = Offset(
barX,
drawTooltipOnTop ? barTopY : barBottomY,
);
final isZoomed = holder.chartVirtualRect != null;
if (isZoomed && !canvasWrapper.size.contains(tooltipOriginPoint)) {
return;
}
final tooltipTop = drawTooltipOnTop
? barTopY - tooltipHeight - tooltipData.tooltipMargin
: barBottomY + tooltipData.tooltipMargin;
final tooltipLeft = getTooltipLeft(
barToYPixel.dx,
tooltipWidth,
tooltipData.tooltipHorizontalAlignment,
tooltipData.tooltipHorizontalOffset,
);
/// draw the background rect with rounded radius
// ignore: omit_local_variable_types
Rect rect = Rect.fromLTWH(
tooltipLeft,
tooltipTop,
tooltipWidth,
tooltipHeight,
);
if (tooltipData.fitInsideHorizontally) {
if (rect.left < 0) {
final shiftAmount = 0 - rect.left;
rect = Rect.fromLTRB(
rect.left + shiftAmount,
rect.top,
rect.right + shiftAmount,
rect.bottom,
);
}
if (rect.right > viewSize.width) {
final shiftAmount = rect.right - viewSize.width;
rect = Rect.fromLTRB(
rect.left - shiftAmount,
rect.top,
rect.right - shiftAmount,
rect.bottom,
);
}
}
if (tooltipData.fitInsideVertically) {
if (rect.top < 0) {
final shiftAmount = 0 - rect.top;
rect = Rect.fromLTRB(
rect.left,
rect.top + shiftAmount,
rect.right,
rect.bottom + shiftAmount,
);
}
if (rect.bottom > viewSize.height) {
final shiftAmount = rect.bottom - viewSize.height;
rect = Rect.fromLTRB(
rect.left,
rect.top - shiftAmount,
rect.right,
rect.bottom - shiftAmount,
);
}
}
final roundedRect = RRect.fromRectAndCorners(
rect,
topLeft: tooltipData.tooltipBorderRadius.topLeft,
topRight: tooltipData.tooltipBorderRadius.topRight,
bottomLeft: tooltipData.tooltipBorderRadius.bottomLeft,
bottomRight: tooltipData.tooltipBorderRadius.bottomRight,
);
/// set tooltip's background color for each rod
_bgTouchTooltipPaint.color = tooltipData.getTooltipColor(showOnBarGroup);
final rotateAngle = tooltipData.rotateAngle;
final rectRotationOffset =
Offset(0, Utils().calculateRotationOffset(rect.size, rotateAngle).dy);
final rectDrawOffset = Offset(roundedRect.left, roundedRect.top);
final textRotationOffset =
Utils().calculateRotationOffset(tp.size, rotateAngle);
/// draw the texts one by one in below of each other
final top = tooltipData.tooltipPadding.top;
final drawOffset = Offset(
rect.center.dx - (tp.width / 2),
rect.topCenter.dy + top - textRotationOffset.dy + rectRotationOffset.dy,
);
if (tooltipData.tooltipBorder != BorderSide.none) {
_borderTouchTooltipPaint
..color = tooltipData.tooltipBorder.color
..strokeWidth = tooltipData.tooltipBorder.width;
}
final reverseQuarterTurnsAngle = -holder.data.rotationQuarterTurns * 90;
canvasWrapper.drawRotated(
size: rect.size,
rotationOffset: rectRotationOffset,
drawOffset: rectDrawOffset,
angle: reverseQuarterTurnsAngle + rotateAngle,
drawCallback: () {
canvasWrapper
..drawRRect(roundedRect, _bgTouchTooltipPaint)
..drawRRect(roundedRect, _borderTouchTooltipPaint)
..drawText(tp, drawOffset);
},
);
}
@visibleForTesting
void drawStackItemBorderStroke(
CanvasWrapper canvasWrapper,
BarChartRodStackItem stackItem,
int index,
int rodStacksSize,
double barThickSize,
RRect barRRect,
Size drawSize,
PaintHolder<BarChartData> holder,
) {
if (stackItem.borderSide.width == 0 || stackItem.borderSide.color.a == 0) {
return;
}
RRect strokeBarRect;
if (index == 0) {
strokeBarRect = RRect.fromLTRBAndCorners(
barRRect.left,
getPixelY(stackItem.toY, drawSize, holder),
barRRect.right,
getPixelY(stackItem.fromY, drawSize, holder),
bottomLeft:
stackItem.fromY < stackItem.toY ? barRRect.blRadius : Radius.zero,
bottomRight:
stackItem.fromY < stackItem.toY ? barRRect.brRadius : Radius.zero,
topLeft:
stackItem.fromY < stackItem.toY ? Radius.zero : barRRect.tlRadius,
topRight:
stackItem.fromY < stackItem.toY ? Radius.zero : barRRect.trRadius,
);
} else if (index == rodStacksSize - 1) {
strokeBarRect = RRect.fromLTRBAndCorners(
barRRect.left,
max(getPixelY(stackItem.toY, drawSize, holder), barRRect.top),
barRRect.right,
getPixelY(stackItem.fromY, drawSize, holder),
bottomLeft:
stackItem.fromY < stackItem.toY ? Radius.zero : barRRect.blRadius,
bottomRight:
stackItem.fromY < stackItem.toY ? Radius.zero : barRRect.brRadius,
topLeft:
stackItem.fromY < stackItem.toY ? barRRect.tlRadius : Radius.zero,
topRight:
stackItem.fromY < stackItem.toY ? barRRect.trRadius : Radius.zero,
);
} else {
strokeBarRect = RRect.fromLTRBR(
barRRect.left,
getPixelY(stackItem.toY, drawSize, holder),
barRRect.right,
getPixelY(stackItem.fromY, drawSize, holder),
Radius.zero,
);
}
_barStrokePaint
..color = stackItem.borderSide.color
..strokeWidth = min(stackItem.borderSide.width, barThickSize / 2);
canvasWrapper.drawRRect(strokeBarRect, _barStrokePaint);
}
/// Makes a [BarTouchedSpot] based on the provided [localPosition]
///
/// Processes [localPosition] and checks
/// the elements of the chart that are near the offset,
/// then makes a [BarTouchedSpot] from the elements that has been touched.
///
/// Returns null if finds nothing!
BarTouchedSpot? handleTouch(
Offset localPosition,
Size size,
PaintHolder<BarChartData> holder,
) {
final data = holder.data;
final targetData = holder.targetData;
final touchedPoint = localPosition;
if (targetData.barGroups.isEmpty) {
return null;
}
final viewSize = holder.getChartUsableSize(size);
// Check if the touch is outside the canvas bounds
final isZoomed = holder.chartVirtualRect != null;
if (isZoomed && !size.contains(touchedPoint)) {
return null;
}
if (_groupBarsPosition == null) {
final groupsX = data.calculateGroupsX(viewSize.width);
_groupBarsPosition =
calculateGroupAndBarsPosition(viewSize, groupsX, data.barGroups);
}
/// Find the nearest barRod
for (var i = 0; i < _groupBarsPosition!.length; i++) {
final groupBarPos = _groupBarsPosition![i];
for (var j = 0; j < groupBarPos.barsX.length; j++) {
final barX = groupBarPos.barsX[j];
final barWidth = targetData.barGroups[i].barRods[j].width;
final halfBarWidth = barWidth / 2;
double barTopY;
double barBotY;
final isUpward = targetData.barGroups[i].barRods[j].isUpward();
if (isUpward) {
barTopY = getPixelY(
targetData.barGroups[i].barRods[j].toY,
viewSize,
holder,
);
barBotY = getPixelY(
targetData.barGroups[i].barRods[j].fromY +
targetData.barGroups[i].barRods[j].backDrawRodData.fromY,
viewSize,
holder,
);
} else {
barTopY = getPixelY(
targetData.barGroups[i].barRods[j].fromY +
targetData.barGroups[i].barRods[j].backDrawRodData.fromY,
viewSize,
holder,
);
barBotY = getPixelY(
targetData.barGroups[i].barRods[j].toY,
viewSize,
holder,
);
}
final backDrawBarY = getPixelY(
targetData.barGroups[i].barRods[j].backDrawRodData.toY,
viewSize,
holder,
);
final touchExtraThreshold = targetData.barTouchData.touchExtraThreshold;
final isXInTouchBounds = (touchedPoint.dx <=
barX + halfBarWidth + touchExtraThreshold.right) &&
(touchedPoint.dx >= barX - halfBarWidth - touchExtraThreshold.left);
bool isYInBarBounds;
if (isUpward) {
isYInBarBounds =
(touchedPoint.dy <= barBotY + touchExtraThreshold.bottom) &&
(touchedPoint.dy >= barTopY - touchExtraThreshold.top);
} else {
isYInBarBounds =
(touchedPoint.dy >= barTopY - touchExtraThreshold.top) &&
(touchedPoint.dy <= barBotY + touchExtraThreshold.bottom);
}
bool isYInBarBackDrawBounds;
if (isUpward) {
isYInBarBackDrawBounds =
(touchedPoint.dy <= barBotY + touchExtraThreshold.bottom) &&
(touchedPoint.dy >= backDrawBarY - touchExtraThreshold.top);
} else {
isYInBarBackDrawBounds = (touchedPoint.dy >=
barTopY - touchExtraThreshold.top) &&
(touchedPoint.dy <= backDrawBarY + touchExtraThreshold.bottom);
}
final isYInTouchBounds =
(targetData.barTouchData.allowTouchBarBackDraw &&
isYInBarBackDrawBounds) ||
isYInBarBounds;
if (isXInTouchBounds && isYInTouchBounds) {
final nearestGroup = targetData.barGroups[i];
final nearestBarRod = nearestGroup.barRods[j];
final nearestSpot =
FlSpot(nearestGroup.x.toDouble(), nearestBarRod.toY);
final nearestSpotPos =
Offset(barX, getPixelY(nearestSpot.y, viewSize, holder));
var touchedStackIndex = -1;
BarChartRodStackItem? touchedStack;
for (var stackIndex = 0;
stackIndex < nearestBarRod.rodStackItems.length;
stackIndex++) {
final stackItem = nearestBarRod.rodStackItems[stackIndex];
final fromPixel = getPixelY(stackItem.fromY, viewSize, holder);
final toPixel = getPixelY(stackItem.toY, viewSize, holder);
if (touchedPoint.dy <= fromPixel && touchedPoint.dy >= toPixel) {
touchedStackIndex = stackIndex;
touchedStack = stackItem;
break;
}
}
return BarTouchedSpot(
nearestGroup,
i,
nearestBarRod,
j,
touchedStack,
touchedStackIndex,
nearestSpot,
nearestSpotPos,
);
}
}
}
return null;
}
}
@visibleForTesting
class GroupBarsPosition {
GroupBarsPosition(this.groupX, this.barsX);
final double groupX;
final List<double> barsX;
}

View File

@@ -0,0 +1,140 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/bar_chart/bar_chart_painter.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/chart/base/base_chart/render_base_chart.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:flutter/cupertino.dart';
// coverage:ignore-start
/// Low level BarChart Widget.
class BarChartLeaf extends LeafRenderObjectWidget {
const BarChartLeaf({
super.key,
required this.data,
required this.targetData,
required this.canBeScaled,
required this.chartVirtualRect,
});
final BarChartData data;
final BarChartData targetData;
final Rect? chartVirtualRect;
final bool canBeScaled;
@override
RenderBarChart createRenderObject(BuildContext context) => RenderBarChart(
context,
data,
targetData,
MediaQuery.of(context).textScaler,
chartVirtualRect,
canBeScaled: canBeScaled,
);
@override
void updateRenderObject(BuildContext context, RenderBarChart renderObject) {
renderObject
..data = data
..targetData = targetData
..textScaler = MediaQuery.of(context).textScaler
..buildContext = context
..chartVirtualRect = chartVirtualRect
..canBeScaled = canBeScaled;
}
}
// coverage:ignore-end
/// Renders our BarChart, also handles hitTest.
class RenderBarChart extends RenderBaseChart<BarTouchResponse> {
RenderBarChart(
BuildContext context,
BarChartData data,
BarChartData targetData,
TextScaler textScaler,
Rect? chartVirtualRect, {
required bool canBeScaled,
}) : _data = data,
_targetData = targetData,
_textScaler = textScaler,
_chartVirtualRect = chartVirtualRect,
super(targetData.barTouchData, context, canBeScaled: canBeScaled);
BarChartData get data => _data;
BarChartData _data;
set data(BarChartData value) {
if (_data == value) return;
_data = value;
markNeedsPaint();
}
BarChartData get targetData => _targetData;
BarChartData _targetData;
set targetData(BarChartData value) {
if (_targetData == value) return;
_targetData = value;
super.updateBaseTouchData(_targetData.barTouchData);
markNeedsPaint();
}
TextScaler get textScaler => _textScaler;
TextScaler _textScaler;
set textScaler(TextScaler value) {
if (_textScaler == value) return;
_textScaler = value;
markNeedsPaint();
}
Rect? get chartVirtualRect => _chartVirtualRect;
Rect? _chartVirtualRect;
set chartVirtualRect(Rect? value) {
if (_chartVirtualRect == value) return;
_chartVirtualRect = value;
markNeedsPaint();
}
// We couldn't mock [size] property of this class, that's why we have this
@visibleForTesting
Size? mockTestSize;
@visibleForTesting
BarChartPainter painter = BarChartPainter();
PaintHolder<BarChartData> get paintHolder =>
PaintHolder(data, targetData, textScaler, chartVirtualRect);
@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas
..save()
..translate(offset.dx, offset.dy);
painter.paint(
buildContext,
CanvasWrapper(canvas, mockTestSize ?? size),
paintHolder,
);
canvas.restore();
}
@override
BarTouchResponse getResponseAtLocation(Offset localPosition) {
final chartSize = mockTestSize ?? size;
return BarTouchResponse(
touchLocation: localPosition,
touchChartCoordinate: painter.getChartCoordinateFromPixel(
localPosition,
chartSize,
paintHolder,
),
spot: painter.handleTouch(
localPosition,
chartSize,
paintHolder,
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
import 'package:fl_chart/fl_chart.dart';
extension FlSpotListExtension on List<FlSpot> {
/// Splits a line by [FlSpot.nullSpot] values inside it.
List<List<FlSpot>> splitByNullSpots() {
final barList = <List<FlSpot>>[[]];
// handle nullability by splitting off the list into multiple
// separate lists when separated by nulls
for (final spot in this) {
if (spot.isNotNull()) {
barList.last.add(spot);
} else if (barList.last.isNotEmpty) {
barList.add([]);
}
}
// remove last item if one or more last spots were null
if (barList.last.isEmpty) {
barList.removeLast();
}
return barList;
}
}

View File

@@ -0,0 +1,104 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/utils/utils.dart';
import 'package:flutter/material.dart';
class AxisChartHelper {
factory AxisChartHelper() {
return _singleton;
}
AxisChartHelper._internal();
static final _singleton = AxisChartHelper._internal();
/// Iterates over an axis from [min] to [max].
///
/// [interval] determines each step
///
/// If [minIncluded] is true, it starts from [min] value,
/// otherwise it starts from [min] + [interval]
///
/// If [maxIncluded] is true, it ends at [max] value,
/// otherwise it ends at [max] - [interval]
Iterable<double> iterateThroughAxis({
required double min,
bool minIncluded = true,
required double max,
bool maxIncluded = true,
required double baseLine,
required double interval,
}) sync* {
final initialValue = Utils()
.getBestInitialIntervalValue(min, max, interval, baseline: baseLine);
var axisSeek = initialValue;
final firstPositionOverlapsWithMin = axisSeek == min;
if (!minIncluded && firstPositionOverlapsWithMin) {
// If initial value is equal to data minimum,
// move first label one interval further
axisSeek += interval;
}
final diff = max - min;
final count = diff ~/ interval;
final lastPosition = initialValue + (count * interval);
final lastPositionOverlapsWithMax = lastPosition == max;
final end =
!maxIncluded && lastPositionOverlapsWithMax ? max - interval : max;
final epsilon = interval / 100000;
if (minIncluded && !firstPositionOverlapsWithMin) {
// Data minimum shall be included and is not yet covered
yield min;
}
while (axisSeek <= end + epsilon) {
yield axisSeek;
axisSeek += interval;
}
if (maxIncluded && !lastPositionOverlapsWithMax) {
yield max;
}
}
/// Calculate translate offset to keep [SideTitle] child
/// placed inside its corresponding axis.
/// The offset will translate the child to the closest edge inside
/// of the corresponding axis bounding box
Offset calcFitInsideOffset({
required AxisSide axisSide,
required double? childSize,
required double parentAxisSize,
required double axisPosition,
required double distanceFromEdge,
}) {
if (childSize == null) return Offset.zero;
// Find title alignment along its axis
final axisMid = parentAxisSize / 2;
final mainAxisAlignment = (axisPosition - axisMid).isNegative
? MainAxisAlignment.start
: MainAxisAlignment.end;
// Find if child widget overflowed outside the chart
late bool isOverflowed;
if (mainAxisAlignment == MainAxisAlignment.start) {
isOverflowed = (axisPosition - (childSize / 2)).isNegative;
} else {
isOverflowed = (axisPosition + (childSize / 2)) > parentAxisSize;
}
if (isOverflowed == false) return Offset.zero;
// Calc offset if child overflowed
late double offset;
if (mainAxisAlignment == MainAxisAlignment.start) {
offset = (childSize / 2) - axisPosition + distanceFromEdge;
} else {
offset =
-(childSize / 2) + (parentAxisSize - axisPosition) - distanceFromEdge;
}
return switch (axisSide) {
AxisSide.left || AxisSide.right => Offset(0, offset),
AxisSide.top || AxisSide.bottom => Offset(offset, 0),
};
}
}

View File

@@ -0,0 +1,570 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/bar_chart/bar_chart_painter.dart';
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_helper.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/chart/line_chart/line_chart_painter.dart';
import 'package:fl_chart/src/extensions/paint_extension.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:fl_chart/src/utils/utils.dart';
import 'package:flutter/material.dart';
/// This class is responsible to draw the grid behind all axis base charts.
/// also we have two useful function [getPixelX] and [getPixelY] that used
/// in child classes -> [BarChartPainter], [LineChartPainter]
/// [dataList] is the currently showing data (it may produced by an animation using lerp function),
/// [targetData] is the target data, that animation is going to show (if animating)
abstract class AxisChartPainter<D extends AxisChartData>
extends BaseChartPainter<D> {
AxisChartPainter() {
_gridPaint = Paint()..style = PaintingStyle.stroke;
_backgroundPaint = Paint()..style = PaintingStyle.fill;
_rangeAnnotationPaint = Paint()..style = PaintingStyle.fill;
_extraLinesPaint = Paint()..style = PaintingStyle.stroke;
_imagePaint = Paint();
_clipPaint = Paint();
}
late Paint _gridPaint;
late Paint _backgroundPaint;
late Paint _extraLinesPaint;
late Paint _imagePaint;
late Paint _clipPaint;
/// [_rangeAnnotationPaint] draws range annotations;
late Paint _rangeAnnotationPaint;
/// Paints [AxisChartData] into the provided canvas.
@override
void paint(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<D> holder,
) {
super.paint(context, canvasWrapper, holder);
drawBackground(canvasWrapper, holder);
drawRangeAnnotation(canvasWrapper, holder);
drawGrid(canvasWrapper, holder);
}
@visibleForTesting
void drawGrid(CanvasWrapper canvasWrapper, PaintHolder<D> holder) {
final data = holder.data;
if (!data.gridData.show) {
return;
}
final viewSize = canvasWrapper.size;
// Show Vertical Grid
if (data.gridData.drawVerticalLine) {
final verticalInterval = data.gridData.verticalInterval ??
Utils().getEfficientInterval(
viewSize.width,
data.horizontalDiff,
);
final axisValues = AxisChartHelper().iterateThroughAxis(
min: data.minX,
minIncluded: false,
max: data.maxX,
maxIncluded: false,
baseLine: data.baselineX,
interval: verticalInterval,
);
for (final axisValue in axisValues) {
if (!data.gridData.checkToShowVerticalLine(axisValue)) {
continue;
}
final bothX = getPixelX(axisValue, viewSize, holder);
final x1 = bothX;
const y1 = 0.0;
final x2 = bothX;
final y2 = viewSize.height;
final from = Offset(x1, y1);
final to = Offset(x2, y2);
final flLineStyle = data.gridData.getDrawingVerticalLine(axisValue);
_gridPaint
..setColorOrGradientForLine(
flLineStyle.color,
flLineStyle.gradient,
from: from,
to: to,
)
..strokeWidth = flLineStyle.strokeWidth
..transparentIfWidthIsZero();
canvasWrapper.drawDashedLine(
from,
to,
_gridPaint,
flLineStyle.dashArray,
);
}
}
// Show Horizontal Grid
if (data.gridData.drawHorizontalLine) {
final horizontalInterval = data.gridData.horizontalInterval ??
Utils().getEfficientInterval(viewSize.height, data.verticalDiff);
final axisValues = AxisChartHelper().iterateThroughAxis(
min: data.minY,
minIncluded: false,
max: data.maxY,
maxIncluded: false,
baseLine: data.baselineY,
interval: horizontalInterval,
);
for (final axisValue in axisValues) {
if (!data.gridData.checkToShowHorizontalLine(axisValue)) {
continue;
}
final flLine = data.gridData.getDrawingHorizontalLine(axisValue);
final bothY = getPixelY(axisValue, viewSize, holder);
const x1 = 0.0;
final y1 = bothY;
final x2 = viewSize.width;
final y2 = bothY;
final from = Offset(x1, y1);
final to = Offset(x2, y2);
_gridPaint
..setColorOrGradientForLine(
flLine.color,
flLine.gradient,
from: from,
to: to,
)
..strokeWidth = flLine.strokeWidth
..transparentIfWidthIsZero();
canvasWrapper.drawDashedLine(
from,
to,
_gridPaint,
flLine.dashArray,
);
}
}
}
/// This function draws a colored background behind the chart.
@visibleForTesting
void drawBackground(CanvasWrapper canvasWrapper, PaintHolder<D> holder) {
final data = holder.data;
if (data.backgroundColor.a == 0.0) {
return;
}
final viewSize = canvasWrapper.size;
_backgroundPaint.color = data.backgroundColor;
canvasWrapper.drawRect(
Rect.fromLTWH(0, 0, viewSize.width, viewSize.height),
_backgroundPaint,
);
}
@visibleForTesting
void drawRangeAnnotation(CanvasWrapper canvasWrapper, PaintHolder<D> holder) {
final data = holder.data;
final viewSize = canvasWrapper.size;
if (data.rangeAnnotations.verticalRangeAnnotations.isNotEmpty) {
for (final annotation in data.rangeAnnotations.verticalRangeAnnotations) {
final from = Offset(getPixelX(annotation.x1, viewSize, holder), 0);
final to = Offset(
getPixelX(annotation.x2, viewSize, holder),
viewSize.height,
);
final rect = Rect.fromPoints(from, to);
_rangeAnnotationPaint.setColorOrGradient(
annotation.color,
annotation.gradient,
rect,
);
canvasWrapper.drawRect(rect, _rangeAnnotationPaint);
}
}
if (data.rangeAnnotations.horizontalRangeAnnotations.isNotEmpty) {
for (final annotation
in data.rangeAnnotations.horizontalRangeAnnotations) {
final from = Offset(0, getPixelY(annotation.y1, viewSize, holder));
final to = Offset(
viewSize.width,
getPixelY(annotation.y2, viewSize, holder),
);
final rect = Rect.fromPoints(from, to);
_rangeAnnotationPaint.setColorOrGradient(
annotation.color,
annotation.gradient,
rect,
);
canvasWrapper.drawRect(rect, _rangeAnnotationPaint);
}
}
}
void drawExtraLines(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<D> holder,
) {
if (holder.chartVirtualRect != null) {
canvasWrapper.restore();
}
super.paint(context, canvasWrapper, holder);
final data = holder.data;
final viewSize = canvasWrapper.size;
if (data.extraLinesData.horizontalLines.isNotEmpty) {
drawHorizontalLines(context, canvasWrapper, holder, viewSize);
}
if (data.extraLinesData.verticalLines.isNotEmpty) {
drawVerticalLines(context, canvasWrapper, holder, viewSize);
}
if (holder.chartVirtualRect != null) {
canvasWrapper
..saveLayer(
Offset.zero & canvasWrapper.size,
_clipPaint,
)
..clipRect(Offset.zero & canvasWrapper.size);
}
}
void drawHorizontalLines(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<D> holder,
Size viewSize,
) {
for (final line in holder.data.extraLinesData.horizontalLines) {
final from = Offset(0, getPixelY(line.y, viewSize, holder));
final to = Offset(viewSize.width, getPixelY(line.y, viewSize, holder));
final isLineOutsideOfChart = from.dy < 0 ||
to.dy < 0 ||
from.dy > viewSize.height ||
to.dy > viewSize.height;
if (!isLineOutsideOfChart) {
_extraLinesPaint
..setColorOrGradientForLine(
line.color,
line.gradient,
from: from,
to: to,
)
..strokeWidth = line.strokeWidth
..transparentIfWidthIsZero()
..strokeCap = line.strokeCap;
canvasWrapper.drawDashedLine(
from,
to,
_extraLinesPaint,
line.dashArray,
);
if (line.sizedPicture != null) {
final centerX = line.sizedPicture!.width / 2;
final centerY = line.sizedPicture!.height / 2;
final xPosition = centerX;
final yPosition = to.dy - centerY;
canvasWrapper
..save()
..translate(xPosition, yPosition)
..drawPicture(line.sizedPicture!.picture)
..restore();
}
if (line.image != null) {
final centerX = line.image!.width / 2;
final centerY = line.image!.height / 2;
final centeredImageOffset = Offset(centerX, to.dy - centerY);
canvasWrapper.drawImage(
line.image!,
centeredImageOffset,
_imagePaint,
);
}
if (line.label.show) {
final label = line.label;
final style =
TextStyle(fontSize: 11, color: line.color).merge(label.style);
final padding = label.padding as EdgeInsets;
final span = TextSpan(
text: label.labelResolver(line),
style: Utils().getThemeAwareTextStyle(context, style),
);
final tp = TextPainter(
text: span,
textDirection: TextDirection.ltr,
)..layout();
switch (label.direction) {
case LabelDirection.horizontal:
canvasWrapper.drawText(
tp,
label.alignment.withinRect(
Rect.fromLTRB(
from.dx + padding.left,
from.dy - padding.bottom - tp.height,
to.dx - padding.right - tp.width,
to.dy + padding.top,
),
),
);
case LabelDirection.vertical:
canvasWrapper.drawVerticalText(
tp,
label.alignment.withinRect(
Rect.fromLTRB(
from.dx + padding.left + tp.height,
from.dy - padding.bottom - tp.width,
to.dx - padding.right,
to.dy + padding.top,
),
),
);
}
}
}
}
}
void drawVerticalLines(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<D> holder,
Size viewSize,
) {
for (final line in holder.data.extraLinesData.verticalLines) {
final from = Offset(getPixelX(line.x, viewSize, holder), 0);
final to = Offset(getPixelX(line.x, viewSize, holder), viewSize.height);
final isLineOutsideOfChart = from.dx < 0 ||
to.dx < 0 ||
from.dx > viewSize.width ||
to.dx > viewSize.width;
if (!isLineOutsideOfChart) {
_extraLinesPaint
..setColorOrGradientForLine(
line.color,
line.gradient,
from: from,
to: to,
)
..strokeWidth = line.strokeWidth
..transparentIfWidthIsZero()
..strokeCap = line.strokeCap;
canvasWrapper.drawDashedLine(
from,
to,
_extraLinesPaint,
line.dashArray,
);
if (line.sizedPicture != null) {
final centerX = line.sizedPicture!.width / 2;
final centerY = line.sizedPicture!.height / 2;
final xPosition = to.dx - centerX;
final yPosition = viewSize.height - centerY;
canvasWrapper
..save()
..translate(xPosition, yPosition)
..drawPicture(line.sizedPicture!.picture)
..restore();
}
if (line.image != null) {
final centerX = line.image!.width / 2;
final centerY = line.image!.height + 2;
final centeredImageOffset =
Offset(to.dx - centerX, viewSize.height - centerY);
canvasWrapper.drawImage(
line.image!,
centeredImageOffset,
_imagePaint,
);
}
if (line.label.show) {
final label = line.label;
final style =
TextStyle(fontSize: 11, color: line.color).merge(label.style);
final padding = label.padding as EdgeInsets;
final span = TextSpan(
text: label.labelResolver(line),
style: Utils().getThemeAwareTextStyle(context, style),
);
final tp = TextPainter(
text: span,
textDirection: TextDirection.ltr,
)..layout();
switch (label.direction) {
case LabelDirection.horizontal:
canvasWrapper.drawText(
tp,
label.alignment.withinRect(
Rect.fromLTRB(
from.dx - padding.right - tp.width,
from.dy + padding.top,
to.dx + padding.left,
to.dy - padding.bottom - tp.height,
),
),
);
case LabelDirection.vertical:
canvasWrapper.drawVerticalText(
tp,
label.alignment.withinRect(
Rect.fromLTRB(
from.dx - padding.right,
from.dy + padding.top,
to.dx + padding.left + tp.height,
to.dy - padding.bottom - tp.width,
),
),
);
}
}
}
}
}
/// With this function we can convert our [FlSpot] x
/// to the view base axis x .
/// the view 0, 0 is on the top/left, but the spots is bottom/left
double getPixelX(
double spotX,
Size viewSize,
PaintHolder<D> holder,
) {
final usableSize = holder.getChartUsableSize(viewSize);
final pixelXUnadjusted = _getPixelX(spotX, holder.data, usableSize);
// Adjust the position relative to the canvas if chartVirtualRect
// is provided
final adjustment = holder.chartVirtualRect?.left ?? 0;
return pixelXUnadjusted + adjustment;
}
double _getPixelX(double spotX, D data, Size usableSize) {
final deltaX = data.maxX - data.minX;
if (deltaX == 0.0) {
return 0;
}
return ((spotX - data.minX) / deltaX) * usableSize.width;
}
/// With this function we can convert our [FlSpot] y
/// to the view base axis y.
double getPixelY(
double spotY,
Size viewSize,
PaintHolder<D> holder,
) {
final usableSize = holder.getChartUsableSize(viewSize);
final pixelYUnadjusted = _getPixelY(spotY, holder.data, usableSize);
// Adjust the position relative to the canvas if chartVirtualRect
// is provided
final adjustment = holder.chartVirtualRect?.top ?? 0;
return pixelYUnadjusted + adjustment;
}
double _getPixelY(double spotY, D data, Size usableSize) {
final deltaY = data.maxY - data.minY;
if (deltaY == 0.0) {
return usableSize.height;
}
return usableSize.height -
(((spotY - data.minY) / deltaY) * usableSize.height);
}
/// Converts pixel X position to axis X coordinates
double getXForPixel(
double pixelX,
Size viewSize,
PaintHolder<D> holder,
) {
final usableSize = holder.getChartUsableSize(viewSize);
final adjustment = holder.chartVirtualRect?.left ?? 0;
final unadjustedPixelX = pixelX - adjustment;
final deltaX = holder.data.maxX - holder.data.minX;
if (deltaX == 0.0) return holder.data.minX;
return (unadjustedPixelX / usableSize.width) * deltaX + holder.data.minX;
}
/// Converts pixel Y position to axis Y coordinates
double getYForPixel(
double pixelY,
Size viewSize,
PaintHolder<D> holder,
) {
final usableSize = holder.getChartUsableSize(viewSize);
final adjustment = holder.chartVirtualRect?.top ?? 0;
final unadjustedPixelY = pixelY - adjustment;
final deltaY = holder.data.maxY - holder.data.minY;
if (deltaY == 0.0) return holder.data.minY;
return holder.data.maxY - (unadjustedPixelY / usableSize.height) * deltaY;
}
/// Converts pixel coordinates to chart coordinates
Offset getChartCoordinateFromPixel(
Offset pixelOffset,
Size viewSize,
PaintHolder<D> holder,
) =>
Offset(
getXForPixel(pixelOffset.dx, viewSize, holder),
getYForPixel(pixelOffset.dy, viewSize, holder),
);
/// With this function we can get horizontal
/// position for the tooltip.
double getTooltipLeft(
double dx,
double tooltipWidth,
FLHorizontalAlignment tooltipHorizontalAlignment,
double tooltipHorizontalOffset,
) =>
switch (tooltipHorizontalAlignment) {
FLHorizontalAlignment.center =>
dx - (tooltipWidth / 2) + tooltipHorizontalOffset,
FLHorizontalAlignment.right => dx + tooltipHorizontalOffset,
FLHorizontalAlignment.left =>
dx - tooltipWidth + tooltipHorizontalOffset,
};
}

View File

@@ -0,0 +1,324 @@
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_data.dart';
import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart';
import 'package:fl_chart/src/chart/base/axis_chart/side_titles/side_titles_widget.dart';
import 'package:fl_chart/src/chart/base/axis_chart/transformation_config.dart';
import 'package:fl_chart/src/chart/base/custom_interactive_viewer.dart';
import 'package:fl_chart/src/extensions/fl_titles_data_extension.dart';
import 'package:flutter/material.dart';
/// A builder to build a chart.
///
/// The [chartVirtualRect] is the virtual chart virtual rect to be used when
/// laying out the chart's content. It is transformed based on users'
/// interactions like scaling and panning.
typedef ChartBuilder = Widget Function(
BuildContext context,
Rect? chartVirtualRect,
);
/// A scaffold to show a scalable axis-based chart
///
/// It contains some placeholders to represent an axis-based chart.
///
/// It's something like the below graph:
/// |----------------------|
/// | | top | |
/// |------|-------|-------|
/// | left | chart | right |
/// |------|-------|-------|
/// | | bottom| |
/// |----------------------|
///
/// `left`, `top`, `right`, `bottom` are some place holders to show titles
/// provided by [AxisChartData.titlesData] around the chart
/// `chart` is a centered place holder to show a raw chart. The chart is
/// built using [chartBuilder].
class AxisChartScaffoldWidget extends StatefulWidget {
const AxisChartScaffoldWidget({
super.key,
required this.chartBuilder,
required this.data,
this.transformationConfig = const FlTransformationConfig(),
});
/// The builder to build the chart.
final ChartBuilder chartBuilder;
/// The data to build the chart.
final AxisChartData data;
/// {@template fl_chart.AxisChartScaffoldWidget.transformationConfig}
/// The transformation configuration of the chart.
///
/// Used to configure scaling and panning of the chart.
/// {@endtemplate}
final FlTransformationConfig transformationConfig;
@override
State<AxisChartScaffoldWidget> createState() =>
_AxisChartScaffoldWidgetState();
}
class _AxisChartScaffoldWidgetState extends State<AxisChartScaffoldWidget> {
late TransformationController _transformationController;
final _chartKey = GlobalKey();
FlTransformationConfig get _transformationConfig =>
widget.transformationConfig;
bool get _canScaleHorizontally =>
_transformationConfig.scaleAxis == FlScaleAxis.horizontal ||
_transformationConfig.scaleAxis == FlScaleAxis.free;
bool get _canScaleVertically =>
_transformationConfig.scaleAxis == FlScaleAxis.vertical ||
_transformationConfig.scaleAxis == FlScaleAxis.free;
@override
void initState() {
super.initState();
_transformationController =
_transformationConfig.transformationController ??
TransformationController();
_transformationController.addListener(_transformationControllerListener);
}
@override
void dispose() {
_transformationController.removeListener(_transformationControllerListener);
if (_transformationConfig.transformationController == null) {
_transformationController.dispose();
}
super.dispose();
}
@override
void didUpdateWidget(AxisChartScaffoldWidget oldWidget) {
super.didUpdateWidget(oldWidget);
switch ((
oldWidget.transformationConfig.transformationController,
widget.transformationConfig.transformationController
)) {
case (null, null):
break;
case (null, TransformationController()):
_transformationController.dispose();
_transformationController =
widget.transformationConfig.transformationController!;
_transformationController
.addListener(_transformationControllerListener);
case (TransformationController(), null):
_transformationController
.removeListener(_transformationControllerListener);
_transformationController = TransformationController();
_transformationController
.addListener(_transformationControllerListener);
case (TransformationController(), TransformationController()):
if (oldWidget.transformationConfig.transformationController !=
widget.transformationConfig.transformationController) {
_transformationController
.removeListener(_transformationControllerListener);
_transformationController =
widget.transformationConfig.transformationController!;
_transformationController
.addListener(_transformationControllerListener);
}
}
}
void _transformationControllerListener() {
setState(() {});
}
// Applies the inverse transformation to the chart to get the zoomed
// bounding box.
//
// The transformation matrix is inverted because the bounding box needs to
// grow beyond the chart's boundaries when the chart is scaled in order
// for its content to be laid out on the larger area. This leads to the
// scaling effect.
Rect? _calculateAdjustedRect(Rect rect) {
final scale = _transformationController.value.getMaxScaleOnAxis();
if (scale == 1.0) {
return null;
}
final inverseMatrix = Matrix4.inverted(_transformationController.value);
final chartVirtualQuad = CustomInteractiveViewer.transformViewport(
inverseMatrix,
rect,
);
final chartVirtualRect = CustomInteractiveViewer.axisAlignedBoundingBox(
chartVirtualQuad,
);
return Rect.fromLTWH(
_canScaleHorizontally ? chartVirtualRect.left : rect.left,
_canScaleVertically ? chartVirtualRect.top : rect.top,
_canScaleHorizontally ? chartVirtualRect.width : rect.width,
_canScaleVertically ? chartVirtualRect.height : rect.height,
);
}
bool get showLeftTitles {
if (!widget.data.titlesData.show) {
return false;
}
final showAxisTitles = widget.data.titlesData.leftTitles.showAxisTitles;
final showSideTitles = widget.data.titlesData.leftTitles.showSideTitles;
return showAxisTitles || showSideTitles;
}
bool get showRightTitles {
if (!widget.data.titlesData.show) {
return false;
}
final showAxisTitles = widget.data.titlesData.rightTitles.showAxisTitles;
final showSideTitles = widget.data.titlesData.rightTitles.showSideTitles;
return showAxisTitles || showSideTitles;
}
bool get showTopTitles {
if (!widget.data.titlesData.show) {
return false;
}
final showAxisTitles = widget.data.titlesData.topTitles.showAxisTitles;
final showSideTitles = widget.data.titlesData.topTitles.showSideTitles;
return showAxisTitles || showSideTitles;
}
bool get showBottomTitles {
if (!widget.data.titlesData.show) {
return false;
}
final showAxisTitles = widget.data.titlesData.bottomTitles.showAxisTitles;
final showSideTitles = widget.data.titlesData.bottomTitles.showSideTitles;
return showAxisTitles || showSideTitles;
}
List<Widget> _stackWidgets(BoxConstraints constraints) {
final margin = widget.data.titlesData.allSidesPadding;
final borderData = widget.data.borderData.isVisible()
? widget.data.borderData.border
: null;
final borderWidth =
borderData == null ? 0 : borderData.dimensions.horizontal;
final borderHeight =
borderData == null ? 0 : borderData.dimensions.vertical;
final rect = Rect.fromLTRB(
0,
0,
constraints.maxWidth - margin.horizontal - borderWidth,
constraints.maxHeight - margin.vertical - borderHeight,
);
final adjustedRect = _calculateAdjustedRect(rect);
final virtualRect = switch (_transformationConfig.scaleAxis) {
FlScaleAxis.none => null,
FlScaleAxis() => adjustedRect,
};
final chart = KeyedSubtree(
key: _chartKey,
child: widget.chartBuilder(context, virtualRect),
);
final child = switch (_transformationConfig.scaleAxis) {
FlScaleAxis.none => chart,
FlScaleAxis() => CustomInteractiveViewer(
transformationController: _transformationController,
clipBehavior: Clip.none,
trackpadScrollCausesScale:
_transformationConfig.trackpadScrollCausesScale,
maxScale: _transformationConfig.maxScale,
minScale: _transformationConfig.minScale,
panEnabled: _transformationConfig.panEnabled,
scaleEnabled: _transformationConfig.scaleEnabled,
child: SizedBox(
width: rect.width,
height: rect.height,
child: chart,
),
),
};
final widgets = <Widget>[
Container(
margin: margin,
decoration: BoxDecoration(border: borderData),
child: child,
),
];
int insertIndex(bool drawBelow) => drawBelow ? 0 : widgets.length;
if (showLeftTitles) {
widgets.insert(
insertIndex(widget.data.titlesData.leftTitles.drawBelowEverything),
SideTitlesWidget(
side: AxisSide.left,
axisChartData: widget.data,
parentSize: constraints.biggest,
chartVirtualRect: adjustedRect,
),
);
}
if (showTopTitles) {
widgets.insert(
insertIndex(widget.data.titlesData.topTitles.drawBelowEverything),
SideTitlesWidget(
side: AxisSide.top,
axisChartData: widget.data,
parentSize: constraints.biggest,
chartVirtualRect: adjustedRect,
),
);
}
if (showRightTitles) {
widgets.insert(
insertIndex(widget.data.titlesData.rightTitles.drawBelowEverything),
SideTitlesWidget(
side: AxisSide.right,
axisChartData: widget.data,
parentSize: constraints.biggest,
chartVirtualRect: adjustedRect,
),
);
}
if (showBottomTitles) {
widgets.insert(
insertIndex(widget.data.titlesData.bottomTitles.drawBelowEverything),
SideTitlesWidget(
side: AxisSide.bottom,
axisChartData: widget.data,
parentSize: constraints.biggest,
chartVirtualRect: adjustedRect,
),
);
}
return widgets;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return RotatedBox(
quarterTurns: widget.data.rotationQuarterTurns,
child: Stack(
children: _stackWidgets(constraints),
),
);
},
);
}
}

View File

@@ -0,0 +1,134 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// Wraps a [child] widget and applies some default behaviours
///
/// Recommended to be used in [SideTitles.getTitlesWidget]
/// You need to pass [axisSide] value that provided by [TitleMeta]
/// It forces the widget to be close to the chart.
/// It also applies a [space] to the chart.
/// You can also fill [angle] in radians if you need to rotate your widget.
/// To force widget to be positioned within its axis bounding box,
/// define [fitInside] by passing [SideTitleFitInsideData]
class SideTitleWidget extends StatefulWidget {
const SideTitleWidget({
super.key,
required this.child,
required this.meta,
this.space = 8.0,
this.angle = 0.0,
this.fitInside = const SideTitleFitInsideData(
enabled: false,
distanceFromEdge: 0,
parentAxisSize: 0,
axisPosition: 0,
),
});
final TitleMeta meta;
final double space;
final Widget child;
final double angle;
/// Define fitInside options with [SideTitleFitInsideData]
///
/// To makes things simpler, it's recommended to use
/// [SideTitleFitInsideData.fromTitleMeta] and pass the
/// TitleMeta provided from [SideTitles.getTitlesWidget]
///
/// If [fitInside.enabled] is true, the widget will be placed
/// inside the parent axis bounding box.
///
/// Some translations will be applied to force
/// children to be positioned inside the parent axis bounding box.
///
/// Will override the [SideTitleWidget.space] and caused
/// spacing between [SideTitles] children might be not equal.
final SideTitleFitInsideData fitInside;
@override
State<SideTitleWidget> createState() => _SideTitleWidgetState();
}
class _SideTitleWidgetState extends State<SideTitleWidget> {
Alignment _getAlignment() => switch (widget.meta.axisSide) {
AxisSide.left => Alignment.centerRight,
AxisSide.top => Alignment.bottomCenter,
AxisSide.right => Alignment.centerLeft,
AxisSide.bottom => Alignment.topCenter,
};
EdgeInsets _getMargin() => switch (widget.meta.axisSide) {
AxisSide.left => EdgeInsets.only(right: widget.space),
AxisSide.top => EdgeInsets.only(bottom: widget.space),
AxisSide.right => EdgeInsets.only(left: widget.space),
AxisSide.bottom => EdgeInsets.only(top: widget.space),
};
/// Calculate child width/height
final GlobalKey widgetKey = GlobalKey();
double? _childSize;
void _getChildSize(_) {
// If fitInside is false, no need to find child size
if (!widget.fitInside.enabled) return;
// If childSize is not null, no need to find the size anymore
if (_childSize != null) return;
final context = widgetKey.currentContext;
if (context == null) return;
// Set size based on its axis side
final size = switch (widget.meta.axisSide) {
AxisSide.left || AxisSide.right => context.size?.height ?? 0,
AxisSide.top || AxisSide.bottom => context.size?.width ?? 0,
};
// If childSize is the same, no need to set new value
if (_childSize == size) return;
setState(() => _childSize = size);
}
@override
void initState() {
super.initState();
SchedulerBinding.instance.addPostFrameCallback(_getChildSize);
}
@override
void didUpdateWidget(covariant SideTitleWidget oldWidget) {
super.didUpdateWidget(oldWidget);
SchedulerBinding.instance.addPostFrameCallback(_getChildSize);
}
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: !widget.fitInside.enabled
? Offset.zero
: AxisChartHelper().calcFitInsideOffset(
axisSide: widget.meta.axisSide,
childSize: _childSize,
parentAxisSize: widget.fitInside.parentAxisSize,
axisPosition: widget.fitInside.axisPosition,
distanceFromEdge: widget.fitInside.distanceFromEdge,
),
child: Transform.rotate(
angle: widget.angle,
child: Container(
key: widgetKey,
margin: _getMargin(),
alignment: _getAlignment(),
child: RotatedBox(
quarterTurns: -widget.meta.rotationQuarterTurns,
child: widget.child,
),
),
),
);
}
}

View File

@@ -0,0 +1,20 @@
enum FlScaleAxis {
/// Scales the horizontal axis.
horizontal,
/// Scales the vertical axis.
vertical,
/// Scales both the horizontal and vertical axes.
free,
/// Does not scale the axes.
none;
/// Axes that allow scaling.
static const scalingEnabledAxis = [
free,
horizontal,
vertical,
];
}

View File

@@ -0,0 +1,298 @@
import 'dart:math' as math;
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
/// Inspired from [Flex]
class SideTitlesFlex extends MultiChildRenderObjectWidget {
SideTitlesFlex({
super.key,
required this.direction,
required this.axisSideMetaData,
List<AxisSideTitleWidgetHolder> widgetHolders =
const <AxisSideTitleWidgetHolder>[],
}) : axisSideTitlesMetaData = widgetHolders.map((e) => e.metaData).toList(),
super(children: widgetHolders.map((e) => e.widget).toList());
final Axis direction;
final AxisSideMetaData axisSideMetaData;
final List<AxisSideTitleMetaData> axisSideTitlesMetaData;
@override
AxisSideTitlesRenderFlex createRenderObject(BuildContext context) {
return AxisSideTitlesRenderFlex(
direction: direction,
axisSideMetaData: axisSideMetaData,
axisSideTitlesMetaData: axisSideTitlesMetaData,
);
}
@override
void updateRenderObject(
BuildContext context,
covariant AxisSideTitlesRenderFlex renderObject,
) {
renderObject
..direction = direction
..axisSideMetaData = axisSideMetaData
..axisSideTitlesMetaData = axisSideTitlesMetaData;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<Axis>('direction', direction));
}
}
class AxisSideTitlesRenderFlex extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, FlexParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, FlexParentData>,
DebugOverflowIndicatorMixin {
AxisSideTitlesRenderFlex({
Axis direction = Axis.horizontal,
required AxisSideMetaData axisSideMetaData,
required List<AxisSideTitleMetaData> axisSideTitlesMetaData,
}) : _direction = direction,
_axisSideMetaData = axisSideMetaData,
_axisSideTitlesMetaData = axisSideTitlesMetaData;
Axis get direction => _direction;
Axis _direction;
set direction(Axis value) {
if (_direction != value) {
_direction = value;
markNeedsLayout();
}
}
AxisSideMetaData get axisSideMetaData => _axisSideMetaData;
AxisSideMetaData _axisSideMetaData;
set axisSideMetaData(AxisSideMetaData value) {
if (_axisSideMetaData != value) {
_axisSideMetaData = value;
markNeedsLayout();
}
}
List<AxisSideTitleMetaData> get axisSideTitlesMetaData =>
_axisSideTitlesMetaData;
List<AxisSideTitleMetaData> _axisSideTitlesMetaData;
set axisSideTitlesMetaData(List<AxisSideTitleMetaData> value) {
if (_axisSideTitlesMetaData != value) {
_axisSideTitlesMetaData = value;
markNeedsLayout();
}
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! FlexParentData) {
child.parentData = FlexParentData();
}
}
@override
bool get debugNeedsLayout => false;
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
if (_direction == Axis.horizontal) {
return defaultComputeDistanceToHighestActualBaseline(baseline);
}
return defaultComputeDistanceToFirstActualBaseline(baseline);
}
double _getCrossSize(Size size) {
switch (_direction) {
case Axis.horizontal:
return size.height;
case Axis.vertical:
return size.width;
}
}
double _getMainSize(Size size) {
switch (_direction) {
case Axis.horizontal:
return size.width;
case Axis.vertical:
return size.height;
}
}
@override
Size computeDryLayout(BoxConstraints constraints) {
final sizes = _computeSizes(
layoutChild: ChildLayoutHelper.dryLayoutChild,
constraints: constraints,
);
switch (_direction) {
case Axis.horizontal:
return constraints.constrain(Size(sizes.mainSize, sizes.crossSize));
case Axis.vertical:
return constraints.constrain(Size(sizes.crossSize, sizes.mainSize));
}
}
_LayoutSizes _computeSizes({
required BoxConstraints constraints,
required ChildLayouter layoutChild,
}) {
// Determine used flex factor, size inflexible items, calculate free space.
final maxMainSize = _direction == Axis.horizontal
? constraints.maxWidth
: constraints.maxHeight;
final canFlex = maxMainSize < double.infinity;
var crossSize = 0.0;
var allocatedSize = 0.0; // Sum of the sizes of the non-flexible children.
var child = firstChild;
while (child != null) {
final childParentData = child.parentData! as FlexParentData;
// Stretch
final innerConstraints = switch (_direction) {
Axis.horizontal => BoxConstraints.tightFor(
height: constraints.maxHeight,
),
Axis.vertical => BoxConstraints.tightFor(
width: constraints.maxWidth,
),
};
final childSize = layoutChild(child, innerConstraints);
allocatedSize += _getMainSize(childSize);
crossSize = math.max(crossSize, _getCrossSize(childSize));
assert(child.parentData == childParentData);
child = childParentData.nextSibling;
}
final idealSize = canFlex ? maxMainSize : allocatedSize;
return _LayoutSizes(
mainSize: idealSize,
crossSize: crossSize,
allocatedSize: allocatedSize,
);
}
@override
void performLayout() {
final constraints = this.constraints;
final sizes = _computeSizes(
layoutChild: ChildLayoutHelper.layoutChild,
constraints: constraints,
);
var actualSize = sizes.mainSize;
var crossSize = sizes.crossSize;
// Align items along the main axis.
switch (_direction) {
case Axis.horizontal:
size = constraints.constrain(Size(actualSize, crossSize));
actualSize = size.width;
crossSize = size.height;
case Axis.vertical:
size = constraints.constrain(Size(crossSize, actualSize));
actualSize = size.height;
crossSize = size.width;
}
// Position elements
var child = firstChild;
var counter = 0;
while (child != null) {
final childParentData = child.parentData! as FlexParentData;
final metaData = _axisSideTitlesMetaData[counter];
final double childCrossPosition;
// Stretch
childCrossPosition = 0.0;
final childMainPosition =
metaData.axisPixelLocation - (_getMainSize(child.size) / 2);
childParentData.offset = switch (_direction) {
Axis.horizontal => Offset(childMainPosition, childCrossPosition),
Axis.vertical => Offset(childCrossPosition, childMainPosition),
};
child = childParentData.nextSibling;
counter++;
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
@override
void paint(PaintingContext context, Offset offset) {
// There's no point in drawing the children if we're empty.
if (size.isEmpty) {
return;
}
_clipRectLayer.layer = null;
defaultPaint(context, offset);
}
final LayerHandle<ClipRectLayer> _clipRectLayer =
LayerHandle<ClipRectLayer>();
@override
void dispose() {
_clipRectLayer.layer = null;
super.dispose();
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<Axis>('direction', direction));
}
}
class _LayoutSizes {
const _LayoutSizes({
required this.mainSize,
required this.crossSize,
required this.allocatedSize,
});
final double mainSize;
final double crossSize;
final double allocatedSize;
}
class AxisSideMetaData {
AxisSideMetaData(this.minValue, this.maxValue, this.axisViewSize);
final double minValue;
final double maxValue;
final double axisViewSize;
double get diff => maxValue - minValue;
}
class AxisSideTitleMetaData with EquatableMixin {
AxisSideTitleMetaData(this.axisValue, this.axisPixelLocation);
final double axisValue;
final double axisPixelLocation;
@override
List<Object?> get props => [
axisValue,
axisPixelLocation,
];
}
class AxisSideTitleWidgetHolder {
AxisSideTitleWidgetHolder(this.metaData, this.widget);
final AxisSideTitleMetaData metaData;
final Widget widget;
}

View File

@@ -0,0 +1,320 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_helper.dart';
import 'package:fl_chart/src/chart/base/axis_chart/side_titles/side_titles_flex.dart';
import 'package:fl_chart/src/extensions/bar_chart_data_extension.dart';
import 'package:fl_chart/src/extensions/edge_insets_extension.dart';
import 'package:fl_chart/src/extensions/fl_border_data_extension.dart';
import 'package:fl_chart/src/extensions/fl_titles_data_extension.dart';
import 'package:fl_chart/src/extensions/size_extension.dart';
import 'package:fl_chart/src/utils/utils.dart';
import 'package:flutter/material.dart';
class SideTitlesWidget extends StatefulWidget {
const SideTitlesWidget({
super.key,
required this.side,
required this.axisChartData,
required this.parentSize,
this.chartVirtualRect,
});
final AxisSide side;
final AxisChartData axisChartData;
final Size parentSize;
final Rect? chartVirtualRect;
@override
State<SideTitlesWidget> createState() => _SideTitlesWidgetState();
}
class _SideTitlesWidgetState extends State<SideTitlesWidget> {
bool get isHorizontal =>
widget.side == AxisSide.top || widget.side == AxisSide.bottom;
bool get isVertical => !isHorizontal;
double get minX => widget.axisChartData.minX;
double get maxX => widget.axisChartData.maxX;
double get baselineX => widget.axisChartData.baselineX;
double get minY => widget.axisChartData.minY;
double get maxY => widget.axisChartData.maxY;
double get baselineY => widget.axisChartData.baselineY;
double get axisMin => isHorizontal ? minX : minY;
double get axisMax => isHorizontal ? maxX : maxY;
double get axisBaseLine => isHorizontal ? baselineX : baselineY;
FlTitlesData get titlesData => widget.axisChartData.titlesData;
bool get isLeftOrTop =>
widget.side == AxisSide.left || widget.side == AxisSide.top;
bool get isRightOrBottom =>
widget.side == AxisSide.right || widget.side == AxisSide.bottom;
AxisTitles get axisTitles => switch (widget.side) {
AxisSide.left => titlesData.leftTitles,
AxisSide.top => titlesData.topTitles,
AxisSide.right => titlesData.rightTitles,
AxisSide.bottom => titlesData.bottomTitles,
};
SideTitles get sideTitles => axisTitles.sideTitles;
Axis get direction => isHorizontal ? Axis.horizontal : Axis.vertical;
Axis get counterDirection => isHorizontal ? Axis.vertical : Axis.horizontal;
Alignment get alignment => switch (widget.side) {
AxisSide.left => Alignment.centerLeft,
AxisSide.top => Alignment.topCenter,
AxisSide.right => Alignment.centerRight,
AxisSide.bottom => Alignment.bottomCenter,
};
EdgeInsets get thisSidePadding {
final titlesPadding = titlesData.allSidesPadding;
final borderPadding = widget.axisChartData.borderData.allSidesPadding;
return switch (widget.side) {
AxisSide.right ||
AxisSide.left =>
titlesPadding.onlyTopBottom + borderPadding.onlyTopBottom,
AxisSide.top ||
AxisSide.bottom =>
titlesPadding.onlyLeftRight + borderPadding.onlyLeftRight,
};
}
double get thisSidePaddingTotal {
final borderPadding = widget.axisChartData.borderData.allSidesPadding;
final titlesPadding = titlesData.allSidesPadding;
return switch (widget.side) {
AxisSide.right ||
AxisSide.left =>
titlesPadding.vertical + borderPadding.vertical,
AxisSide.top ||
AxisSide.bottom =>
titlesPadding.horizontal + borderPadding.horizontal,
};
}
Size get viewSize {
late Size size;
final chartVirtualRect = widget.chartVirtualRect;
if (chartVirtualRect == null) {
size = widget.parentSize;
} else {
size = chartVirtualRect.size +
Offset(thisSidePaddingTotal, thisSidePaddingTotal);
}
return size.rotateByQuarterTurns(
widget.axisChartData.rotationQuarterTurns,
);
}
double get axisOffset {
final chartVirtualRect = widget.chartVirtualRect;
if (chartVirtualRect == null) {
return 0;
}
return switch (widget.side) {
AxisSide.left || AxisSide.right => chartVirtualRect.top,
AxisSide.top || AxisSide.bottom => chartVirtualRect.left,
};
}
List<AxisSideTitleWidgetHolder> makeWidgets(
double axisViewSize,
double axisMin,
double axisMax,
AxisSide side,
) {
List<AxisSideTitleMetaData> axisPositions;
final interval = sideTitles.interval ??
Utils().getEfficientInterval(
axisViewSize,
axisMax - axisMin,
);
if (isHorizontal && widget.axisChartData is BarChartData) {
final barChartData = widget.axisChartData as BarChartData;
if (barChartData.barGroups.isEmpty) {
return [];
}
final xLocations = barChartData.calculateGroupsX(axisViewSize);
axisPositions = xLocations.asMap().entries.map((e) {
final index = e.key;
final xLocation = e.value;
final xValue = barChartData.barGroups[index].x;
final adjustedLocation = xLocation + axisOffset;
return AxisSideTitleMetaData(xValue.toDouble(), adjustedLocation);
}).toList();
} else {
final axisValues = AxisChartHelper().iterateThroughAxis(
min: axisMin,
max: axisMax,
minIncluded: sideTitles.minIncluded,
maxIncluded: sideTitles.maxIncluded,
baseLine: axisBaseLine,
interval: interval,
);
axisPositions = axisValues.map((axisValue) {
final axisDiff = axisMax - axisMin;
var portion = 0.0;
if (axisDiff > 0) {
portion = (axisValue - axisMin) / axisDiff;
}
if (isVertical) {
portion = 1 - portion;
}
final axisLocation = portion * axisViewSize + axisOffset;
return AxisSideTitleMetaData(axisValue, axisLocation);
}).toList();
}
axisPositions = _getPositionsWithinChartRange(axisPositions, side);
return axisPositions.map(
(metaData) {
return AxisSideTitleWidgetHolder(
metaData,
sideTitles.getTitlesWidget(
metaData.axisValue,
TitleMeta(
min: axisMin,
max: axisMax,
appliedInterval: interval,
sideTitles: sideTitles,
formattedValue: Utils().formatNumber(
axisMin,
axisMax,
metaData.axisValue,
),
axisSide: side,
parentAxisSize: axisViewSize,
axisPosition: metaData.axisPixelLocation,
rotationQuarterTurns: widget.axisChartData.rotationQuarterTurns,
),
),
);
},
).toList();
}
List<AxisSideTitleMetaData> _getPositionsWithinChartRange(
List<AxisSideTitleMetaData> axisPositions,
AxisSide side,
) {
final chartSize = Size(
widget.parentSize.width - thisSidePaddingTotal,
widget.parentSize.height - thisSidePaddingTotal,
).rotateByQuarterTurns(widget.axisChartData.rotationQuarterTurns);
// Add 1 pixel to the chart's edges to avoid clipping the last title.
final chartRect = (Offset.zero & chartSize).inflate(1);
return axisPositions.where((metaData) {
final location = metaData.axisPixelLocation;
return switch (side) {
AxisSide.left ||
AxisSide.right =>
chartRect.contains(Offset(0, location)),
AxisSide.top ||
AxisSide.bottom =>
chartRect.contains(Offset(location, 0)),
};
}).toList();
}
@override
Widget build(BuildContext context) {
if (!axisTitles.showAxisTitles && !axisTitles.showSideTitles) {
return Container();
}
final axisViewSize = isHorizontal ? viewSize.width : viewSize.height;
return Align(
alignment: alignment,
child: Flex(
direction: counterDirection,
mainAxisSize: MainAxisSize.min,
children: [
if (isLeftOrTop && axisTitles.axisNameWidget != null)
_AxisTitleWidget(
axisTitles: axisTitles,
side: widget.side,
axisViewSize: axisViewSize,
),
if (sideTitles.showTitles)
Container(
width: isHorizontal ? axisViewSize : sideTitles.reservedSize,
height: isHorizontal ? sideTitles.reservedSize : axisViewSize,
margin: thisSidePadding,
child: SideTitlesFlex(
direction: direction,
axisSideMetaData: AxisSideMetaData(
axisMin,
axisMax,
axisViewSize - thisSidePaddingTotal,
),
widgetHolders: makeWidgets(
axisViewSize - thisSidePaddingTotal,
axisMin,
axisMax,
widget.side,
),
),
),
if (isRightOrBottom && axisTitles.axisNameWidget != null)
_AxisTitleWidget(
axisTitles: axisTitles,
side: widget.side,
axisViewSize: axisViewSize,
),
],
),
);
}
}
class _AxisTitleWidget extends StatelessWidget {
const _AxisTitleWidget({
required this.axisTitles,
required this.side,
required this.axisViewSize,
});
final AxisTitles axisTitles;
final AxisSide side;
final double axisViewSize;
int get axisNameQuarterTurns => switch (side) {
AxisSide.right => 3,
AxisSide.left => 3,
AxisSide.top => 0,
AxisSide.bottom => 0,
};
bool get isHorizontal => side == AxisSide.top || side == AxisSide.bottom;
@override
Widget build(BuildContext context) {
return SizedBox(
width: isHorizontal ? axisViewSize : axisTitles.axisNameSize,
height: isHorizontal ? axisTitles.axisNameSize : axisViewSize,
child: Center(
child: RotatedBox(
quarterTurns: axisNameQuarterTurns,
child: axisTitles.axisNameWidget,
),
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart';
import 'package:flutter/widgets.dart';
/// Configuration for the transformation of an axis-based chart.
class FlTransformationConfig {
const FlTransformationConfig({
this.scaleAxis = FlScaleAxis.none,
this.minScale = 1,
this.maxScale = 2.5,
this.panEnabled = true,
this.scaleEnabled = true,
this.trackpadScrollCausesScale = false,
this.transformationController,
}) : assert(minScale >= 1, 'minScale must be greater than or equal to 1'),
assert(
maxScale >= minScale,
'maxScale must be greater than or equal to minScale',
);
/// Determines what axis of the chart should be scaled.
final FlScaleAxis scaleAxis;
/// The minimum scale of the chart.
///
/// Ignored when [scaleAxis] is [FlScaleAxis.none].
final double minScale;
/// The maximum scale of the chart.
///
/// Ignored when [scaleAxis] is [FlScaleAxis.none].
final double maxScale;
/// If false, the user will be prevented from panning.
///
/// Ignored when [scaleAxis] is [FlScaleAxis.none].
final bool panEnabled;
/// If false, the user will be prevented from scaling.
///
/// Ignored when [scaleAxis] is [FlScaleAxis.none].
final bool scaleEnabled;
/// Whether trackpad scroll causes scale.
///
/// Ignored when [scaleAxis] is [FlScaleAxis.none].
final bool trackpadScrollCausesScale;
/// The transformation controller to control the transformation of the chart.
final TransformationController? transformationController;
}

View File

@@ -0,0 +1,203 @@
// coverage:ignore-file
import 'dart:core';
import 'package:equatable/equatable.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/extensions/border_extension.dart';
import 'package:flutter/material.dart';
/// This class holds all data needed for [BaseChartPainter].
///
/// In this phase we draw the border,
/// and handle touches in an abstract way.
abstract class BaseChartData with EquatableMixin {
/// It draws 4 borders around your chart, you can customize it using [borderData],
/// [touchData] defines the touch behavior and responses.
BaseChartData({
FlBorderData? borderData,
}) : borderData = borderData ?? FlBorderData();
/// Holds data to drawing border around the chart.
final FlBorderData borderData;
BaseChartData lerp(BaseChartData a, BaseChartData b, double t);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
borderData,
];
}
/// Holds data to drawing border around the chart.
class FlBorderData with EquatableMixin {
/// [show] Determines showing or hiding border around the chart.
/// [border] Determines the visual look of 4 borders, see [Border].
FlBorderData({
bool? show,
Border? border,
}) : show = show ?? true,
border = border ?? Border.all();
final bool show;
final Border border;
/// returns false if all borders have 0 width or 0 opacity
bool isVisible() => show && border.isVisible();
/// Lerps a [FlBorderData] based on [t] value, check [Tween.lerp].
static FlBorderData lerp(FlBorderData a, FlBorderData b, double t) =>
FlBorderData(
show: b.show,
border: Border.lerp(a.border, b.border, t),
);
/// Copies current [FlBorderData] to a new [FlBorderData],
/// and replaces provided values.
FlBorderData copyWith({
bool? show,
Border? border,
}) =>
FlBorderData(
show: show ?? this.show,
border: border ?? this.border,
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
show,
border,
];
}
/// Holds data to handle touch events, and touch responses in abstract way.
///
/// There is a touch flow, explained [here](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/handle_touches.md)
/// in a simple way, each chart's renderer captures the touch events, and passes the pointerEvent
/// to the painter, and gets touched spot, and wraps it into a concrete [BaseTouchResponse].
abstract class FlTouchData<R extends BaseTouchResponse> with EquatableMixin {
/// You can disable or enable the touch system using [enabled] flag,
const FlTouchData(
this.enabled,
this.touchCallback,
this.mouseCursorResolver,
this.longPressDuration,
);
/// You can disable or enable the touch system using [enabled] flag,
final bool enabled;
/// [touchCallback] notifies you about the happened touch/pointer events.
/// It gives you a [FlTouchEvent] which is the happened event such as [FlPointerHoverEvent], [FlTapUpEvent], ...
/// It also gives you a [BaseTouchResponse] which is the chart specific type and contains information
/// about the elements that has touched.
final BaseTouchCallback<R>? touchCallback;
/// Using [mouseCursorResolver] you can change the mouse cursor
/// based on the provided [FlTouchEvent] and [BaseTouchResponse]
final MouseCursorResolver<R>? mouseCursorResolver;
/// This property that allows to customize the duration of the longPress gesture.
/// default to 500 milliseconds refer to [kLongPressTimeout].
final Duration? longPressDuration;
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
enabled,
touchCallback,
mouseCursorResolver,
longPressDuration,
];
}
/// Holds data to clipping chart around its borders.
class FlClipData with EquatableMixin {
/// Creates data that clips specified sides
const FlClipData({
required this.top,
required this.bottom,
required this.left,
required this.right,
});
/// Creates data that clips all sides
const FlClipData.all()
: this(top: true, bottom: true, left: true, right: true);
/// Creates data that clips only top and bottom side
const FlClipData.vertical()
: this(top: true, bottom: true, left: false, right: false);
/// Creates data that clips only left and right side
const FlClipData.horizontal()
: this(top: false, bottom: false, left: true, right: true);
/// Creates data that doesn't clip any side
const FlClipData.none()
: this(top: false, bottom: false, left: false, right: false);
final bool top;
final bool bottom;
final bool left;
final bool right;
/// Checks whether any of the sides should be clipped
bool get any => top || bottom || left || right;
/// Copies current [FlBorderData] to a new [FlBorderData],
/// and replaces provided values.
FlClipData copyWith({
bool? top,
bool? bottom,
bool? left,
bool? right,
}) =>
FlClipData(
top: top ?? this.top,
bottom: bottom ?? this.bottom,
left: left ?? this.left,
right: right ?? this.right,
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [top, bottom, left, right];
}
/// Chart's touch callback.
typedef BaseTouchCallback<R extends BaseTouchResponse> = void Function(
FlTouchEvent,
R?,
);
/// It gives you the happened [FlTouchEvent] and existed [R] data at the event's location,
/// then you should provide a [MouseCursor] to change the cursor at the event's location.
/// For example you can pass the [SystemMouseCursors.click] to change the mouse cursor to click.
typedef MouseCursorResolver<R extends BaseTouchResponse> = MouseCursor Function(
FlTouchEvent,
R?,
);
/// This class holds the touch response details of charts.
abstract class BaseTouchResponse {
BaseTouchResponse({
required this.touchLocation,
});
/// The location of the touch in pixels on the screen.
final Offset touchLocation;
}
/// Controls an element horizontal alignment to given point.
enum FLHorizontalAlignment {
/// Element shown horizontally center aligned to a given point.
center,
/// Element shown on the left side of the given point.
left,
/// Element shown on the right side of the given point.
right,
}

View File

@@ -0,0 +1,60 @@
// coverage:ignore-file
import 'package:fl_chart/src/chart/base/base_chart/base_chart_data.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:flutter/material.dart';
/// Base class of our painters.
class BaseChartPainter<D extends BaseChartData> {
/// Draws some basic elements
const BaseChartPainter();
// Paints [BaseChartData] into the provided canvas.
void paint(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<D> holder,
) {}
}
/// Holds data for painting on canvas
class PaintHolder<Data extends BaseChartData> {
/// Holds data for painting on canvas
const PaintHolder(
this.data,
this.targetData,
this.textScaler, [
this.chartVirtualRect,
]);
/// [data] is what we need to show frame by frame (it might be changed by an animator)
final Data data;
/// [targetData] is the target of animation that is playing.
final Data targetData;
/// system [TextScaler] used for scaling texts for better readability
final TextScaler textScaler;
/// The virtual rect representing the chart when it is scaled or panned.
///
/// The chart will be drawn in this virtual canvas, and then clipped to the
/// actual canvas.
///
/// When the chart is scaled, the virtual canvas will be larger than the
/// actual canvas. This will lead to the content being laid out on the larger
/// area. Thus resulting in the scaling effect.
///
/// Null when not scaling or panning.
final Rect? chartVirtualRect;
/// Returns the size of the chart that is actually being painted.
///
/// When scaling the chart, the chart is painted on a larger area to simulate
/// the zoom effect. This function returns the size of the area that is
/// actually being painted.
///
/// When not scaled it returns the actual size of the chart.
Size getChartUsableSize(Size viewSize) {
return chartVirtualRect?.size ?? viewSize;
}
}

View File

@@ -0,0 +1,249 @@
// coverage:ignore-file
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
/// Parent class for several kind of touch/pointer events (like tap, panMode, longPressStart, ...)
abstract class FlTouchEvent {
const FlTouchEvent();
/// Represents the position of happened touch/pointer event
///
/// Some events such as [FlPanCancelEvent] and [FlTapCancelEvent]
/// doesn't have any position (their details come from flutter engine).
/// That's why this field is nullable
Offset? get localPosition => null;
/// excludes exit or up events to show interactions on charts
bool get isInterestedForInteractions {
final isLinux = defaultTargetPlatform == TargetPlatform.linux;
final isMacOS = defaultTargetPlatform == TargetPlatform.macOS;
final isWindows = defaultTargetPlatform == TargetPlatform.windows;
final isDesktopOrWeb = kIsWeb || isLinux || isMacOS || isWindows;
/// In desktop when mouse hovers into a chart element using [FlPointerHoverEvent], we show the interaction
/// and when tap happens at the same position, interaction will be dismissed because of [FlTapUpEvent].
/// That's why we exclude it on desktop or web
if (isDesktopOrWeb && this is FlTapUpEvent) {
return true;
}
return this is! FlPanEndEvent &&
this is! FlPanCancelEvent &&
this is! FlPointerExitEvent &&
this is! FlLongPressEnd &&
this is! FlTapUpEvent &&
this is! FlTapCancelEvent;
}
}
/// When a pointer has contacted the screen and might begin to move
///
/// The [details] object provides the position of the touch.
/// Inspired from [GestureDragDownCallback]
class FlPanDownEvent extends FlTouchEvent {
const FlPanDownEvent(this.details);
/// Contains information of happened touch gesture
final DragDownDetails details;
/// Represents the position of happened touch/pointer event
@override
Offset get localPosition => details.localPosition;
}
/// When a pointer has contacted the screen and has begun to move.
///
/// The [details] object provides the position of the touch when it first
/// touched the surface.
/// Inspired from [GestureDragStartCallback].
class FlPanStartEvent extends FlTouchEvent {
/// Creates
const FlPanStartEvent(this.details);
/// Contains information of happened touch gesture
final DragStartDetails details;
/// Represents the position of happened touch/pointer event
@override
Offset get localPosition => details.localPosition;
}
/// When a pointer that is in contact with the screen and moving
/// has moved again.
///
/// The [details] object provides the position of the touch and the distance it
/// has traveled since the last update.
/// Inspired from [GestureDragUpdateCallback]
class FlPanUpdateEvent extends FlTouchEvent {
const FlPanUpdateEvent(this.details);
/// Contains information of happened touch gesture
final DragUpdateDetails details;
/// Represents the position of happened touch/pointer event
@override
Offset get localPosition => details.localPosition;
}
/// When the pointer that previously triggered a [FlPanStartEvent] did not complete.
/// Inspired from [GestureDragCancelCallback]
class FlPanCancelEvent extends FlTouchEvent {
const FlPanCancelEvent();
}
/// When a pointer that was previously in contact with the screen
/// and moving is no longer in contact with the screen.
///
/// The velocity at which the pointer was moving when it stopped contacting
/// the screen is available in the [details].
/// Inspired from [GestureDragEndCallback]
class FlPanEndEvent extends FlTouchEvent {
const FlPanEndEvent(this.details);
/// Contains information of happened touch gesture
final DragEndDetails details;
}
/// When a pointer that might cause a tap has contacted the
/// screen.
///
/// The position at which the pointer contacted the screen is available in the
/// [details].
/// Inspired from [GestureTapDownCallback]
class FlTapDownEvent extends FlTouchEvent {
const FlTapDownEvent(this.details);
/// Contains information of happened touch gesture
final TapDownDetails details;
/// Represents the position of happened touch/pointer event
@override
Offset get localPosition => details.localPosition;
}
/// When the pointer that previously triggered a [FlTapDownEvent] will not end up causing a tap.
/// Inspired from [GestureTapCancelCallback]
class FlTapCancelEvent extends FlTouchEvent {
const FlTapCancelEvent();
}
/// When a pointer that will trigger a tap has stopped contacting
/// the screen.
///
/// The position at which the pointer stopped contacting the screen is available
/// in the [details].
/// Inspired from [GestureTapUpCallback]
class FlTapUpEvent extends FlTouchEvent {
const FlTapUpEvent(this.details);
/// Contains information of happened touch gesture
final TapUpDetails details;
/// Represents the position of happened touch/pointer event
@override
Offset get localPosition => details.localPosition;
}
/// Called When a pointer has remained in contact with the screen at the
/// same location for a long period of time.
///
/// Details are available in the [details].
///
/// Inspired from [GestureLongPressStartCallback]
class FlLongPressStart extends FlTouchEvent {
const FlLongPressStart(this.details);
/// Contains information of happened touch gesture
final LongPressStartDetails details;
/// Represents the position of happened touch/pointer event
@override
Offset get localPosition => details.localPosition;
}
/// When a pointer is moving after being held in contact at the same
/// location for a long period of time. Reports the new position and its offset
/// from the original down position.
///
/// Details are available in the [details]
///
/// Inspired from [GestureLongPressMoveUpdateCallback]
class FlLongPressMoveUpdate extends FlTouchEvent {
const FlLongPressMoveUpdate(this.details);
/// Contains information of happened touch gesture
final LongPressMoveUpdateDetails details;
/// Represents the position of happened touch/pointer event
@override
Offset get localPosition => details.localPosition;
}
/// When a pointer stops contacting the screen after a long press
/// gesture was detected. Also reports the position where the pointer stopped
/// contacting the screen.
///
/// Details are available in the [details]
///
/// Inspired from [GestureLongPressEndCallback]
class FlLongPressEnd extends FlTouchEvent {
const FlLongPressEnd(this.details);
/// Contains information of happened touch gesture
final LongPressEndDetails details;
/// Represents the position of happened touch/pointer event
@override
Offset get localPosition => details.localPosition;
}
/// The pointer has moved with respect to the device while the pointer is or is
/// not in contact with the device, and it has entered our chart.
///
/// Details are available in the [event]
///
/// Inspired from [PointerEnterEventListener]
class FlPointerEnterEvent extends FlTouchEvent {
const FlPointerEnterEvent(this.event);
/// Contains information of happened pointer event
final PointerEnterEvent event;
/// Represents the position of happened touch/pointer event
@override
Offset get localPosition => event.localPosition;
}
/// The pointer has moved with respect to the device while the pointer is not
/// in contact with the device.
///
/// Details are available in the [event]
///
/// Inspired from [PointerHoverEventListener]
class FlPointerHoverEvent extends FlTouchEvent {
const FlPointerHoverEvent(this.event);
/// Contains information of happened pointer event
final PointerHoverEvent event;
/// Represents the position of happened touch/pointer event
@override
Offset get localPosition => event.localPosition;
}
/// The pointer has moved with respect to the device while the pointer is or is
/// not in contact with the device, and exited our chart.
///
/// Inspired from [PointerExitEventListener] which contains [PointerExitEvent]
class FlPointerExitEvent extends FlTouchEvent {
const FlPointerExitEvent(this.event);
/// Contains information of happened pointer event
final PointerExitEvent event;
/// Represents the position of happened touch/pointer event
@override
Offset get localPosition => event.localPosition;
}

View File

@@ -0,0 +1,207 @@
// coverage:ignore-file
import 'package:fl_chart/src/chart/base/base_chart/base_chart_data.dart';
import 'package:fl_chart/src/chart/base/base_chart/fl_touch_event.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
/// It implements shared logics between our renderers such as touch/pointer events recognition, size, layout, ...
abstract class RenderBaseChart<R extends BaseTouchResponse> extends RenderBox
implements MouseTrackerAnnotation {
/// We use [FlTouchData] to retrieve [FlTouchData.touchCallback] and [FlTouchData.mouseCursorResolver]
/// to invoke them when touch happens.
RenderBaseChart(
FlTouchData<R>? touchData,
BuildContext context, {
required bool canBeScaled,
}) : _canBeScaled = canBeScaled,
_buildContext = context {
updateBaseTouchData(touchData);
initGestureRecognizers();
}
bool get canBeScaled => _canBeScaled;
bool _canBeScaled;
set canBeScaled(bool value) {
if (_canBeScaled == value) return;
_canBeScaled = value;
markNeedsPaint();
}
// We use buildContext to retrieve Theme data
BuildContext get buildContext => _buildContext;
BuildContext _buildContext;
set buildContext(BuildContext value) {
_buildContext = value;
markNeedsPaint();
}
void updateBaseTouchData(FlTouchData<R>? value) {
_touchCallback = value?.touchCallback;
_mouseCursorResolver = value?.mouseCursorResolver;
_longPressDuration = value?.longPressDuration;
}
BaseTouchCallback<R>? _touchCallback;
MouseCursorResolver<R>? _mouseCursorResolver;
Duration? _longPressDuration;
MouseCursor _latestMouseCursor = MouseCursor.defer;
late bool _validForMouseTracker;
/// Recognizes pan gestures, such as onDown, onStart, onUpdate, onCancel, ...
@visibleForTesting
late PanGestureRecognizer panGestureRecognizer;
/// Recognizes tap gestures, such as onTapDown, onTapCancel and onTapUp
@visibleForTesting
late TapGestureRecognizer tapGestureRecognizer;
/// Recognizes longPress gestures, such as onLongPressStart, onLongPressMoveUpdate and onLongPressEnd
@visibleForTesting
late LongPressGestureRecognizer longPressGestureRecognizer;
/// Initializes our recognizers and implement their callbacks.
void initGestureRecognizers() {
panGestureRecognizer = PanGestureRecognizer();
panGestureRecognizer
..onDown = (dragDownDetails) {
_notifyTouchEvent(FlPanDownEvent(dragDownDetails));
}
..onStart = (dragStartDetails) {
_notifyTouchEvent(FlPanStartEvent(dragStartDetails));
}
..onUpdate = (dragUpdateDetails) {
_notifyTouchEvent(FlPanUpdateEvent(dragUpdateDetails));
}
..onCancel = () {
_notifyTouchEvent(const FlPanCancelEvent());
}
..onEnd = (dragEndDetails) {
_notifyTouchEvent(FlPanEndEvent(dragEndDetails));
};
tapGestureRecognizer = TapGestureRecognizer();
tapGestureRecognizer
..onTapDown = (tapDownDetails) {
_notifyTouchEvent(FlTapDownEvent(tapDownDetails));
}
..onTapCancel = () {
_notifyTouchEvent(const FlTapCancelEvent());
}
..onTapUp = (tapUpDetails) {
_notifyTouchEvent(FlTapUpEvent(tapUpDetails));
};
longPressGestureRecognizer =
LongPressGestureRecognizer(duration: _longPressDuration);
longPressGestureRecognizer
..onLongPressStart = (longPressStartDetails) {
_notifyTouchEvent(FlLongPressStart(longPressStartDetails));
}
..onLongPressMoveUpdate = (longPressMoveUpdateDetails) {
_notifyTouchEvent(
FlLongPressMoveUpdate(longPressMoveUpdateDetails),
);
}
..onLongPressEnd = (longPressEndDetails) =>
_notifyTouchEvent(FlLongPressEnd(longPressEndDetails));
}
@override
void performLayout() {
size = computeDryLayout(constraints);
}
@override
Size computeDryLayout(BoxConstraints constraints) {
return Size(constraints.maxWidth, constraints.maxHeight);
}
@override
bool hitTestSelf(Offset position) => true;
/// Feeds our gesture recognizers for handling events, we also handle [PointerHoverEvent] here.
///
/// Our gesture recognizers are responsible for notifying us about happened gestures (such as tap, panMove, ...)
/// we need to give them [PointerDownEvent] then they will listen to the global [GestureBinding] for further events.
///
/// We need to handle [PointerHoverEvent] because there is no gesture recognizer
/// for mouse hover events (in fact they don't have any gestures, they are just events).
@override
void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (_touchCallback == null) {
return;
}
if (event is PointerDownEvent) {
longPressGestureRecognizer.addPointer(event);
tapGestureRecognizer.addPointer(event);
if (!canBeScaled) {
panGestureRecognizer.addPointer(event);
}
} else if (event is PointerHoverEvent) {
_notifyTouchEvent(FlPointerHoverEvent(event));
}
}
/// Here we handle mouse hover enter event
@override
PointerEnterEventListener? get onEnter =>
(event) => _notifyTouchEvent(FlPointerEnterEvent(event));
/// Here we handle mouse hover exit event
@override
PointerExitEventListener? get onExit =>
(event) => _notifyTouchEvent(FlPointerExitEvent(event));
/// Invokes the [_touchCallback] to notify listeners of this [FlTouchEvent]
///
/// We get a [BaseTouchResponse] using [getResponseAtLocation] for events which contains a localPosition.
/// Then we invoke [_touchCallback] using the [event] and [response].
void _notifyTouchEvent(FlTouchEvent event) {
if (_touchCallback == null) {
return;
}
final localPosition = event.localPosition;
R? response;
if (localPosition != null) {
response = getResponseAtLocation(localPosition);
}
_touchCallback!(event, response);
if (_mouseCursorResolver == null) {
_latestMouseCursor = MouseCursor.defer;
} else {
_latestMouseCursor = _mouseCursorResolver!(event, response);
}
}
/// Represents the mouse cursor style when hovers on our chart
/// In the future we can change it runtime, for example we can turn it to
/// [SystemMouseCursors.click] when mouse hovers a specific point of our chart.
@override
MouseCursor get cursor => _latestMouseCursor;
/// [MouseTracker] will catch us if this variable is true
@override
bool get validForMouseTracker => _validForMouseTracker;
/// Charts need to implement this class to tell us what [BaseTouchResponse] is available at provided [localPosition]
/// When touch/pointer event happens, we send it to the user alongside the [FlTouchEvent] using [_touchCallback]
R getResponseAtLocation(Offset localPosition);
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_validForMouseTracker = true;
}
@override
void detach() {
_validForMouseTracker = false;
super.detach();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
import 'dart:math' as math;
import 'package:flutter/painting.dart';
/// Describes a line model (contains [from], and end [to])
class Line {
const Line(this.from, this.to);
/// Start of the line
final Offset from;
/// End of the line
final Offset to;
/// Returns the length of line
double magnitude() {
final diff = to - from;
final dx = diff.dx;
final dy = diff.dy;
return math.sqrt(dx * dx + dy * dy);
}
/// Returns angle of the line in radians
double direction() {
final diff = to - from;
return math.atan(diff.dy / diff.dx);
}
/// Returns the line in magnitude of 1.0
Offset normalize() {
final diffOffset = to - from;
return diffOffset * (1.0 / magnitude());
}
}

View File

@@ -0,0 +1,184 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart';
import 'package:fl_chart/src/chart/candlestick_chart/candlestick_chart_renderer.dart';
import 'package:flutter/material.dart';
/// Renders a pie chart as a widget, using provided [CandlestickChartData].
class CandlestickChart extends ImplicitlyAnimatedWidget {
/// [data] determines how the [CandlestickChart] should be look like,
/// when you make any change in the [CandlestickChartData], it updates
/// new values with animation, and duration is [duration].
/// also you can change the [curve]
/// which default is [Curves.linear].
const CandlestickChart(
this.data, {
this.chartRendererKey,
super.key,
@Deprecated('Please use [duration] instead')
Duration? swapAnimationDuration,
Duration duration = const Duration(milliseconds: 150),
@Deprecated('Please use [curve] instead') Curve? swapAnimationCurve,
Curve curve = Curves.linear,
this.transformationConfig = const FlTransformationConfig(),
}) : super(
duration: swapAnimationDuration ?? duration,
curve: swapAnimationCurve ?? curve,
);
/// Determines how the [CandlestickChart] should be look like.
final CandlestickChartData data;
/// {@macro fl_chart.AxisChartScaffoldWidget.transformationConfig}
final FlTransformationConfig transformationConfig;
/// We pass this key to our renderers which are responsible to
/// render the chart itself (without anything around the chart).
final Key? chartRendererKey;
/// Creates a [_CandlestickChartState]
@override
_CandlestickChartState createState() => _CandlestickChartState();
}
class _CandlestickChartState extends AnimatedWidgetBaseState<CandlestickChart> {
/// we handle under the hood animations (implicit animations) via this tween,
/// it lerps between the old [CandlestickChartData] to the new one.
CandlestickChartDataTween? _candlestickChartDataTween;
/// If [CandlestickTouchData.handleBuiltInTouches] is true, we override the callback to handle touches internally,
/// but we need to keep the provided callback to notify it too.
BaseTouchCallback<CandlestickTouchResponse>? _providedTouchCallback;
({
Offset axisCoordinate,
int spotIndex,
})? touchedSpots;
@override
Widget build(BuildContext context) {
final showingData = _getData();
return AxisChartScaffoldWidget(
data: showingData,
transformationConfig: widget.transformationConfig,
chartBuilder: (context, chartVirtualRect) => CandlestickChartLeaf(
data: _withTouchedIndicators(
_candlestickChartDataTween!.evaluate(animation),
),
targetData: _withTouchedIndicators(showingData),
key: widget.chartRendererKey,
chartVirtualRect: chartVirtualRect,
canBeScaled: widget.transformationConfig.scaleAxis != FlScaleAxis.none,
),
);
}
CandlestickChartData _withTouchedIndicators(
CandlestickChartData candlestickChartData,
) {
if (!candlestickChartData.candlestickTouchData.enabled ||
!candlestickChartData.candlestickTouchData.handleBuiltInTouches) {
return candlestickChartData;
}
final spot = touchedSpots != null && touchedSpots!.spotIndex != -1
? candlestickChartData.candlestickSpots[touchedSpots!.spotIndex]
: null;
final touchInsideChart = touchedSpots != null &&
touchedSpots!.axisCoordinate.dx >= candlestickChartData.minX &&
touchedSpots!.axisCoordinate.dy >= candlestickChartData.minY &&
touchedSpots!.axisCoordinate.dx <= candlestickChartData.maxX &&
touchedSpots!.axisCoordinate.dy <= candlestickChartData.maxY;
final providedPainter = candlestickChartData.touchedPointIndicator?.painter;
final providedX = candlestickChartData.touchedPointIndicator?.x;
final providedY = candlestickChartData.touchedPointIndicator?.y;
return candlestickChartData.copyWith(
showingTooltipIndicators:
touchedSpots != null ? [touchedSpots!.spotIndex] : [],
touchedPointIndicator: touchedSpots != null
? AxisSpotIndicator(
x: providedX ?? spot?.x,
y: providedY ??
(touchInsideChart ? touchedSpots!.axisCoordinate.dy : null),
painter: providedPainter ??
AxisLinesIndicatorPainter(
horizontalLineProvider: (y) => HorizontalLine(
y: y,
color: Theme.of(context).colorScheme.outline.withValues(
alpha: 0.5,
),
strokeWidth: 1,
),
verticalLineProvider: (x) => VerticalLine(
x: x,
color: Theme.of(context).colorScheme.outline.withValues(
alpha: 0.5,
),
strokeWidth: 1,
),
),
)
: null,
);
}
CandlestickChartData _getData() {
final candlestickTouchData = widget.data.candlestickTouchData;
if (candlestickTouchData.enabled &&
candlestickTouchData.handleBuiltInTouches) {
_providedTouchCallback = candlestickTouchData.touchCallback;
return widget.data.copyWith(
candlestickTouchData: widget.data.candlestickTouchData
.copyWith(touchCallback: _handleBuiltInTouch),
);
}
return widget.data;
}
void _handleBuiltInTouch(
FlTouchEvent event,
CandlestickTouchResponse? touchResponse,
) {
if (!mounted) {
return;
}
_providedTouchCallback?.call(event, touchResponse);
final desiredTouch = event.isInterestedForInteractions;
if (!desiredTouch ||
touchResponse == null ||
touchResponse.touchedSpot == null) {
setState(() {
if (desiredTouch) {
touchedSpots = (
axisCoordinate: touchResponse?.touchChartCoordinate ?? Offset.zero,
spotIndex: -1,
);
} else {
touchedSpots = null;
}
});
return;
}
setState(() {
touchedSpots = (
axisCoordinate: touchResponse.touchChartCoordinate,
spotIndex: touchResponse.touchedSpot!.spotIndex,
);
});
}
@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_candlestickChartDataTween = visitor(
_candlestickChartDataTween,
_getData(),
(dynamic value) => CandlestickChartDataTween(
begin: value as CandlestickChartData,
end: widget.data,
),
) as CandlestickChartDataTween?;
}
}

View File

@@ -0,0 +1,999 @@
// coverage:ignore-file
import 'dart:math';
import 'dart:ui';
import 'package:equatable/equatable.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/candlestick_chart/candlestick_chart_helper.dart';
import 'package:fl_chart/src/extensions/color_extension.dart';
import 'package:fl_chart/src/utils/lerp.dart';
import 'package:flutter/material.dart';
/// [CandlestickChart] needs this class to render itself.
///
/// It holds data needed to draw a candlestick chart,
/// including background color, Candlestick spots, ...
class CandlestickChartData extends AxisChartData with EquatableMixin {
/// [CandlestickChart] draws some candlesticks on the chart based on
/// the provided [candlestickSpots],
///
/// It draws some titles on left, top, right, bottom sides per each axis number,
/// you can modify [titlesData] to have your custom titles,
///
/// It draws a color as a background behind everything you can set it using [backgroundColor],
/// then a grid over it, you can customize it using [gridData],
/// and it draws 4 borders around your chart, you can customize it using [borderData].
///
/// You can modify [candlestickTouchData] to customize touch behaviors and responses.
///
/// You can show some tooltipIndicators (a popup with an information)
/// on top of each [CandlestickChartData.candleSpots] using [showingTooltipIndicators],
/// just put spot indices you want to show it on top of them.
///
/// [clipData] forces the [CandlestickChart] to draw lines inside the chart bounding box.
CandlestickChartData({
List<CandlestickSpot>? candlestickSpots,
FlCandlestickPainter? candlestickPainter,
FlTitlesData? titlesData,
CandlestickTouchData? candlestickTouchData,
List<int>? showingTooltipIndicators,
FlGridData? gridData,
super.borderData,
double? minX,
double? maxX,
super.baselineX,
double? minY,
double? maxY,
super.baselineY,
super.rangeAnnotations,
FlClipData? clipData,
super.backgroundColor,
super.rotationQuarterTurns,
this.touchedPointIndicator,
}) : candlestickSpots = candlestickSpots ?? const [],
candlestickPainter = candlestickPainter ?? DefaultCandlestickPainter(),
candlestickTouchData = candlestickTouchData ?? CandlestickTouchData(),
showingTooltipIndicators = showingTooltipIndicators ?? const [],
super(
gridData: gridData ?? const FlGridData(),
titlesData: titlesData ?? const FlTitlesData(),
clipData: clipData ?? const FlClipData.none(),
minX: minX ??
CandlestickChartHelper.calculateMaxAxisValues(
candlestickSpots ?? const [],
).$1,
maxX: maxX ??
CandlestickChartHelper.calculateMaxAxisValues(
candlestickSpots ?? const [],
).$2,
minY: minY ??
CandlestickChartHelper.calculateMaxAxisValues(
candlestickSpots ?? const [],
).$3,
maxY: maxY ??
CandlestickChartHelper.calculateMaxAxisValues(
candlestickSpots ?? const [],
).$4,
);
/// Contains the data for the candlestick chart.
///
/// Each [CandlestickSpot] represents a candlestick in the chart
/// that contains [open, high, low, close] values.
final List<CandlestickSpot> candlestickSpots;
/// The painter used to draw the candlestick.
/// You can use the [DefaultCandlestickPainter] or implement your own.
final FlCandlestickPainter candlestickPainter;
/// Handles touch behaviors and responses.
final CandlestickTouchData candlestickTouchData;
/// you can show some tooltipIndicators (a popup with an information)
/// on top of each [CandlestickSpot] using [showingTooltipIndicators],
/// just put indices you want to show it on top of them.
///
/// An important point is that you have to disable the default touch behaviour
/// to show the tooltip manually, see [CandlestickTouchData.handleBuiltInTouches].
final List<int> showingTooltipIndicators;
/// Shows an indicator on the touched / hovered point
///
/// We manage to set it by default
/// when [candlestickTouchData.handleBuiltInTouches] is true,
/// so nothing happens if you change this property as long as
/// the handleBuiltInTouches property is true.
///
/// But you can set [candlestickTouchData.handleBuiltInTouches] to false
/// if you want to have customized [touchedPointIndicator]
final AxisSpotIndicator? touchedPointIndicator;
/// Lerps a [CandlestickChartData] based on [t] value, check [Tween.lerp].
@override
CandlestickChartData lerp(BaseChartData a, BaseChartData b, double t) {
if (a is CandlestickChartData && b is CandlestickChartData) {
return CandlestickChartData(
candlestickSpots:
lerpCandleSpotList(a.candlestickSpots, b.candlestickSpots, t),
candlestickPainter: b.candlestickPainter.lerp(
a.candlestickPainter,
b.candlestickPainter,
t,
),
titlesData: FlTitlesData.lerp(a.titlesData, b.titlesData, t),
candlestickTouchData: b.candlestickTouchData,
showingTooltipIndicators: lerpIntList(
a.showingTooltipIndicators,
b.showingTooltipIndicators,
t,
),
gridData: FlGridData.lerp(a.gridData, b.gridData, t),
borderData: FlBorderData.lerp(a.borderData, b.borderData, t),
minX: lerpDouble(a.minX, b.minX, t),
maxX: lerpDouble(a.maxX, b.maxX, t),
baselineX: lerpDouble(a.baselineX, b.baselineX, t),
minY: lerpDouble(a.minY, b.minY, t),
maxY: lerpDouble(a.maxY, b.maxY, t),
baselineY: lerpDouble(a.baselineY, b.baselineY, t),
rangeAnnotations: RangeAnnotations.lerp(
a.rangeAnnotations,
b.rangeAnnotations,
t,
),
clipData: b.clipData,
backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t),
rotationQuarterTurns: b.rotationQuarterTurns,
touchedPointIndicator: b.touchedPointIndicator,
);
} else {
throw Exception('Illegal State');
}
}
/// Copies current [CandlestickChartData] to a new [CandlestickChartData],
/// and replaces provided values.
CandlestickChartData copyWith({
List<CandlestickSpot>? candlestickSpots,
FlCandlestickPainter? candlestickPainter,
FlTitlesData? titlesData,
CandlestickTouchData? candlestickTouchData,
List<int>? showingTooltipIndicators,
FlGridData? gridData,
FlBorderData? borderData,
double? minX,
double? maxX,
double? baselineX,
double? minY,
double? maxY,
double? baselineY,
RangeAnnotations? rangeAnnotations,
FlClipData? clipData,
Color? backgroundColor,
int? rotationQuarterTurns,
AxisSpotIndicator? touchedPointIndicator,
}) =>
CandlestickChartData(
candlestickSpots: candlestickSpots ?? this.candlestickSpots,
candlestickPainter: candlestickPainter ?? this.candlestickPainter,
titlesData: titlesData ?? this.titlesData,
candlestickTouchData: candlestickTouchData ?? this.candlestickTouchData,
showingTooltipIndicators:
showingTooltipIndicators ?? this.showingTooltipIndicators,
gridData: gridData ?? this.gridData,
borderData: borderData ?? this.borderData,
minX: minX ?? this.minX,
maxX: maxX ?? this.maxX,
baselineX: baselineX ?? this.baselineX,
minY: minY ?? this.minY,
maxY: maxY ?? this.maxY,
baselineY: baselineY ?? this.baselineY,
rangeAnnotations: rangeAnnotations ?? this.rangeAnnotations,
clipData: clipData ?? this.clipData,
backgroundColor: backgroundColor ?? this.backgroundColor,
rotationQuarterTurns: rotationQuarterTurns ?? this.rotationQuarterTurns,
touchedPointIndicator:
touchedPointIndicator ?? this.touchedPointIndicator,
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
candlestickSpots,
candlestickPainter,
candlestickTouchData,
showingTooltipIndicators,
gridData,
titlesData,
minX,
maxX,
baselineX,
minY,
maxY,
baselineY,
rangeAnnotations,
clipData,
backgroundColor,
borderData,
rotationQuarterTurns,
touchedPointIndicator,
];
}
/// Defines information about a spot in the [CandlestickChart]
class CandlestickSpot extends FlSpot with EquatableMixin {
/// You can change [show] value to show or hide the spot,
/// [x] determines the location of [CandlestickChart] in the x-axis,
/// [open], [high], [low], and [close] defines the values of the spot
/// based on the standard [OHLC chart](https://en.wikipedia.org/wiki/Open-high-low-close_chart).
///
/// You can temporarily hide the spot by setting [show] to false.
///
/// The [candlestickPainter] is used to customize the appearance of each candlestick.
/// We use the [DefaultCandlestickPainter] by default, but you can implement
/// your own painter with your shiny UI
CandlestickSpot({
required double x,
required this.open,
required this.high,
required this.low,
required this.close,
bool? show,
}) : show = show ?? true,
super(x, high);
/// The open value of a specific candlestick.
final double open;
/// The high value of a specific candlestick.
final double high;
/// The low value of a specific candlestick.
final double low;
/// The close value of a specific candlestick.
final double close;
/// Determines show or hide the spot.
final bool show;
/// Checks if the candlestick is up (close > open).
///
/// It is the same as bullish and bearish definitions in the stock market.
bool get isUp => close > open;
/// Returns the middle point of the candlestick
double get midPoint => (open + close) / 2;
@override
CandlestickSpot copyWith({
double? x,
double? y,
FlErrorRange? xError,
FlErrorRange? yError,
double? open,
double? high,
double? low,
double? close,
bool? show,
}) {
if (y != null) {
throw Exception(
'y value is not used in CandlestickSpot and it does not do anything. Please use open, high, low, close values.',
);
}
if (xError != null || yError != null) {
throw Exception(
'xError and yError values are not used in CandlestickSpot and it does not do anything.',
);
}
return CandlestickSpot(
x: x ?? this.x,
open: open ?? this.open,
high: high ?? this.high,
low: low ?? this.low,
close: close ?? this.close,
show: show ?? this.show,
);
}
/// Lerps a [CandlestickSpot] based on [t] value, check [Tween.lerp].
static CandlestickSpot lerp(CandlestickSpot a, CandlestickSpot b, double t) =>
CandlestickSpot(
x: lerpDouble(a.x, b.x, t)!,
open: lerpDouble(a.open, b.open, t)!,
high: lerpDouble(a.high, b.high, t)!,
low: lerpDouble(a.low, b.low, t)!,
close: lerpDouble(a.close, b.close, t)!,
show: b.show,
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
x,
open,
high,
low,
close,
show,
];
}
/// Holds data to handle touch events, and touch responses in the [CandlestickChart].
///
/// There is a touch flow, explained [here](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/handle_touches.md)
/// in a simple way, each chart's renderer captures the touch events, and passes the pointerEvent
/// to the painter, and gets touched spot, and wraps it into a concrete [CandlestickTouchResponse].
class CandlestickTouchData extends FlTouchData<CandlestickTouchResponse>
with EquatableMixin {
/// You can disable or enable the touch system using [enabled] flag,
///
/// [touchCallback] notifies you about the happened touch/pointer events.
/// It gives you a [FlTouchEvent] which is the happened event such as [FlPointerHoverEvent], [FlTapUpEvent], ...
/// It also gives you a [CandlestickTouchResponse] which contains information
/// about the elements that has touched.
///
/// Using [mouseCursorResolver] you can change the mouse cursor
/// based on the provided [FlTouchEvent] and [CandlestickTouchResponse]
///
/// if [handleBuiltInTouches] is true, [CandlestickChart] shows a tooltip popup on top of the spots if
/// touch occurs (or you can show it manually using, [CandlestickChartData.showingTooltipIndicators])
/// You can customize this tooltip using [touchTooltipData],
///
/// If you need to have a distance threshold for handling touches, use [touchSpotThreshold].
CandlestickTouchData({
bool? enabled,
BaseTouchCallback<CandlestickTouchResponse>? touchCallback,
MouseCursorResolver<CandlestickTouchResponse>? mouseCursorResolver,
Duration? longPressDuration,
CandlestickTouchTooltipData? touchTooltipData,
bool? handleBuiltInTouches,
double? touchSpotThreshold,
}) : touchTooltipData = touchTooltipData ?? CandlestickTouchTooltipData(),
handleBuiltInTouches = handleBuiltInTouches ?? true,
touchSpotThreshold = touchSpotThreshold ?? 4,
super(
enabled ?? true,
touchCallback,
mouseCursorResolver,
longPressDuration,
);
/// show a tooltip on touched spots
final CandlestickTouchTooltipData touchTooltipData;
/// set this true if you want the built in touch handling
/// (show a tooltip bubble and an indicator on touched spots)
final bool handleBuiltInTouches;
/// we find the nearest spots on touched position based on this threshold
final double touchSpotThreshold;
/// Copies current [CandlestickTouchData] to a new [CandlestickTouchData],
/// and replaces provided values.
CandlestickTouchData copyWith({
bool? enabled,
BaseTouchCallback<CandlestickTouchResponse>? touchCallback,
MouseCursorResolver<CandlestickTouchResponse>? mouseCursorResolver,
Duration? longPressDuration,
CandlestickTouchTooltipData? touchTooltipData,
bool? handleBuiltInTouches,
double? touchSpotThreshold,
}) =>
CandlestickTouchData(
enabled: enabled ?? this.enabled,
touchCallback: touchCallback ?? this.touchCallback,
mouseCursorResolver: mouseCursorResolver ?? this.mouseCursorResolver,
longPressDuration: longPressDuration ?? this.longPressDuration,
touchTooltipData: touchTooltipData ?? this.touchTooltipData,
handleBuiltInTouches: handleBuiltInTouches ?? this.handleBuiltInTouches,
touchSpotThreshold: touchSpotThreshold ?? this.touchSpotThreshold,
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
enabled,
touchCallback,
mouseCursorResolver,
longPressDuration,
touchTooltipData,
handleBuiltInTouches,
touchSpotThreshold,
];
}
/// [CandlestickChart]'s touch callback.
typedef CandlestickTouchCallback = void Function(CandlestickTouchResponse);
/// Holds information about touch response in the [CandlestickChart].
///
/// You can override [CandlestickTouchData.touchCallback] to handle touch events,
/// it gives you a [CandlestickTouchResponse] and you can do whatever you want.
class CandlestickTouchResponse extends AxisBaseTouchResponse {
/// If touch happens, [CandlestickChart] processes it internally and
/// passes out a [CandlestickTouchResponse], it gives you information about the touched spot.
///
/// [touchedSpot] tells you
/// in which spot (of [CandlestickChartData.candleSpots]) touch happened.
CandlestickTouchResponse({
required super.touchLocation,
required super.touchChartCoordinate,
required this.touchedSpot,
});
final CandlestickTouchedSpot? touchedSpot;
/// Copies current [CandlestickTouchResponse] to a new [CandlestickTouchResponse],
/// and replaces provided values.
CandlestickTouchResponse copyWith({
Offset? touchLocation,
Offset? touchChartCoordinate,
CandlestickTouchedSpot? touchedSpot,
}) =>
CandlestickTouchResponse(
touchLocation: touchLocation ?? this.touchLocation,
touchChartCoordinate: touchChartCoordinate ?? this.touchChartCoordinate,
touchedSpot: touchedSpot ?? this.touchedSpot,
);
}
/// Holds the touched spot data
class CandlestickTouchedSpot with EquatableMixin {
/// [spot], and [spotIndex] tells you
/// in which spot (of [CandlestickChartData.candleSpots]) touch happened.
const CandlestickTouchedSpot(this.spot, this.spotIndex);
/// Touch happened on this spot
final CandlestickSpot spot;
/// Touch happened on this spot index
final int spotIndex;
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
spot,
spotIndex,
];
/// Copies current [CandlestickTouchedSpot] to a new [CandlestickTouchedSpot],
/// and replaces provided values.
CandlestickTouchedSpot copyWith({
CandlestickSpot? spot,
int? spotIndex,
}) =>
CandlestickTouchedSpot(spot ?? this.spot, spotIndex ?? this.spotIndex);
}
/// Holds representation data for showing tooltip popup on top of spots.
class CandlestickTouchTooltipData with EquatableMixin {
/// if [CandlestickTouchData.handleBuiltInTouches] is true,
/// [CandlestickChart] shows a tooltip popup on top of spots automatically when touch happens,
/// otherwise you can show it manually using [CandlestickChartData.showingTooltipIndicators].
/// Tooltip shows on top of rods, with [getTooltipColor] as a background color.
/// You can set the corner radius using [tooltipBorderRadius].
/// If you want to have a padding inside the tooltip, fill [tooltipPadding].
/// Content of the tooltip will provide using [getTooltipItems] callback, you can override it
/// and pass your custom data to show in the tooltip.
/// You can restrict the tooltip's width using [maxContentWidth].
/// Sometimes, [CandlestickChart] shows the tooltip outside of the chart,
/// you can set [fitInsideHorizontally] true to force it to shift inside the chart horizontally,
/// also you can set [fitInsideVertically] true to force it to shift inside the chart vertically.
CandlestickTouchTooltipData({
BorderRadius? tooltipBorderRadius,
EdgeInsets? tooltipPadding,
FLHorizontalAlignment? tooltipHorizontalAlignment,
double? tooltipHorizontalOffset,
double? maxContentWidth,
GetCandlestickTooltipItems? getTooltipItems,
bool? fitInsideHorizontally,
bool? fitInsideVertically,
bool? showOnTopOfTheChartBoxArea,
double? rotateAngle,
BorderSide? tooltipBorder,
GetCandlestickTooltipColor? getTooltipColor,
}) : tooltipBorderRadius = tooltipBorderRadius ?? BorderRadius.circular(4),
tooltipPadding = tooltipPadding ??
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
tooltipHorizontalAlignment =
tooltipHorizontalAlignment ?? FLHorizontalAlignment.center,
tooltipHorizontalOffset = tooltipHorizontalOffset ?? 0,
maxContentWidth = maxContentWidth ?? 120,
getTooltipItems = getTooltipItems ?? defaultCandlestickTooltipItem,
fitInsideHorizontally = fitInsideHorizontally ?? false,
fitInsideVertically = fitInsideVertically ?? false,
showOnTopOfTheChartBoxArea = showOnTopOfTheChartBoxArea ?? false,
rotateAngle = rotateAngle ?? 0.0,
tooltipBorder = tooltipBorder ?? BorderSide.none,
getTooltipColor = getTooltipColor ?? defaultCandlestickTooltipColor,
super();
/// Sets a border radius for the tooltip.
final BorderRadius tooltipBorderRadius;
/// Applies a padding for showing contents inside the tooltip.
final EdgeInsets tooltipPadding;
/// Controls showing tooltip on left side, right side or center aligned with spot, default is center
final FLHorizontalAlignment tooltipHorizontalAlignment;
/// Applies horizontal offset for showing tooltip, default is zero.
final double tooltipHorizontalOffset;
/// Restricts the tooltip's width.
final double maxContentWidth;
/// Retrieves data for showing content inside the tooltip.
final GetCandlestickTooltipItems getTooltipItems;
/// Forces the tooltip to shift horizontally inside the chart, if overflow happens.
final bool fitInsideHorizontally;
/// Forces the tooltip to shift vertically inside the chart, if overflow happens.
final bool fitInsideVertically;
/// Forces the tooltip container to top of the line, default 'false'
final bool showOnTopOfTheChartBoxArea;
/// Controls the rotation of the tooltip.
final double rotateAngle;
/// The tooltip border color.
final BorderSide tooltipBorder;
/// Retrieves data for showing content inside the tooltip.
final GetCandlestickTooltipColor getTooltipColor;
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
tooltipBorderRadius,
tooltipPadding,
tooltipHorizontalAlignment,
tooltipHorizontalOffset,
maxContentWidth,
getTooltipItems,
fitInsideHorizontally,
fitInsideVertically,
showOnTopOfTheChartBoxArea,
rotateAngle,
tooltipBorder,
getTooltipColor,
];
/// Copies current [CandlestickTouchTooltipData] to a new [CandlestickTouchTooltipData],
/// and replaces provided values.
CandlestickTouchTooltipData copyWith({
BorderRadius? tooltipBorderRadius,
EdgeInsets? tooltipPadding,
FLHorizontalAlignment? tooltipHorizontalAlignment,
double? tooltipHorizontalOffset,
double? maxContentWidth,
GetCandlestickTooltipItems? getTooltipItems,
bool? fitInsideHorizontally,
bool? fitInsideVertically,
double? rotateAngle,
BorderSide? tooltipBorder,
GetCandlestickTooltipColor? getTooltipColor,
}) =>
CandlestickTouchTooltipData(
tooltipBorderRadius: tooltipBorderRadius ?? this.tooltipBorderRadius,
tooltipPadding: tooltipPadding ?? this.tooltipPadding,
tooltipHorizontalAlignment:
tooltipHorizontalAlignment ?? this.tooltipHorizontalAlignment,
tooltipHorizontalOffset:
tooltipHorizontalOffset ?? this.tooltipHorizontalOffset,
maxContentWidth: maxContentWidth ?? this.maxContentWidth,
getTooltipItems: getTooltipItems ?? this.getTooltipItems,
fitInsideHorizontally:
fitInsideHorizontally ?? this.fitInsideHorizontally,
fitInsideVertically: fitInsideVertically ?? this.fitInsideVertically,
rotateAngle: rotateAngle ?? this.rotateAngle,
tooltipBorder: tooltipBorder ?? this.tooltipBorder,
getTooltipColor: getTooltipColor ?? this.getTooltipColor,
);
}
/// Provides a [CandlestickTooltipItem] for showing content inside the [CandlestickTouchTooltipData].
///
/// You can override [CandlestickTouchTooltipData.getTooltipItems], it gives you
/// [touchedSpot] that touch happened on,
/// then you should and pass your custom [CandlestickTooltipItem]
/// to show it inside the tooltip popup.
typedef GetCandlestickTooltipItems = CandlestickTooltipItem? Function(
FlCandlestickPainter painter,
CandlestickSpot touchedSpot,
int spotIndex,
);
/// Default implementation for [CandlestickTouchTooltipData.getTooltipItems].
CandlestickTooltipItem? defaultCandlestickTooltipItem(
FlCandlestickPainter painter,
CandlestickSpot touchedSpot,
int spotIndex,
) {
final textStyle = TextStyle(
color: painter.getMainColor(
spot: touchedSpot,
spotIndex: spotIndex,
),
fontSize: 14,
);
final valueStyle = TextStyle(
color: painter.getMainColor(
spot: touchedSpot,
spotIndex: spotIndex,
),
fontWeight: FontWeight.bold,
fontSize: 14,
);
return CandlestickTooltipItem(
'',
textStyle: textStyle,
children: [
TextSpan(
text: 'open: ',
style: textStyle,
),
TextSpan(
text: '${touchedSpot.open.toInt()}\n',
style: valueStyle,
),
TextSpan(
text: 'high: ',
style: textStyle,
),
TextSpan(
text: '${touchedSpot.high.toInt()}\n',
style: valueStyle,
),
TextSpan(
text: 'low: ',
style: textStyle,
),
TextSpan(
text: '${touchedSpot.low.toInt()}\n',
style: valueStyle,
),
TextSpan(
text: 'close: ',
style: textStyle,
),
TextSpan(
text: '${touchedSpot.close.toInt()}',
style: valueStyle,
),
],
);
}
/// Provides a [Color] to show different background color inside the [CandlestickTouchTooltipData].
///
/// You can override [CandlestickTouchTooltipData.getTooltipColor], it gives you
/// [touchedSpot] that touch happened on,
/// then you should and pass your custom [Color]
/// to show it inside the tooltip popup.
typedef GetCandlestickTooltipColor = Color Function(
CandlestickSpot touchedSpot,
);
/// Default implementation for [CandlestickTouchTooltipData.getTooltipItems].
Color defaultCandlestickTooltipColor(CandlestickSpot touchedSpot) =>
Colors.blueGrey.darken(80);
/// Holds data of showing each item in the tooltip popup.
class CandlestickTooltipItem with EquatableMixin {
/// Shows a [text] with [textStyle], [textDirection], and optional [children] in the tooltip popup,
/// [bottomMargin] is the bottom space from spot.
CandlestickTooltipItem(
this.text, {
this.textStyle,
double? bottomMargin,
TextAlign? textAlign,
TextDirection? textDirection,
this.children,
}) : bottomMargin = bottomMargin ?? 8,
textAlign = textAlign ?? TextAlign.center,
textDirection = textDirection ?? TextDirection.ltr;
/// Showing text.
final String text;
/// Style of showing text.
final TextStyle? textStyle;
/// Defines bottom space from spot.
final double bottomMargin;
/// TextAlign of the showing content.
final TextAlign textAlign;
/// Direction of showing text.
final TextDirection textDirection;
/// Add further style and format to the text of the tooltip
final List<TextSpan>? children;
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
text,
textStyle,
bottomMargin,
textAlign,
textDirection,
children,
];
/// Copies current [CandlestickTooltipItem] to a new [CandlestickTooltipItem],
/// and replaces provided values.
CandlestickTooltipItem copyWith({
String? text,
TextStyle? textStyle,
double? bottomMargin,
TextAlign? textAlign,
TextDirection? textDirection,
List<TextSpan>? children,
}) =>
CandlestickTooltipItem(
text ?? this.text,
textStyle: textStyle ?? this.textStyle,
bottomMargin: bottomMargin ?? this.bottomMargin,
textAlign: textAlign ?? this.textAlign,
textDirection: textDirection ?? this.textDirection,
children: children ?? this.children,
);
}
/// This class contains the interface for drawing the candlestick shape.
abstract class FlCandlestickPainter with EquatableMixin {
const FlCandlestickPainter();
/// This method should be overridden to draw the candlestick shape
void paint(
Canvas canvas,
ValueInCanvasProvider xInCanvasProvider,
ValueInCanvasProvider yInCanvasProvider,
CandlestickSpot spot,
int spotIndex,
);
/// Used to show default UIs, for example [defaultCandlestickTooltipItem]
Color getMainColor({
required CandlestickSpot spot,
required int spotIndex,
});
FlCandlestickPainter lerp(
FlCandlestickPainter a,
FlCandlestickPainter b,
double t,
);
/// Used to implement touch behaviour of this dot, for example,
/// it behaves like a square of [getSize]
/// Check [DefaultCandlestickPainter.hitTest] for an example of an implementation
(bool, double) hitTest(
CandlestickSpot spot,
double touchedX,
double spotX,
double extraTouchThreshold,
) {
final distance = (touchedX - spotX).abs();
final hit = distance <= extraTouchThreshold;
return (hit, distance);
}
}
/// [CandlestickChart]'s touch callback.
typedef CandlestickStyleProvider = CandlestickStyle Function(
CandlestickSpot spot,
int index,
);
CandlestickStyleProvider get _defaultStrokeColorProvider => (spot, _) {
final generalColor =
spot.isUp ? const Color(0xFF4CAF50) : const Color(0xFFEF5350);
return CandlestickStyle(
lineColor: generalColor,
lineWidth: 1.5,
bodyStrokeColor: generalColor,
bodyStrokeWidth: 0,
bodyFillColor: generalColor,
bodyWidth: 4,
bodyRadius: 0,
);
};
/// Default implementation of [FlCandlestickPainter].
///
/// It draws the candlestick shape with a line and a body (just like a standard
/// candlestick chart).
///
/// You can customize the appearance of the candlestick
/// using [CandlestickStyleProvider].
class DefaultCandlestickPainter extends FlCandlestickPainter {
DefaultCandlestickPainter({
CandlestickStyleProvider? candlestickStyleProvider,
}) : candlestickStyleProvider =
candlestickStyleProvider ?? _defaultStrokeColorProvider;
final CandlestickStyleProvider candlestickStyleProvider;
final _linePainter = Paint();
final _bodyPainter = Paint();
final _bodyStrokePainter = Paint();
@override
void paint(
Canvas canvas,
ValueInCanvasProvider xInCanvasProvider,
ValueInCanvasProvider yInCanvasProvider,
CandlestickSpot spot,
int spotIndex,
) {
final style = candlestickStyleProvider(spot, spotIndex);
final xOffsetInCanvas = xInCanvasProvider(spot.x);
final openYOffsetInCanvas = yInCanvasProvider(spot.open);
final highYOffsetInCanvas = yInCanvasProvider(spot.high);
final lowOYOffsetInCanvas = yInCanvasProvider(spot.low);
final closeYOffsetInCanvas = yInCanvasProvider(spot.close);
final bodyHighCanvas = min(openYOffsetInCanvas, closeYOffsetInCanvas);
final bodyLowCanvas = max(openYOffsetInCanvas, closeYOffsetInCanvas);
if (style.lineWidth > 0 && style.lineColor.a > 0) {
canvas
// Bottom line
..drawLine(
Offset(xOffsetInCanvas, lowOYOffsetInCanvas),
Offset(xOffsetInCanvas, bodyLowCanvas),
_linePainter
..color = style.lineColor
..strokeWidth = style.lineWidth,
)
// Top line
..drawLine(
Offset(xOffsetInCanvas, highYOffsetInCanvas),
Offset(xOffsetInCanvas, bodyHighCanvas),
_linePainter
..color = style.lineColor
..strokeWidth = style.lineWidth,
);
}
// Body
final bodyRect = Rect.fromLTRB(
xOffsetInCanvas - style.bodyWidth / 2,
bodyHighCanvas,
xOffsetInCanvas + style.bodyWidth / 2,
bodyLowCanvas,
);
if (style.bodyFillColor.a > 0 && style.bodyWidth > 0) {
canvas.drawRRect(
RRect.fromRectAndRadius(
bodyRect,
Radius.circular(style.bodyRadius),
),
_bodyPainter
..color = style.bodyFillColor
..style = PaintingStyle.fill,
);
}
if (style.bodyStrokeWidth > 0 && style.bodyStrokeColor.a > 0) {
canvas.drawRRect(
RRect.fromRectAndRadius(
bodyRect,
Radius.circular(style.bodyRadius),
),
_bodyStrokePainter
..color = style.bodyStrokeColor
..strokeWidth = style.bodyStrokeWidth
..style = PaintingStyle.stroke,
);
}
}
@override
FlCandlestickPainter lerp(
FlCandlestickPainter a,
FlCandlestickPainter b,
double t,
) {
if (a is! DefaultCandlestickPainter || b is! DefaultCandlestickPainter) {
return b;
}
return DefaultCandlestickPainter(
candlestickStyleProvider: b.candlestickStyleProvider,
);
}
@override
Color getMainColor({
required CandlestickSpot spot,
required int spotIndex,
}) =>
candlestickStyleProvider(spot, spotIndex).lineColor;
@override
List<Object?> get props => [
candlestickStyleProvider,
];
}
/// Holds data for drawing each candlestick shape.
class CandlestickStyle with EquatableMixin {
const CandlestickStyle({
required this.lineColor,
required this.lineWidth,
required this.bodyStrokeColor,
required this.bodyStrokeWidth,
required this.bodyFillColor,
required this.bodyWidth,
required this.bodyRadius,
});
/// The color of the candlestick line.
final Color lineColor;
/// The width of the candlestick line.
final double lineWidth;
/// The color of the candlestick body stroke.
final Color bodyStrokeColor;
/// The width of the candlestick body stroke.
final double bodyStrokeWidth;
/// The fill color of the candlestick body.
final Color bodyFillColor;
/// The width of the candlestick body.
final double bodyWidth;
/// The radius of the corners of the candlestick body.
final double bodyRadius;
/// Lerps a [CandlestickStyle] based on [t] value, check [Tween.lerp].
static CandlestickStyle lerp(
CandlestickStyle a,
CandlestickStyle b,
double t,
) =>
CandlestickStyle(
lineColor: Color.lerp(a.lineColor, b.lineColor, t)!,
lineWidth: lerpDouble(a.lineWidth, b.lineWidth, t)!,
bodyStrokeColor: Color.lerp(a.bodyStrokeColor, b.bodyStrokeColor, t)!,
bodyStrokeWidth: lerpDouble(a.bodyStrokeWidth, b.bodyStrokeWidth, t)!,
bodyFillColor: Color.lerp(a.bodyFillColor, b.bodyFillColor, t)!,
bodyWidth: lerpDouble(a.bodyWidth, b.bodyWidth, t)!,
bodyRadius: lerpDouble(a.bodyRadius, b.bodyRadius, t)!,
);
@override
List<Object?> get props => [
lineColor,
lineWidth,
bodyStrokeColor,
bodyStrokeWidth,
bodyFillColor,
bodyWidth,
bodyRadius,
];
}
/// It lerps a [CandlestickChartData] to another [CandlestickChartData] (handles animation for updating values)
class CandlestickChartDataTween extends Tween<CandlestickChartData> {
CandlestickChartDataTween({
required CandlestickChartData begin,
required CandlestickChartData end,
}) : super(begin: begin, end: end);
/// Lerps a [CandlestickChartData] based on [t] value, check [Tween.lerp].
@override
CandlestickChartData lerp(double t) => begin!.lerp(begin!, end!, t);
}

View File

@@ -0,0 +1,43 @@
import 'package:fl_chart/src/chart/candlestick_chart/candlestick_chart_data.dart';
/// Contains anything that helps CandlestickChart works
class CandlestickChartHelper {
/// Calculates minX, maxX, minY, and maxY based on [candleSpots],
/// returns cached values, to prevent redundant calculations.
static (
double minX,
double maxX,
double minY,
double maxY,
) calculateMaxAxisValues(
List<CandlestickSpot> candleSpots,
) {
if (candleSpots.isEmpty) {
return (0, 0, 0, 0);
}
var minX = candleSpots[0].x;
var maxX = candleSpots[0].x;
var minY = candleSpots[0].low;
var maxY = candleSpots[0].high;
for (var j = 0; j < candleSpots.length; j++) {
final spot = candleSpots[j];
if (spot.x > maxX) {
maxX = spot.x;
}
if (spot.x < minX) {
minX = spot.x;
}
if (spot.high > maxY) {
maxY = spot.high;
}
if (spot.low < minY) {
minY = spot.low;
}
}
return (minX, maxX, minY, maxY);
}
}

View File

@@ -0,0 +1,381 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_painter.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:fl_chart/src/utils/utils.dart';
import 'package:flutter/material.dart';
/// Paints [CandlestickChartData] in the canvas, it can be used in a [CustomPainter]
class CandlestickChartPainter extends AxisChartPainter<CandlestickChartData> {
/// Paints [CandlestickChartData] in the canvas
CandlestickChartPainter() : super() {
_bgTouchTooltipPaint = Paint()
..style = PaintingStyle.fill
..color = Colors.white;
_borderTouchTooltipPaint = Paint()
..style = PaintingStyle.stroke
..color = Colors.transparent
..strokeWidth = 1.0;
_clipPaint = Paint();
}
late Paint _bgTouchTooltipPaint;
late Paint _borderTouchTooltipPaint;
late Paint _clipPaint;
/// Paints [CandlestickChartData] into the provided canvas.
@override
void paint(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<CandlestickChartData> holder,
) {
if (holder.chartVirtualRect != null) {
canvasWrapper
..saveLayer(
Offset.zero & canvasWrapper.size,
_clipPaint,
)
..clipRect(Offset.zero & canvasWrapper.size);
}
super.paint(context, canvasWrapper, holder);
drawAxisSpotIndicator(context, canvasWrapper, holder);
drawCandlesticks(context, canvasWrapper, holder);
if (holder.chartVirtualRect != null) {
canvasWrapper.restore();
}
drawTouchTooltips(context, canvasWrapper, holder);
}
@visibleForTesting
void drawCandlesticks(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<CandlestickChartData> holder,
) {
final data = holder.data;
final viewSize = canvasWrapper.size;
final clip = data.clipData;
final border = data.borderData.show ? data.borderData.border : null;
if (data.clipData.any) {
canvasWrapper.saveLayer(
Rect.fromLTRB(
0,
0,
canvasWrapper.size.width,
canvasWrapper.size.height,
),
_clipPaint,
);
var left = 0.0;
var top = 0.0;
var right = viewSize.width;
var bottom = viewSize.height;
if (clip.left) {
final borderWidth = border?.left.width ?? 0;
left = borderWidth / 2;
}
if (clip.top) {
final borderWidth = border?.top.width ?? 0;
top = borderWidth / 2;
}
if (clip.right) {
final borderWidth = border?.right.width ?? 0;
right = viewSize.width - (borderWidth / 2);
}
if (clip.bottom) {
final borderWidth = border?.bottom.width ?? 0;
bottom = viewSize.height - (borderWidth / 2);
}
canvasWrapper.clipRect(Rect.fromLTRB(left, top, right, bottom));
}
for (var i = 0; i < data.candlestickSpots.length; i++) {
final candlestickSpot = data.candlestickSpots[i];
if (!candlestickSpot.show) {
continue;
}
holder.data.candlestickPainter.paint(
canvasWrapper.canvas,
(x) => getPixelX(x, viewSize, holder),
(y) => getPixelY(y, viewSize, holder),
candlestickSpot,
i,
);
}
if (data.clipData.any) {
canvasWrapper.restore();
}
}
@visibleForTesting
void drawTouchTooltips(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<CandlestickChartData> holder,
) {
final targetData = holder.targetData;
for (var i = 0; i < targetData.candlestickSpots.length; i++) {
if (!targetData.showingTooltipIndicators.contains(i)) {
continue;
}
final candlestickSpot = targetData.candlestickSpots[i];
drawTouchTooltip(
context,
canvasWrapper,
targetData.candlestickTouchData.touchTooltipData,
candlestickSpot,
i,
holder,
);
}
}
@visibleForTesting
void drawTouchTooltip(
BuildContext context,
CanvasWrapper canvasWrapper,
CandlestickTouchTooltipData tooltipData,
CandlestickSpot showOnSpot,
int spotIndex,
PaintHolder<CandlestickChartData> holder,
) {
final viewSize = canvasWrapper.size;
final tooltipItem = tooltipData.getTooltipItems(
holder.data.candlestickPainter,
showOnSpot,
spotIndex,
);
if (tooltipItem == null) {
return;
}
final span = TextSpan(
style: Utils().getThemeAwareTextStyle(context, tooltipItem.textStyle),
text: tooltipItem.text,
children: tooltipItem.children,
);
final drawingTextPainter = TextPainter(
text: span,
textAlign: tooltipItem.textAlign,
textDirection: tooltipItem.textDirection,
textScaler: holder.textScaler,
)..layout(maxWidth: tooltipData.maxContentWidth);
final width = drawingTextPainter.width;
final height = drawingTextPainter.height;
final tooltipOriginPoint = Offset(
getPixelX(showOnSpot.x, viewSize, holder),
getPixelY(
showOnSpot.high,
viewSize,
holder,
),
);
final tooltipWidth = width + tooltipData.tooltipPadding.horizontal;
final tooltipHeight = height + tooltipData.tooltipPadding.vertical;
double tooltipTopPosition;
if (tooltipData.showOnTopOfTheChartBoxArea) {
tooltipTopPosition = 0 - tooltipHeight - tooltipItem.bottomMargin;
} else {
tooltipTopPosition =
tooltipOriginPoint.dy - tooltipHeight - tooltipItem.bottomMargin;
}
final tooltipLeftPosition = getTooltipLeft(
tooltipOriginPoint.dx,
tooltipWidth,
tooltipData.tooltipHorizontalAlignment,
tooltipData.tooltipHorizontalOffset,
);
/// draw the background rect with rounded radius
var rect = Rect.fromLTWH(
tooltipLeftPosition,
tooltipTopPosition,
tooltipWidth,
tooltipHeight,
);
if (tooltipData.fitInsideHorizontally) {
if (rect.left < 0) {
final shiftAmount = 0 - rect.left;
rect = Rect.fromLTRB(
rect.left + shiftAmount,
rect.top,
rect.right + shiftAmount,
rect.bottom,
);
}
if (rect.right > viewSize.width) {
final shiftAmount = rect.right - viewSize.width;
rect = Rect.fromLTRB(
rect.left - shiftAmount,
rect.top,
rect.right - shiftAmount,
rect.bottom,
);
}
}
if (tooltipData.fitInsideVertically) {
if (rect.top < 0) {
final shiftAmount = 0 - rect.top;
rect = Rect.fromLTRB(
rect.left,
rect.top + shiftAmount,
rect.right,
rect.bottom + shiftAmount,
);
}
if (rect.bottom > viewSize.height) {
final shiftAmount = rect.bottom - viewSize.height;
rect = Rect.fromLTRB(
rect.left,
rect.top - shiftAmount,
rect.right,
rect.bottom - shiftAmount,
);
}
}
final roundedRect = RRect.fromRectAndCorners(
rect,
topLeft: tooltipData.tooltipBorderRadius.topLeft,
topRight: tooltipData.tooltipBorderRadius.topRight,
bottomLeft: tooltipData.tooltipBorderRadius.bottomLeft,
bottomRight: tooltipData.tooltipBorderRadius.bottomRight,
);
_bgTouchTooltipPaint.color = tooltipData.getTooltipColor(showOnSpot);
final rotateAngle = tooltipData.rotateAngle;
final rectRotationOffset =
Offset(0, Utils().calculateRotationOffset(rect.size, rotateAngle).dy);
final rectDrawOffset = Offset(roundedRect.left, roundedRect.top);
final textRotationOffset =
Utils().calculateRotationOffset(drawingTextPainter.size, rotateAngle);
final drawOffset = Offset(
rect.center.dx - (drawingTextPainter.width / 2),
rect.topCenter.dy +
tooltipData.tooltipPadding.top -
textRotationOffset.dy +
rectRotationOffset.dy,
);
if (tooltipData.tooltipBorder != BorderSide.none) {
_borderTouchTooltipPaint
..color = tooltipData.tooltipBorder.color
..strokeWidth = tooltipData.tooltipBorder.width;
}
final reverseQuarterTurnsAngle = -holder.data.rotationQuarterTurns * 90;
canvasWrapper.drawRotated(
size: rect.size,
rotationOffset: rectRotationOffset,
drawOffset: rectDrawOffset,
angle: reverseQuarterTurnsAngle + rotateAngle,
drawCallback: () {
canvasWrapper
..drawRRect(roundedRect, _bgTouchTooltipPaint)
..drawRRect(roundedRect, _borderTouchTooltipPaint)
..drawText(drawingTextPainter, drawOffset);
},
);
}
@visibleForTesting
void drawAxisSpotIndicator(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<CandlestickChartData> holder,
) {
final pointIndicator = holder.data.touchedPointIndicator;
if (pointIndicator == null) {
return;
}
final viewSize = canvasWrapper.size;
pointIndicator.painter.paint(
context,
canvasWrapper.canvas,
canvasWrapper.size,
pointIndicator,
(x) => getPixelX(x, viewSize, holder),
(y) => getPixelY(y, viewSize, holder),
holder.data,
);
}
/// Makes a [CandlestickTouchedSpot] based on the provided [localPosition]
///
/// Processes [localPosition] and checks
/// the elements of the chart that are near the offset,
/// then makes a [CandlestickTouchedSpot] from the elements that has been touched.
///
/// Returns null if finds nothing!
CandlestickTouchedSpot? handleTouch(
Offset localPosition,
Size viewSize,
PaintHolder<CandlestickChartData> holder,
) {
final data = holder.data;
final touchedSpots =
<({CandlestickSpot spot, int index, double distance})>[];
for (var i = data.candlestickSpots.length - 1; i >= 0; i--) {
// Reverse the loop to check the topmost spot first
final spot = data.candlestickSpots[i];
if (!spot.show) {
continue;
}
final spotPixelX = getPixelX(spot.x, viewSize, holder);
final (hit, distance) = holder.targetData.candlestickPainter.hitTest(
spot,
spotPixelX,
localPosition.dx,
holder.data.candlestickTouchData.touchSpotThreshold,
);
if (hit) {
touchedSpots.add(
(
spot: spot,
index: i,
distance: distance,
),
);
}
}
if (touchedSpots.isEmpty) {
return null;
}
// Sort the touched spots by distance
touchedSpots.sort((a, b) => a.distance.compareTo(b.distance));
final closestSpot = touchedSpots.first;
return CandlestickTouchedSpot(closestSpot.spot, closestSpot.index);
}
}

View File

@@ -0,0 +1,148 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/chart/base/base_chart/render_base_chart.dart';
import 'package:fl_chart/src/chart/candlestick_chart/candlestick_chart_painter.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:flutter/cupertino.dart';
// coverage:ignore-start
/// Low level ScatterChart Widget.
class CandlestickChartLeaf extends LeafRenderObjectWidget {
const CandlestickChartLeaf({
super.key,
required this.data,
required this.targetData,
required this.chartVirtualRect,
required this.canBeScaled,
});
final CandlestickChartData data;
final CandlestickChartData targetData;
final Rect? chartVirtualRect;
final bool canBeScaled;
@override
RenderCandlestickChart createRenderObject(BuildContext context) =>
RenderCandlestickChart(
context,
data,
targetData,
MediaQuery.of(context).textScaler,
chartVirtualRect,
canBeScaled: canBeScaled,
);
@override
void updateRenderObject(
BuildContext context,
RenderCandlestickChart renderObject,
) {
renderObject
..data = data
..targetData = targetData
..textScaler = MediaQuery.of(context).textScaler
..buildContext = context
..chartVirtualRect = chartVirtualRect
..canBeScaled = canBeScaled;
}
}
// coverage:ignore-end
/// Renders our ScatterChart, also handles hitTest.
class RenderCandlestickChart extends RenderBaseChart<CandlestickTouchResponse> {
RenderCandlestickChart(
BuildContext context,
CandlestickChartData data,
CandlestickChartData targetData,
TextScaler textScaler,
Rect? chartVirtualRect, {
required bool canBeScaled,
}) : _data = data,
_targetData = targetData,
_textScaler = textScaler,
_chartVirtualRect = chartVirtualRect,
super(
targetData.candlestickTouchData,
context,
canBeScaled: canBeScaled,
);
CandlestickChartData get data => _data;
CandlestickChartData _data;
set data(CandlestickChartData value) {
if (_data == value) return;
_data = value;
markNeedsPaint();
}
CandlestickChartData get targetData => _targetData;
CandlestickChartData _targetData;
set targetData(CandlestickChartData value) {
if (_targetData == value) return;
_targetData = value;
super.updateBaseTouchData(_targetData.candlestickTouchData);
markNeedsPaint();
}
TextScaler get textScaler => _textScaler;
TextScaler _textScaler;
set textScaler(TextScaler value) {
if (_textScaler == value) return;
_textScaler = value;
markNeedsPaint();
}
Rect? get chartVirtualRect => _chartVirtualRect;
Rect? _chartVirtualRect;
set chartVirtualRect(Rect? value) {
if (_chartVirtualRect == value) return;
_chartVirtualRect = value;
markNeedsPaint();
}
// We couldn't mock [size] property of this class, that's why we have this
@visibleForTesting
Size? mockTestSize;
@visibleForTesting
CandlestickChartPainter painter = CandlestickChartPainter();
PaintHolder<CandlestickChartData> get paintHolder =>
PaintHolder(data, targetData, textScaler, chartVirtualRect);
@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas
..save()
..translate(offset.dx, offset.dy);
painter.paint(
buildContext,
CanvasWrapper(canvas, mockTestSize ?? size),
paintHolder,
);
canvas.restore();
}
@override
CandlestickTouchResponse getResponseAtLocation(Offset localPosition) {
final chartSize = mockTestSize ?? size;
return CandlestickTouchResponse(
touchLocation: localPosition,
touchChartCoordinate: painter.getChartCoordinateFromPixel(
localPosition,
chartSize,
paintHolder,
),
touchedSpot: painter.handleTouch(
localPosition,
chartSize,
paintHolder,
),
);
}
}

View File

@@ -0,0 +1,171 @@
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart';
import 'package:fl_chart/src/chart/base/axis_chart/scale_axis.dart';
import 'package:fl_chart/src/chart/base/axis_chart/transformation_config.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_data.dart';
import 'package:fl_chart/src/chart/base/base_chart/fl_touch_event.dart';
import 'package:fl_chart/src/chart/line_chart/line_chart_data.dart';
import 'package:fl_chart/src/chart/line_chart/line_chart_helper.dart';
import 'package:fl_chart/src/chart/line_chart/line_chart_renderer.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
/// Renders a line chart as a widget, using provided [LineChartData].
class LineChart extends ImplicitlyAnimatedWidget {
/// [data] determines how the [LineChart] should be look like,
/// when you make any change in the [LineChartData], it updates
/// new values with animation, and duration is [duration].
/// also you can change the [curve]
/// which default is [Curves.linear].
const LineChart(
this.data, {
this.chartRendererKey,
super.key,
super.duration = const Duration(milliseconds: 150),
super.curve = Curves.linear,
this.transformationConfig = const FlTransformationConfig(),
});
/// Determines how the [LineChart] should be look like.
final LineChartData data;
/// {@macro fl_chart.AxisChartScaffoldWidget.transformationConfig}
final FlTransformationConfig transformationConfig;
/// We pass this key to our renderers which are supposed to
/// render the chart itself (without anything around the chart).
final Key? chartRendererKey;
/// Creates a [_LineChartState]
@override
_LineChartState createState() => _LineChartState();
}
class _LineChartState extends AnimatedWidgetBaseState<LineChart> {
/// we handle under the hood animations (implicit animations) via this tween,
/// it lerps between the old [LineChartData] to the new one.
LineChartDataTween? _lineChartDataTween;
/// If [LineTouchData.handleBuiltInTouches] is true, we override the callback to handle touches internally,
/// but we need to keep the provided callback to notify it too.
BaseTouchCallback<LineTouchResponse>? _providedTouchCallback;
final List<ShowingTooltipIndicators> _showingTouchedTooltips = [];
final Map<int, List<int>> _showingTouchedIndicators = {};
final _lineChartHelper = LineChartHelper();
@override
Widget build(BuildContext context) {
final showingData = _getData();
return AxisChartScaffoldWidget(
transformationConfig: widget.transformationConfig,
chartBuilder: (context, chartVirtualRect) => LineChartLeaf(
data: _withTouchedIndicators(
_lineChartDataTween!.evaluate(animation),
),
targetData: _withTouchedIndicators(showingData),
key: widget.chartRendererKey,
chartVirtualRect: chartVirtualRect,
canBeScaled: widget.transformationConfig.scaleAxis != FlScaleAxis.none,
),
data: showingData,
);
}
LineChartData _withTouchedIndicators(LineChartData lineChartData) {
if (!lineChartData.lineTouchData.enabled ||
!lineChartData.lineTouchData.handleBuiltInTouches) {
return lineChartData;
}
return lineChartData.copyWith(
showingTooltipIndicators: _showingTouchedTooltips,
lineBarsData: lineChartData.lineBarsData.map((barData) {
final index = lineChartData.lineBarsData.indexOf(barData);
return barData.copyWith(
showingIndicators: _showingTouchedIndicators[index] ?? [],
);
}).toList(),
);
}
LineChartData _getData() {
var newData = widget.data;
/// Calculate minX, maxX, minY, maxY for [LineChartData] if they are null,
/// it is necessary to render the chart correctly.
if (newData.minX.isNaN ||
newData.maxX.isNaN ||
newData.minY.isNaN ||
newData.maxY.isNaN) {
final (minX, maxX, minY, maxY) = _lineChartHelper.calculateMaxAxisValues(
newData.lineBarsData,
);
newData = newData.copyWith(
minX: newData.minX.isNaN ? minX : newData.minX,
maxX: newData.maxX.isNaN ? maxX : newData.maxX,
minY: newData.minY.isNaN ? minY : newData.minY,
maxY: newData.maxY.isNaN ? maxY : newData.maxY,
);
}
final lineTouchData = newData.lineTouchData;
if (lineTouchData.enabled && lineTouchData.handleBuiltInTouches) {
_providedTouchCallback = lineTouchData.touchCallback;
newData = newData.copyWith(
lineTouchData:
newData.lineTouchData.copyWith(touchCallback: _handleBuiltInTouch),
);
}
return newData;
}
void _handleBuiltInTouch(
FlTouchEvent event,
LineTouchResponse? touchResponse,
) {
if (!mounted) {
return;
}
_providedTouchCallback?.call(event, touchResponse);
if (!event.isInterestedForInteractions ||
touchResponse?.lineBarSpots == null ||
touchResponse!.lineBarSpots!.isEmpty) {
setState(() {
_showingTouchedTooltips.clear();
_showingTouchedIndicators.clear();
});
return;
}
setState(() {
final sortedLineSpots = List.of(touchResponse.lineBarSpots!)
..sort((spot1, spot2) => spot2.y.compareTo(spot1.y));
_showingTouchedIndicators.clear();
for (var i = 0; i < touchResponse.lineBarSpots!.length; i++) {
final touchedBarSpot = touchResponse.lineBarSpots![i];
final barPos = touchedBarSpot.barIndex;
_showingTouchedIndicators[barPos] = [touchedBarSpot.spotIndex];
}
_showingTouchedTooltips
..clear()
..add(ShowingTooltipIndicators(sortedLineSpots));
});
}
@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_lineChartDataTween = visitor(
_lineChartDataTween,
_getData(),
(dynamic value) =>
LineChartDataTween(begin: value as LineChartData, end: widget.data),
) as LineChartDataTween?;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
import 'package:fl_chart/fl_chart.dart';
/// Contains anything that helps LineChart works
class LineChartHelper {
/// Calculates the [minX], [maxX], [minY], and [maxY] values of
/// the provided [lineBarsData].
(double minX, double maxX, double minY, double maxY) calculateMaxAxisValues(
List<LineChartBarData> lineBarsData,
) {
if (lineBarsData.isEmpty) {
return (0, 0, 0, 0);
}
final LineChartBarData lineBarData;
try {
lineBarData =
lineBarsData.firstWhere((element) => element.spots.isNotEmpty);
} catch (_) {
// There is no lineBarData with at least one spot
return (0, 0, 0, 0);
}
final FlSpot firstValidSpot;
try {
firstValidSpot =
lineBarData.spots.firstWhere((element) => element != FlSpot.nullSpot);
} catch (_) {
// There is no valid spot
return (0, 0, 0, 0);
}
var minX = firstValidSpot.x;
var maxX = firstValidSpot.x;
var minY = firstValidSpot.y;
var maxY = firstValidSpot.y;
for (final barData in lineBarsData) {
if (barData.spots.isEmpty) {
continue;
}
if (barData.mostRightSpot.x > maxX) {
maxX = barData.mostRightSpot.x;
}
if (barData.mostLeftSpot.x < minX) {
minX = barData.mostLeftSpot.x;
}
if (barData.mostTopSpot.y > maxY) {
maxY = barData.mostTopSpot.y;
}
if (barData.mostBottomSpot.y < minY) {
minY = barData.mostBottomSpot.y;
}
}
return (minX, maxX, minY, maxY);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/chart/base/base_chart/render_base_chart.dart';
import 'package:fl_chart/src/chart/line_chart/line_chart_painter.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
// coverage:ignore-start
/// Low level LineChart Widget.
class LineChartLeaf extends LeafRenderObjectWidget {
const LineChartLeaf({
super.key,
required this.data,
required this.targetData,
required this.canBeScaled,
required this.chartVirtualRect,
});
final LineChartData data;
final LineChartData targetData;
final Rect? chartVirtualRect;
final bool canBeScaled;
@override
RenderLineChart createRenderObject(BuildContext context) => RenderLineChart(
context,
data,
targetData,
MediaQuery.of(context).textScaler,
chartVirtualRect,
canBeScaled: canBeScaled,
);
@override
void updateRenderObject(BuildContext context, RenderLineChart renderObject) {
renderObject
..data = data
..targetData = targetData
..textScaler = MediaQuery.of(context).textScaler
..buildContext = context
..chartVirtualRect = chartVirtualRect
..canBeScaled = canBeScaled;
}
}
// coverage:ignore-end
/// Renders our LineChart, also handles hitTest.
class RenderLineChart extends RenderBaseChart<LineTouchResponse> {
RenderLineChart(
BuildContext context,
LineChartData data,
LineChartData targetData,
TextScaler textScaler,
Rect? chartVirtualRect, {
required bool canBeScaled,
}) : _data = data,
_targetData = targetData,
_textScaler = textScaler,
_chartVirtualRect = chartVirtualRect,
super(
targetData.lineTouchData,
context,
canBeScaled: canBeScaled,
);
LineChartData get data => _data;
LineChartData _data;
set data(LineChartData value) {
if (_data == value) return;
_data = value;
markNeedsPaint();
}
LineChartData get targetData => _targetData;
LineChartData _targetData;
set targetData(LineChartData value) {
if (_targetData == value) return;
_targetData = value;
super.updateBaseTouchData(_targetData.lineTouchData);
markNeedsPaint();
}
TextScaler get textScaler => _textScaler;
TextScaler _textScaler;
set textScaler(TextScaler value) {
if (_textScaler == value) return;
_textScaler = value;
markNeedsPaint();
}
Rect? get chartVirtualRect => _chartVirtualRect;
Rect? _chartVirtualRect;
set chartVirtualRect(Rect? value) {
if (_chartVirtualRect == value) return;
_chartVirtualRect = value;
markNeedsPaint();
}
// We couldn't mock [size] property of this class, that's why we have this
@visibleForTesting
Size? mockTestSize;
@visibleForTesting
LineChartPainter painter = LineChartPainter();
PaintHolder<LineChartData> get paintHolder =>
PaintHolder(data, targetData, textScaler, chartVirtualRect);
@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas
..save()
..translate(offset.dx, offset.dy);
painter.paint(
buildContext,
CanvasWrapper(canvas, mockTestSize ?? size),
paintHolder,
);
canvas.restore();
}
@override
LineTouchResponse getResponseAtLocation(Offset localPosition) {
final chartSize = mockTestSize ?? size;
return LineTouchResponse(
touchLocation: localPosition,
touchChartCoordinate: painter.getChartCoordinateFromPixel(
localPosition,
chartSize,
paintHolder,
),
lineBarSpots: painter.handleTouch(
localPosition,
chartSize,
paintHolder,
),
);
}
}

View File

@@ -0,0 +1,117 @@
import 'package:fl_chart/src/chart/pie_chart/pie_chart_data.dart';
import 'package:fl_chart/src/chart/pie_chart/pie_chart_renderer.dart';
import 'package:flutter/material.dart';
/// Renders a pie chart as a widget, using provided [PieChartData].
class PieChart extends ImplicitlyAnimatedWidget {
/// [data] determines how the [PieChart] should be look like,
/// when you make any change in the [PieChartData], it updates
/// new values with animation, and duration is [duration].
/// also you can change the [curve]
/// which default is [Curves.linear].
const PieChart(
this.data, {
super.key,
@Deprecated('Please use [duration] instead')
Duration? swapAnimationDuration,
Duration duration = const Duration(milliseconds: 150),
@Deprecated('Please use [curve] instead') Curve? swapAnimationCurve,
Curve curve = Curves.linear,
}) : super(
duration: swapAnimationDuration ?? duration,
curve: swapAnimationCurve ?? curve,
);
/// Default duration to reuse externally.
static const defaultDuration = Duration(milliseconds: 150);
/// Determines how the [PieChart] should be look like.
final PieChartData data;
/// Creates a [_PieChartState]
@override
_PieChartState createState() => _PieChartState();
}
class _PieChartState extends AnimatedWidgetBaseState<PieChart> {
/// We handle under the hood animations (implicit animations) via this tween,
/// it lerps between the old [PieChartData] to the new one.
PieChartDataTween? _pieChartDataTween;
@override
void initState() {
/// Make sure that [_widgetsPositionHandler] is updated.
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
if (mounted) {
setState(() {});
}
});
super.initState();
}
@override
Widget build(BuildContext context) {
final showingData = _getData();
return PieChartLeaf(
data: _pieChartDataTween!.evaluate(animation),
targetData: showingData,
);
}
/// if builtIn touches are enabled, we should recreate our [pieChartData]
/// to handle built in touches
PieChartData _getData() {
return widget.data;
}
@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_pieChartDataTween = visitor(
_pieChartDataTween,
widget.data,
(dynamic value) =>
PieChartDataTween(begin: value as PieChartData, end: widget.data),
) as PieChartDataTween?;
}
}
/// Positions the badge widgets on their respective sections.
class BadgeWidgetsDelegate extends MultiChildLayoutDelegate {
BadgeWidgetsDelegate({
required this.badgeWidgetsCount,
required this.badgeWidgetsOffsets,
});
final int badgeWidgetsCount;
final Map<int, Offset> badgeWidgetsOffsets;
@override
void performLayout(Size size) {
for (var index = 0; index < badgeWidgetsCount; index++) {
final key = badgeWidgetsOffsets.keys.elementAt(index);
final finalSize = layoutChild(
key,
BoxConstraints(
maxWidth: size.width,
maxHeight: size.height,
),
);
positionChild(
key,
Offset(
badgeWidgetsOffsets[key]!.dx - (finalSize.width / 2),
badgeWidgetsOffsets[key]!.dy - (finalSize.height / 2),
),
);
}
}
@override
bool shouldRelayout(BadgeWidgetsDelegate oldDelegate) {
return oldDelegate.badgeWidgetsOffsets != badgeWidgetsOffsets;
}
}

View File

@@ -0,0 +1,407 @@
// coverage:ignore-file
import 'dart:ui';
import 'package:equatable/equatable.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/utils/lerp.dart';
import 'package:flutter/material.dart';
/// [PieChart] needs this class to render itself.
///
/// It holds data needed to draw a pie chart,
/// including pie sections, colors, ...
class PieChartData extends BaseChartData with EquatableMixin {
/// [PieChart] draws some [sections] in a circle,
/// and applies free space with radius [centerSpaceRadius],
/// and color [centerSpaceColor] in the center of the circle,
/// if you don't want it, set [centerSpaceRadius] to zero.
///
/// It draws [sections] from zero degree (right side of the circle) clockwise,
/// you can change the starting point, by changing [startDegreeOffset] (in degrees).
///
/// You can define a gap between [sections] by setting [sectionsSpace].
///
/// You can modify [pieTouchData] to customize touch behaviors and responses.
PieChartData({
List<PieChartSectionData>? sections,
double? centerSpaceRadius,
Color? centerSpaceColor,
double? sectionsSpace,
double? startDegreeOffset,
PieTouchData? pieTouchData,
FlBorderData? borderData,
bool? titleSunbeamLayout,
}) : sections = sections ?? const [],
centerSpaceRadius = centerSpaceRadius ?? double.infinity,
centerSpaceColor = centerSpaceColor ?? Colors.transparent,
sectionsSpace = sectionsSpace ?? 2,
startDegreeOffset = startDegreeOffset ?? 0,
pieTouchData = pieTouchData ?? PieTouchData(),
titleSunbeamLayout = titleSunbeamLayout ?? false,
super(
borderData: borderData ?? FlBorderData(show: false),
);
/// Defines showing sections of the [PieChart].
final List<PieChartSectionData> sections;
/// Radius of free space in center of the circle.
final double centerSpaceRadius;
/// Color of free space in center of the circle.
final Color centerSpaceColor;
/// Defines gap between sections.
///
/// Does not work on html-renderer,
/// https://github.com/imaNNeo/fl_chart/issues/955
final double sectionsSpace;
/// [PieChart] draws [sections] from zero degree (right side of the circle) clockwise.
final double startDegreeOffset;
/// Handles touch behaviors and responses.
final PieTouchData pieTouchData;
/// Whether to rotate the titles on each section of the chart
final bool titleSunbeamLayout;
/// We hold this value to determine weight of each [PieChartSectionData.value].
double get sumValue => sections
.map((data) => data.value)
.reduce((first, second) => first + second);
/// Copies current [PieChartData] to a new [PieChartData],
/// and replaces provided values.
PieChartData copyWith({
List<PieChartSectionData>? sections,
double? centerSpaceRadius,
Color? centerSpaceColor,
double? sectionsSpace,
double? startDegreeOffset,
PieTouchData? pieTouchData,
FlBorderData? borderData,
bool? titleSunbeamLayout,
}) =>
PieChartData(
sections: sections ?? this.sections,
centerSpaceRadius: centerSpaceRadius ?? this.centerSpaceRadius,
centerSpaceColor: centerSpaceColor ?? this.centerSpaceColor,
sectionsSpace: sectionsSpace ?? this.sectionsSpace,
startDegreeOffset: startDegreeOffset ?? this.startDegreeOffset,
pieTouchData: pieTouchData ?? this.pieTouchData,
borderData: borderData ?? this.borderData,
titleSunbeamLayout: titleSunbeamLayout ?? this.titleSunbeamLayout,
);
/// Lerps a [BaseChartData] based on [t] value, check [Tween.lerp].
@override
PieChartData lerp(BaseChartData a, BaseChartData b, double t) {
if (a is PieChartData && b is PieChartData) {
return PieChartData(
borderData: FlBorderData.lerp(a.borderData, b.borderData, t),
centerSpaceColor: Color.lerp(a.centerSpaceColor, b.centerSpaceColor, t),
centerSpaceRadius: lerpDoubleAllowInfinity(
a.centerSpaceRadius,
b.centerSpaceRadius,
t,
),
pieTouchData: b.pieTouchData,
sectionsSpace: lerpDouble(a.sectionsSpace, b.sectionsSpace, t),
startDegreeOffset:
lerpDouble(a.startDegreeOffset, b.startDegreeOffset, t),
sections: lerpPieChartSectionDataList(a.sections, b.sections, t),
titleSunbeamLayout: b.titleSunbeamLayout,
);
} else {
throw Exception('Illegal State');
}
}
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
sections,
centerSpaceRadius,
centerSpaceColor,
pieTouchData,
sectionsSpace,
startDegreeOffset,
borderData,
titleSunbeamLayout,
];
}
/// Holds data related to drawing each [PieChart] section.
class PieChartSectionData with EquatableMixin {
/// [PieChart] draws section from right side of the circle (0 degrees),
/// each section have a [value] that determines how much it should occupy,
/// this is depends on sum of all sections, each section should
/// occupy ([value] / sumValues) * 360 degrees.
///
/// It draws this section with filled [color], and [radius].
///
/// If [showTitle] is true, it draws a title at the middle of section,
/// you can set the text using [title], and set the style using [titleStyle],
/// by default it draws texts at the middle of section, but you can change the
/// [titlePositionPercentageOffset] to have your desire design,
/// it should be between 0.0 to 1.0,
/// 0.0 means near the center,
/// 1.0 means near the outside of the [PieChart].
///
/// If [badgeWidget] is not null, it draws a widget at the middle of section,
/// by default it draws the widget at the middle of section, but you can change the
/// [badgePositionPercentageOffset] to have your desire design,
/// the value works the same way as [titlePositionPercentageOffset].
PieChartSectionData({
double? value,
Color? color,
this.gradient,
double? radius,
bool? showTitle,
this.titleStyle,
String? title,
BorderSide? borderSide,
this.badgeWidget,
double? titlePositionPercentageOffset,
double? badgePositionPercentageOffset,
}) : value = value ?? 10,
color = color ?? Colors.cyan,
radius = radius ?? 40,
showTitle = showTitle ?? true,
title = title ?? (value == null ? '' : value.toString()),
borderSide = borderSide ?? const BorderSide(width: 0),
titlePositionPercentageOffset = titlePositionPercentageOffset ?? 0.5,
badgePositionPercentageOffset = badgePositionPercentageOffset ?? 0.5;
/// It determines how much space it should occupy around the circle.
///
/// This is depends on sum of all sections, each section should
/// occupy ([value] / sumValues) * 360 degrees.
///
/// value can not be null.
final double value;
/// Defines the color of section.
final Color color;
/// Defines the gradient of section. If specified, overrides the color setting.
final Gradient? gradient;
/// Defines the radius of section.
final double radius;
/// Defines show or hide the title of section.
final bool showTitle;
/// Defines style of showing title of section.
final TextStyle? titleStyle;
/// Defines text of showing title at the middle of section.
final String title;
/// Defines border stroke around the section
final BorderSide borderSide;
/// Defines a widget that represents the section.
///
/// This can be anything from a text, an image, an animation, and even a combination of widgets.
/// Use AnimatedWidgets to animate this widget.
final Widget? badgeWidget;
/// Defines position of showing title in the section.
///
/// It should be between 0.0 to 1.0,
/// 0.0 means near the center,
/// 1.0 means near the outside of the [PieChart].
final double titlePositionPercentageOffset;
/// Defines position of badge widget in the section.
///
/// It should be between 0.0 to 1.0,
/// 0.0 means near the center,
/// 1.0 means near the outside of the [PieChart].
final double badgePositionPercentageOffset;
/// Copies current [PieChartSectionData] to a new [PieChartSectionData],
/// and replaces provided values.
PieChartSectionData copyWith({
double? value,
Color? color,
Gradient? gradient,
double? radius,
bool? showTitle,
TextStyle? titleStyle,
String? title,
BorderSide? borderSide,
Widget? badgeWidget,
double? titlePositionPercentageOffset,
double? badgePositionPercentageOffset,
}) =>
PieChartSectionData(
value: value ?? this.value,
color: color ?? this.color,
gradient: gradient ?? this.gradient,
radius: radius ?? this.radius,
showTitle: showTitle ?? this.showTitle,
titleStyle: titleStyle ?? this.titleStyle,
title: title ?? this.title,
borderSide: borderSide ?? this.borderSide,
badgeWidget: badgeWidget ?? this.badgeWidget,
titlePositionPercentageOffset:
titlePositionPercentageOffset ?? this.titlePositionPercentageOffset,
badgePositionPercentageOffset:
badgePositionPercentageOffset ?? this.badgePositionPercentageOffset,
);
/// Lerps a [PieChartSectionData] based on [t] value, check [Tween.lerp].
static PieChartSectionData lerp(
PieChartSectionData a,
PieChartSectionData b,
double t,
) =>
PieChartSectionData(
value: lerpDouble(a.value, b.value, t),
color: Color.lerp(a.color, b.color, t),
gradient: Gradient.lerp(a.gradient, b.gradient, t),
radius: lerpDouble(a.radius, b.radius, t),
showTitle: b.showTitle,
titleStyle: TextStyle.lerp(a.titleStyle, b.titleStyle, t),
title: b.title,
borderSide: BorderSide.lerp(a.borderSide, b.borderSide, t),
badgeWidget: b.badgeWidget,
titlePositionPercentageOffset: lerpDouble(
a.titlePositionPercentageOffset,
b.titlePositionPercentageOffset,
t,
),
badgePositionPercentageOffset: lerpDouble(
a.badgePositionPercentageOffset,
b.badgePositionPercentageOffset,
t,
),
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
value,
color,
gradient,
radius,
showTitle,
titleStyle,
title,
borderSide,
badgeWidget,
titlePositionPercentageOffset,
badgePositionPercentageOffset,
];
}
/// Holds data to handle touch events, and touch responses in the [PieChart].
///
/// There is a touch flow, explained [here](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/handle_touches.md)
/// in a simple way, each chart's renderer captures the touch events, and passes the pointerEvent
/// to the painter, and gets touched spot, and wraps it into a concrete [PieTouchResponse].
class PieTouchData extends FlTouchData<PieTouchResponse> with EquatableMixin {
/// You can disable or enable the touch system using [enabled] flag,
///
/// [touchCallback] notifies you about the happened touch/pointer events.
/// It gives you a [FlTouchEvent] which is the happened event such as [FlPointerHoverEvent], [FlTapUpEvent], ...
/// It also gives you a [PieTouchResponse] which contains information
/// about the elements that has touched.
///
/// Using [mouseCursorResolver] you can change the mouse cursor
/// based on the provided [FlTouchEvent] and [PieTouchResponse]
PieTouchData({
bool? enabled,
BaseTouchCallback<PieTouchResponse>? touchCallback,
MouseCursorResolver<PieTouchResponse>? mouseCursorResolver,
Duration? longPressDuration,
}) : super(
enabled ?? true,
touchCallback,
mouseCursorResolver,
longPressDuration,
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
enabled,
touchCallback,
mouseCursorResolver,
longPressDuration,
];
}
class PieTouchedSection with EquatableMixin {
/// This class Contains [touchedSection], [touchedSectionIndex] that tells
/// you touch happened on which section,
/// [touchAngle] gives you angle of touch,
/// and [touchRadius] gives you radius of the touch.
PieTouchedSection(
this.touchedSection,
this.touchedSectionIndex,
this.touchAngle,
this.touchRadius,
);
/// touch happened on this section
final PieChartSectionData? touchedSection;
/// touch happened on this position
final int touchedSectionIndex;
/// touch happened with this angle on the [PieChart]
final double touchAngle;
/// touch happened with this radius on the [PieChart]
final double touchRadius;
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
touchedSection,
touchedSectionIndex,
touchAngle,
touchRadius,
];
}
/// Holds information about touch response in the [PieChart].
///
/// You can override [PieTouchData.touchCallback] to handle touch events,
/// it gives you a [PieTouchResponse] and you can do whatever you want.
class PieTouchResponse extends BaseTouchResponse {
/// If touch happens, [PieChart] processes it internally and passes out a [PieTouchResponse]
PieTouchResponse({
required super.touchLocation,
required this.touchedSection,
});
/// Contains information about touched section, like index, angle, radius, ...
final PieTouchedSection? touchedSection;
/// Copies current [PieTouchResponse] to a new [PieTouchResponse],
/// and replaces provided values.
PieTouchResponse copyWith({
Offset? touchLocation,
PieTouchedSection? touchedSection,
}) =>
PieTouchResponse(
touchLocation: touchLocation ?? this.touchLocation,
touchedSection: touchedSection ?? this.touchedSection,
);
}
/// It lerps a [PieChartData] to another [PieChartData] (handles animation for updating values)
class PieChartDataTween extends Tween<PieChartData> {
PieChartDataTween({required PieChartData begin, required PieChartData end})
: super(begin: begin, end: end);
/// Lerps a [PieChartData] based on [t] value, check [Tween.lerp].
@override
PieChartData lerp(double t) => begin!.lerp(begin!, end!, t);
}

View File

@@ -0,0 +1,21 @@
import 'package:fl_chart/src/chart/pie_chart/pie_chart_data.dart';
import 'package:flutter/widgets.dart';
extension PieChartSectionDataListExtension on List<PieChartSectionData> {
List<Widget> toWidgets() {
final widgets = List<Widget>.filled(length, Container());
var allWidgetsAreNull = true;
asMap().entries.forEach((e) {
final index = e.key;
final section = e.value;
if (section.badgeWidget != null) {
widgets[index] = section.badgeWidget!;
allWidgetsAreNull = false;
}
});
if (allWidgetsAreNull) {
return List.empty();
}
return widgets;
}
}

View File

@@ -0,0 +1,550 @@
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/chart/base/line.dart';
import 'package:fl_chart/src/chart/pie_chart/pie_chart_data.dart';
import 'package:fl_chart/src/extensions/paint_extension.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:fl_chart/src/utils/utils.dart';
import 'package:flutter/material.dart';
/// Paints [PieChartData] in the canvas, it can be used in a [CustomPainter]
class PieChartPainter extends BaseChartPainter<PieChartData> {
/// Paints [dataList] into canvas, it is the animating [PieChartData],
/// [targetData] is the animation's target and remains the same
/// during animation, then we should use it when we need to show
/// tooltips or something like that, because [dataList] is changing constantly.
///
/// [textScale] used for scaling texts inside the chart,
/// parent can use [MediaQuery.textScaleFactor] to respect
/// the system's font size.
PieChartPainter() : super() {
_sectionPaint = Paint()..style = PaintingStyle.stroke;
_sectionSaveLayerPaint = Paint();
_sectionStrokePaint = Paint()..style = PaintingStyle.stroke;
_centerSpacePaint = Paint()..style = PaintingStyle.fill;
_clipPaint = Paint();
}
late Paint _sectionPaint;
late Paint _sectionSaveLayerPaint;
late Paint _sectionStrokePaint;
late Paint _centerSpacePaint;
late Paint _clipPaint;
/// Paints [PieChartData] into the provided canvas.
@override
void paint(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<PieChartData> holder,
) {
super.paint(context, canvasWrapper, holder);
final data = holder.data;
if (data.sections.isEmpty) {
return;
}
final sectionsAngle = calculateSectionsAngle(data.sections, data.sumValue);
final centerRadius = calculateCenterRadius(canvasWrapper.size, holder);
drawCenterSpace(canvasWrapper, centerRadius, holder);
drawSections(canvasWrapper, sectionsAngle, centerRadius, holder);
drawTexts(context, canvasWrapper, holder, centerRadius);
}
@visibleForTesting
List<double> calculateSectionsAngle(
List<PieChartSectionData> sections,
double sumValue,
) {
if (sumValue == 0) {
return List<double>.filled(sections.length, 0);
}
return sections.map((section) {
return 360 * (section.value / sumValue);
}).toList();
}
@visibleForTesting
void drawCenterSpace(
CanvasWrapper canvasWrapper,
double centerRadius,
PaintHolder<PieChartData> holder,
) {
final data = holder.data;
final viewSize = canvasWrapper.size;
final centerX = viewSize.width / 2;
final centerY = viewSize.height / 2;
_centerSpacePaint.color = data.centerSpaceColor;
canvasWrapper.drawCircle(
Offset(centerX, centerY),
centerRadius,
_centerSpacePaint,
);
}
@visibleForTesting
void drawSections(
CanvasWrapper canvasWrapper,
List<double> sectionsAngle,
double centerRadius,
PaintHolder<PieChartData> holder,
) {
final data = holder.data;
final viewSize = canvasWrapper.size;
final center = Offset(viewSize.width / 2, viewSize.height / 2);
var tempAngle = data.startDegreeOffset;
for (var i = 0; i < data.sections.length; i++) {
final section = data.sections[i];
if (section.value == 0) {
continue;
}
final sectionDegree = sectionsAngle[i];
if (sectionDegree == 360) {
final radius = centerRadius + section.radius / 2;
final rect = Rect.fromCircle(center: center, radius: radius);
_sectionPaint
..setColorOrGradient(
section.color,
section.gradient,
rect,
)
..strokeWidth = section.radius
..style = PaintingStyle.fill;
final bounds = Rect.fromCircle(
center: center,
radius: centerRadius + section.radius,
);
canvasWrapper
..saveLayer(bounds, _sectionSaveLayerPaint)
..drawCircle(
center,
centerRadius + section.radius,
_sectionPaint..blendMode = BlendMode.srcOver,
)
..drawCircle(
center,
centerRadius,
_sectionPaint..blendMode = BlendMode.srcOut,
)
..restore();
_sectionPaint.blendMode = BlendMode.srcOver;
if (section.borderSide.width != 0.0 &&
section.borderSide.color.a != 0.0) {
_sectionStrokePaint
..strokeWidth = section.borderSide.width
..color = section.borderSide.color;
// Outer
canvasWrapper
..drawCircle(
center,
centerRadius + section.radius - (section.borderSide.width / 2),
_sectionStrokePaint,
)
// Inner
..drawCircle(
center,
centerRadius + (section.borderSide.width / 2),
_sectionStrokePaint,
);
}
return;
}
final sectionPath = generateSectionPath(
section,
data.sectionsSpace,
tempAngle,
sectionDegree,
center,
centerRadius,
);
drawSection(section, sectionPath, canvasWrapper);
drawSectionStroke(section, sectionPath, canvasWrapper, viewSize);
tempAngle += sectionDegree;
}
}
/// Generates a path around a section
@visibleForTesting
Path generateSectionPath(
PieChartSectionData section,
double sectionSpace,
double tempAngle,
double sectionDegree,
Offset center,
double centerRadius,
) {
final sectionRadiusRect = Rect.fromCircle(
center: center,
radius: centerRadius + section.radius,
);
final centerRadiusRect = Rect.fromCircle(
center: center,
radius: centerRadius,
);
final startRadians = Utils().radians(tempAngle);
final sweepRadians = Utils().radians(sectionDegree);
final endRadians = startRadians + sweepRadians;
final startLineDirection =
Offset(math.cos(startRadians), math.sin(startRadians));
final startLineFrom = center + startLineDirection * centerRadius;
final startLineTo = startLineFrom + startLineDirection * section.radius;
final startLine = Line(startLineFrom, startLineTo);
final endLineDirection = Offset(math.cos(endRadians), math.sin(endRadians));
final endLineFrom = center + endLineDirection * centerRadius;
final endLineTo = endLineFrom + endLineDirection * section.radius;
final endLine = Line(endLineFrom, endLineTo);
var sectionPath = Path()
..moveTo(startLine.from.dx, startLine.from.dy)
..lineTo(startLine.to.dx, startLine.to.dy)
..arcTo(sectionRadiusRect, startRadians, sweepRadians, false)
..lineTo(endLine.from.dx, endLine.from.dy)
..arcTo(centerRadiusRect, endRadians, -sweepRadians, false)
..moveTo(startLine.from.dx, startLine.from.dy)
..close();
/// Subtract section space from the sectionPath
if (sectionSpace != 0) {
final startLineSeparatorPath = createRectPathAroundLine(
Line(startLineFrom, startLineTo),
sectionSpace,
);
try {
sectionPath = Path.combine(
PathOperation.difference,
sectionPath,
startLineSeparatorPath,
);
} catch (_) {
/// It's a flutter engine issue with [Path.combine] in web-html renderer
/// https://github.com/imaNNeo/fl_chart/issues/955
}
final endLineSeparatorPath =
createRectPathAroundLine(Line(endLineFrom, endLineTo), sectionSpace);
try {
sectionPath = Path.combine(
PathOperation.difference,
sectionPath,
endLineSeparatorPath,
);
} catch (_) {
/// It's a flutter engine issue with [Path.combine] in web-html renderer
/// https://github.com/imaNNeo/fl_chart/issues/955
}
}
return sectionPath;
}
/// Creates a rect around a narrow line
@visibleForTesting
Path createRectPathAroundLine(Line line, double width) {
width = width / 2;
final normalized = line.normalize();
final verticalAngle = line.direction() + (math.pi / 2);
final verticalDirection =
Offset(math.cos(verticalAngle), math.sin(verticalAngle));
final startPoint1 = Offset(
line.from.dx -
(normalized * (width / 2)).dx -
(verticalDirection * width).dx,
line.from.dy -
(normalized * (width / 2)).dy -
(verticalDirection * width).dy,
);
final startPoint2 = Offset(
line.to.dx +
(normalized * (width / 2)).dx -
(verticalDirection * width).dx,
line.to.dy +
(normalized * (width / 2)).dy -
(verticalDirection * width).dy,
);
final startPoint3 = Offset(
startPoint2.dx + (verticalDirection * (width * 2)).dx,
startPoint2.dy + (verticalDirection * (width * 2)).dy,
);
final startPoint4 = Offset(
startPoint1.dx + (verticalDirection * (width * 2)).dx,
startPoint1.dy + (verticalDirection * (width * 2)).dy,
);
return Path()
..moveTo(startPoint1.dx, startPoint1.dy)
..lineTo(startPoint2.dx, startPoint2.dy)
..lineTo(startPoint3.dx, startPoint3.dy)
..lineTo(startPoint4.dx, startPoint4.dy)
..lineTo(startPoint1.dx, startPoint1.dy);
}
@visibleForTesting
void drawSection(
PieChartSectionData section,
Path sectionPath,
CanvasWrapper canvasWrapper,
) {
_sectionPaint
..setColorOrGradient(
section.color,
section.gradient,
sectionPath.getBounds(),
)
..style = PaintingStyle.fill;
canvasWrapper.drawPath(sectionPath, _sectionPaint);
}
@visibleForTesting
void drawSectionStroke(
PieChartSectionData section,
Path sectionPath,
CanvasWrapper canvasWrapper,
Size viewSize,
) {
if (section.borderSide.width != 0.0 && section.borderSide.color.a != 0.0) {
canvasWrapper
..saveLayer(
Rect.fromLTWH(0, 0, viewSize.width, viewSize.height),
_clipPaint,
)
..clipPath(sectionPath);
_sectionStrokePaint
..strokeWidth = section.borderSide.width * 2
..color = section.borderSide.color;
canvasWrapper
..drawPath(
sectionPath,
_sectionStrokePaint,
)
..restore();
}
}
/// Calculates layout of overlaying elements, includes:
/// - title text
/// - badge widget positions
@visibleForTesting
void drawTexts(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<PieChartData> holder,
double centerRadius,
) {
final data = holder.data;
final viewSize = canvasWrapper.size;
final center = Offset(viewSize.width / 2, viewSize.height / 2);
var tempAngle = data.startDegreeOffset;
for (var i = 0; i < data.sections.length; i++) {
final section = data.sections[i];
if (section.value == 0) {
continue;
}
final startAngle = tempAngle;
final sweepAngle = 360 * (section.value / data.sumValue);
final sectionCenterAngle = startAngle + (sweepAngle / 2);
double? rotateAngle;
if (data.titleSunbeamLayout) {
if (sectionCenterAngle >= 90 && sectionCenterAngle <= 270) {
rotateAngle = sectionCenterAngle - 180;
} else {
rotateAngle = sectionCenterAngle;
}
}
Offset sectionCenter(double percentageOffset) =>
center +
Offset(
math.cos(Utils().radians(sectionCenterAngle)) *
(centerRadius + (section.radius * percentageOffset)),
math.sin(Utils().radians(sectionCenterAngle)) *
(centerRadius + (section.radius * percentageOffset)),
);
final sectionCenterOffsetTitle =
sectionCenter(section.titlePositionPercentageOffset);
if (section.showTitle) {
final span = TextSpan(
style: Utils().getThemeAwareTextStyle(context, section.titleStyle),
text: section.title,
);
final tp = TextPainter(
text: span,
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
textScaler: holder.textScaler,
)..layout();
canvasWrapper.drawText(
tp,
sectionCenterOffsetTitle - Offset(tp.width / 2, tp.height / 2),
rotateAngle,
);
}
tempAngle += sweepAngle;
}
}
/// Calculates center radius based on the provided sections radius
@visibleForTesting
double calculateCenterRadius(
Size viewSize,
PaintHolder<PieChartData> holder,
) {
final data = holder.data;
if (data.centerSpaceRadius.isFinite) {
return data.centerSpaceRadius;
}
final maxRadius =
data.sections.reduce((a, b) => a.radius > b.radius ? a : b).radius;
return (viewSize.shortestSide - (maxRadius * 2)) / 2;
}
/// Makes a [PieTouchedSection] based on the provided [localPosition]
///
/// Processes [localPosition] and checks
/// the elements of the chart that are near the offset,
/// then makes a [PieTouchedSection] from the elements that has been touched.
PieTouchedSection handleTouch(
Offset localPosition,
Size viewSize,
PaintHolder<PieChartData> holder,
) {
final data = holder.data;
final sectionsAngle = calculateSectionsAngle(data.sections, data.sumValue);
final centerRadius = calculateCenterRadius(viewSize, holder);
final center = Offset(viewSize.width / 2, viewSize.height / 2);
final touchedPoint2 = localPosition - center;
final touchX = touchedPoint2.dx;
final touchY = touchedPoint2.dy;
final touchR = math.sqrt(math.pow(touchX, 2) + math.pow(touchY, 2));
var touchAngle = Utils().degrees(math.atan2(touchY, touchX));
touchAngle = touchAngle < 0 ? (180 - touchAngle.abs()) + 180 : touchAngle;
PieChartSectionData? foundSectionData;
var foundSectionDataPosition = -1;
var tempAngle = data.startDegreeOffset;
for (var i = 0; i < data.sections.length; i++) {
final section = data.sections[i];
final sectionAngle = sectionsAngle[i];
if (sectionAngle == 360) {
final distance = math.sqrt(
math.pow(localPosition.dx - center.dx, 2) +
math.pow(localPosition.dy - center.dy, 2),
);
if (distance >= centerRadius &&
distance <= section.radius + centerRadius) {
foundSectionData = section;
foundSectionDataPosition = i;
}
break;
}
final sectionPath = generateSectionPath(
section,
data.sectionsSpace,
tempAngle,
sectionAngle,
center,
centerRadius,
);
if (sectionPath.contains(localPosition)) {
foundSectionData = section;
foundSectionDataPosition = i;
break;
}
tempAngle += sectionAngle;
}
return PieTouchedSection(
foundSectionData,
foundSectionDataPosition,
touchAngle,
touchR,
);
}
/// Exposes offset for laying out the badge widgets upon the chart.
Map<int, Offset> getBadgeOffsets(
Size viewSize,
PaintHolder<PieChartData> holder,
) {
final data = holder.data;
final center = viewSize.center(Offset.zero);
final badgeWidgetsOffsets = <int, Offset>{};
if (data.sections.isEmpty) {
return badgeWidgetsOffsets;
}
var tempAngle = data.startDegreeOffset;
final sectionsAngle = calculateSectionsAngle(data.sections, data.sumValue);
for (var i = 0; i < data.sections.length; i++) {
final section = data.sections[i];
final startAngle = tempAngle;
final sweepAngle = sectionsAngle[i];
final sectionCenterAngle = startAngle + (sweepAngle / 2);
final centerRadius = calculateCenterRadius(viewSize, holder);
Offset sectionCenter(double percentageOffset) =>
center +
Offset(
math.cos(Utils().radians(sectionCenterAngle)) *
(centerRadius + (section.radius * percentageOffset)),
math.sin(Utils().radians(sectionCenterAngle)) *
(centerRadius + (section.radius * percentageOffset)),
);
final sectionCenterOffsetBadgeWidget =
sectionCenter(section.badgePositionPercentageOffset);
badgeWidgetsOffsets[i] = sectionCenterOffsetBadgeWidget;
tempAngle += sweepAngle;
}
return badgeWidgetsOffsets;
}
}

View File

@@ -0,0 +1,184 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/chart/base/base_chart/render_base_chart.dart';
import 'package:fl_chart/src/chart/pie_chart/pie_chart_helper.dart';
import 'package:fl_chart/src/chart/pie_chart/pie_chart_painter.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
// coverage:ignore-start
/// Low level PieChart Widget.
class PieChartLeaf extends MultiChildRenderObjectWidget {
PieChartLeaf({
super.key,
required this.data,
required this.targetData,
}) : super(children: targetData.sections.toWidgets());
final PieChartData data;
final PieChartData targetData;
@override
RenderPieChart createRenderObject(BuildContext context) => RenderPieChart(
context,
data,
targetData,
MediaQuery.of(context).textScaler,
);
@override
void updateRenderObject(BuildContext context, RenderPieChart renderObject) {
renderObject
..data = data
..targetData = targetData
..textScaler = MediaQuery.of(context).textScaler
..buildContext = context;
}
}
// coverage:ignore-end
/// Renders our PieChart, also handles hitTest.
class RenderPieChart extends RenderBaseChart<PieTouchResponse>
with
ContainerRenderObjectMixin<RenderBox, MultiChildLayoutParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, MultiChildLayoutParentData>
implements MouseTrackerAnnotation {
RenderPieChart(
BuildContext context,
PieChartData data,
PieChartData targetData,
TextScaler textScaler,
) : _data = data,
_targetData = targetData,
_textScaler = textScaler,
super(targetData.pieTouchData, context, canBeScaled: false);
PieChartData get data => _data;
PieChartData _data;
set data(PieChartData value) {
if (_data == value) return;
_data = value;
// We must update layout to draw badges correctly!
markNeedsLayout();
}
PieChartData get targetData => _targetData;
PieChartData _targetData;
set targetData(PieChartData value) {
if (_targetData == value) return;
_targetData = value;
super.updateBaseTouchData(_targetData.pieTouchData);
// We must update layout to draw badges correctly!
markNeedsLayout();
}
TextScaler get textScaler => _textScaler;
TextScaler _textScaler;
set textScaler(TextScaler value) {
if (_textScaler == value) return;
_textScaler = value;
markNeedsPaint();
}
// We couldn't mock [size] property of this class, that's why we have this
@visibleForTesting
Size? mockTestSize;
@visibleForTesting
PieChartPainter painter = PieChartPainter();
PaintHolder<PieChartData> get paintHolder =>
PaintHolder(data, targetData, textScaler);
@override
void setupParentData(RenderBox child) {
if (child.parentData is! MultiChildLayoutParentData) {
child.parentData = MultiChildLayoutParentData();
}
}
@override
void performLayout() {
var child = firstChild;
size = computeDryLayout(constraints);
final childConstraints = constraints.loosen();
var counter = 0;
final badgeOffsets = painter.getBadgeOffsets(
mockTestSize ?? size,
paintHolder,
);
while (child != null) {
if (counter >= badgeOffsets.length) {
break;
}
child.layout(childConstraints, parentUsesSize: true);
final childParentData = child.parentData! as MultiChildLayoutParentData;
final sizeOffset = Offset(child.size.width / 2, child.size.height / 2);
childParentData.offset = badgeOffsets[counter]! - sizeOffset;
child = childParentData.nextSibling;
counter++;
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) =>
defaultHitTestChildren(result, position: position);
@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas
..save()
..translate(offset.dx, offset.dy);
painter.paint(
buildContext,
CanvasWrapper(canvas, mockTestSize ?? size),
paintHolder,
);
canvas.restore();
badgeWidgetPaint(context, offset);
}
void badgeWidgetPaint(PaintingContext context, Offset offset) {
RenderObject? child = firstChild;
var counter = 0;
while (child != null) {
final childParentData = child.parentData! as MultiChildLayoutParentData;
if (data.sections[counter].value > 0) {
context.paintChild(child, childParentData.offset + offset);
}
child = childParentData.nextSibling;
counter++;
}
}
@override
PieTouchResponse getResponseAtLocation(Offset localPosition) {
return PieTouchResponse(
touchLocation: localPosition,
touchedSection: painter.handleTouch(
localPosition,
mockTestSize ?? size,
paintHolder,
),
);
}
@override
void visitChildrenForSemantics(RenderObjectVisitor visitor) {
/// It produces an error when we change the sections list, Check this issue:
/// https://github.com/imaNNeo/fl_chart/issues/861
///
/// Below is the error message:
/// Updated layout information required for RenderSemanticsAnnotations#f3b96 NEEDS-LAYOUT NEEDS-PAINT to calculate semantics.
///
/// I don't know how to solve this error. That's why we disabled semantics for now.
}
}

View File

@@ -0,0 +1,58 @@
import 'package:fl_chart/src/chart/radar_chart/radar_chart_data.dart';
import 'package:fl_chart/src/chart/radar_chart/radar_chart_renderer.dart';
import 'package:flutter/material.dart';
/// Renders a radar chart as a widget, using provided [RadarChartData].
class RadarChart extends ImplicitlyAnimatedWidget {
/// [data] determines how the [RadarChart] should be look like,
/// when you make any change in the [RadarChart], it updates
/// new values with animation, and duration is [duration].
/// also you can change the [curve]
/// which default is [Curves.linear].
const RadarChart(
this.data, {
super.key,
@Deprecated('Please use [duration] instead')
Duration? swapAnimationDuration,
Duration duration = const Duration(milliseconds: 150),
@Deprecated('Please use [curve] instead') Curve? swapAnimationCurve,
Curve curve = Curves.linear,
}) : super(
duration: swapAnimationDuration ?? duration,
curve: swapAnimationCurve ?? curve,
);
/// Determines how the [RadarChart] should be look like.
final RadarChartData data;
@override
_RadarChartState createState() => _RadarChartState();
}
class _RadarChartState extends AnimatedWidgetBaseState<RadarChart> {
/// we handle under the hood animations (implicit animations) via this tween,
/// it lerps between the old [RadarChartData] to the new one.
RadarChartDataTween? _radarChartDataTween;
@override
Widget build(BuildContext context) {
final showingData = _getDate();
return RadarChartLeaf(
data: _radarChartDataTween!.evaluate(animation),
targetData: showingData,
);
}
RadarChartData _getDate() => widget.data;
@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_radarChartDataTween = visitor(
_radarChartDataTween,
widget.data,
(dynamic value) =>
RadarChartDataTween(begin: value as RadarChartData, end: widget.data),
) as RadarChartDataTween?;
}
}

View File

@@ -0,0 +1,506 @@
// coverage:ignore-file
import 'dart:ui';
import 'package:equatable/equatable.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/radar_chart/radar_extension.dart';
import 'package:fl_chart/src/utils/lerp.dart';
import 'package:flutter/material.dart';
typedef GetTitleByIndexFunction = RadarChartTitle Function(
int index,
double angle,
);
enum RadarShape {
circle,
polygon,
}
class RadarChartTitle {
const RadarChartTitle({
required this.text,
this.children,
this.angle = 0,
this.positionPercentageOffset,
});
/// [text] is used to draw titles outside the [RadarChart]
final String text;
/// [children] is used to draw additional titles outside the [RadarChart]
final List<InlineSpan>? children;
/// [angle] is used to rotate the title
final double angle;
/// [positionPercentageOffset] is the place of showing title on the [RadarChart]
/// The higher the value of this field, the more titles move away from the chart.
/// The value of [positionPercentageOffset] takes precedence over the value of
/// [RadarChartData.titlePositionPercentageOffset], even if it is set.
final double? positionPercentageOffset;
}
/// [RadarChart] needs this class to render itself.
///
/// It holds data needed to draw a radar chart,
/// including radar dataSets, colors, ...
class RadarChartData extends BaseChartData with EquatableMixin {
/// [RadarChart] draws some [dataSets] in a radar-shaped chart.
/// it fills the radar area with [radarBackgroundColor]
/// and draws radar border with [radarBorderData]
/// then draws a grid over it, you can customize it using [gridBorderData].
///
/// it draws some titles based on the number of [dataSets] values.
/// the titles are shown near each radar grid or line.
/// for changing the titles you can modify the [getTitle] field.
/// and for styling the titles you can use [titleTextStyle].
///
/// it draws some ticks. and you can customize the number of ticks by modifying the [titleCount]
/// and style the ticks titles with [ticksTextStyle].
/// for changing the ticks color and border width you can use [tickBorderData].
///
/// You can modify [radarTouchData] to customize touch behaviors and responses.
RadarChartData({
@required List<RadarDataSet>? dataSets,
Color? radarBackgroundColor,
BorderSide? radarBorderData,
RadarShape? radarShape,
this.getTitle,
this.titleTextStyle,
double? titlePositionPercentageOffset,
int? tickCount,
this.ticksTextStyle,
BorderSide? tickBorderData,
BorderSide? gridBorderData,
RadarTouchData? radarTouchData,
this.isMinValueAtCenter = false,
super.borderData,
}) : assert(dataSets != null && dataSets.hasEqualDataEntriesLength),
assert(
tickCount == null || tickCount >= 1,
"RadarChart need's at least 1 tick",
),
assert(
titlePositionPercentageOffset == null ||
titlePositionPercentageOffset >= 0 &&
titlePositionPercentageOffset <= 1,
'titlePositionPercentageOffset must be something between 0 and 1 ',
),
dataSets = dataSets ?? const [],
radarBackgroundColor = radarBackgroundColor ?? Colors.transparent,
radarBorderData = radarBorderData ?? const BorderSide(width: 2),
radarShape = radarShape ?? RadarShape.circle,
radarTouchData = radarTouchData ?? RadarTouchData(),
titlePositionPercentageOffset = titlePositionPercentageOffset ?? 0.2,
tickCount = tickCount ?? 1,
tickBorderData = tickBorderData ?? const BorderSide(width: 2),
gridBorderData = gridBorderData ?? const BorderSide(width: 2),
super();
/// [RadarChart] draw [dataSets] that each of them showing a list of [RadarEntry]
final List<RadarDataSet> dataSets;
/// [radarBackgroundColor] draw the background color of the [RadarChart]
final Color radarBackgroundColor;
/// [radarBorderData] is used to draw [RadarChart] border
final BorderSide radarBorderData;
/// [radarShape] is used to draw [RadarChart] border and background
final RadarShape radarShape;
/// [getTitle] is used to draw titles outside the [RadarChart]
/// [getTitle] is type of [GetTitleByIndexFunction] so you should return a valid [RadarChartTitle]
/// for each [index] (we provide a default [angle] = index * 360 / titleCount)
///
/// ```dart
/// getTitle: (index, angle) {
/// switch (index) {
/// case 0:
/// return RadarChartTitle(text: 'Mobile or Tablet', angle: angle);
/// case 2:
/// return RadarChartTitle(text: 'Desktop', angle: angle);
/// case 1:
/// return RadarChartTitle(text: 'TV', angle: angle);
/// default:
/// return const RadarChartTitle(text: '');
/// }
/// }
/// ```
final GetTitleByIndexFunction? getTitle;
/// Defines style of showing [RadarChart] titles.
final TextStyle? titleTextStyle;
/// the [titlePositionPercentageOffset] is the place of showing title on the [RadarChart]
/// The higher the value of this field, the more titles move away from the chart.
/// this field should be between 0 and 1,
/// if it is 0 the title will be drawn near the inside section,
/// if it is 1 the title will be drawn near the outside of section,
/// the default value is 0.2.
final double titlePositionPercentageOffset;
/// Defines the number of ticks that should be paint in [RadarChart]
/// the default & minimum value of this field is 1.
final int tickCount;
/// Defines style of showing [RadarChart] tick titles.
final TextStyle? ticksTextStyle;
/// Defines style of showing [RadarChart] tick borders.
final BorderSide tickBorderData;
/// Defines style of showing [RadarChart] grid borders.
final BorderSide gridBorderData;
/// Handles touch behaviors and responses.
final RadarTouchData radarTouchData;
/// If [isMinValueAtCenter] is true, the minimum value of the [RadarChart] will be at the center of the chart.
final bool isMinValueAtCenter;
/// [titleCount] we use this value to determine number of [RadarChart] grid or lines.
int get titleCount => dataSets[0].dataEntries.length;
/// defines the maximum [RadarEntry] value in all [dataSets]
/// we use this value to calculate the maximum value of ticks.
RadarEntry get maxEntry {
var maximum = dataSets.first.dataEntries.first;
for (final dataSet in dataSets) {
for (final entry in dataSet.dataEntries) {
if (entry.value > maximum.value) maximum = entry;
}
}
return maximum;
}
/// defines the minimum [RadarEntry] value in all [dataSets]
/// we use this value to calculate the minimum value of ticks.
RadarEntry get minEntry {
var minimum = dataSets.first.dataEntries.first;
for (final dataSet in dataSets) {
for (final entry in dataSet.dataEntries) {
if (entry.value < minimum.value) minimum = entry;
}
}
return minimum;
}
/// Copies current [RadarChartData] to a new [RadarChartData],
/// and replaces provided values.
RadarChartData copyWith({
List<RadarDataSet>? dataSets,
Color? radarBackgroundColor,
BorderSide? radarBorderData,
RadarShape? radarShape,
GetTitleByIndexFunction? getTitle,
TextStyle? titleTextStyle,
double? titlePositionPercentageOffset,
int? tickCount,
TextStyle? ticksTextStyle,
BorderSide? tickBorderData,
BorderSide? gridBorderData,
RadarTouchData? radarTouchData,
bool? isMinValueAtCenter,
FlBorderData? borderData,
}) =>
RadarChartData(
dataSets: dataSets ?? this.dataSets,
radarBackgroundColor: radarBackgroundColor ?? this.radarBackgroundColor,
radarBorderData: radarBorderData ?? this.radarBorderData,
radarShape: radarShape ?? this.radarShape,
getTitle: getTitle ?? this.getTitle,
titleTextStyle: titleTextStyle ?? this.titleTextStyle,
titlePositionPercentageOffset:
titlePositionPercentageOffset ?? this.titlePositionPercentageOffset,
tickCount: tickCount ?? this.tickCount,
ticksTextStyle: ticksTextStyle ?? this.ticksTextStyle,
tickBorderData: tickBorderData ?? this.tickBorderData,
gridBorderData: gridBorderData ?? this.gridBorderData,
radarTouchData: radarTouchData ?? this.radarTouchData,
isMinValueAtCenter: isMinValueAtCenter ?? this.isMinValueAtCenter,
borderData: borderData ?? this.borderData,
);
/// Lerps a [BaseChartData] based on [t] value, check [Tween.lerp].
@override
RadarChartData lerp(BaseChartData a, BaseChartData b, double t) {
if (a is RadarChartData && b is RadarChartData) {
return RadarChartData(
dataSets: lerpRadarDataSetList(a.dataSets, b.dataSets, t),
radarBackgroundColor:
Color.lerp(a.radarBackgroundColor, b.radarBackgroundColor, t),
getTitle: b.getTitle,
titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t),
titlePositionPercentageOffset: lerpDouble(
a.titlePositionPercentageOffset,
b.titlePositionPercentageOffset,
t,
),
tickCount: lerpInt(a.tickCount, b.tickCount, t),
ticksTextStyle: TextStyle.lerp(a.ticksTextStyle, b.ticksTextStyle, t),
gridBorderData: BorderSide.lerp(a.gridBorderData, b.gridBorderData, t),
radarBorderData:
BorderSide.lerp(a.radarBorderData, b.radarBorderData, t),
radarShape: b.radarShape,
tickBorderData: BorderSide.lerp(a.tickBorderData, b.tickBorderData, t),
borderData: FlBorderData.lerp(a.borderData, b.borderData, t),
isMinValueAtCenter: b.isMinValueAtCenter,
radarTouchData: b.radarTouchData,
);
} else {
throw Exception('Illegal State');
}
}
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
borderData,
dataSets,
radarBackgroundColor,
radarBorderData,
radarShape,
getTitle,
titleTextStyle,
titlePositionPercentageOffset,
tickCount,
ticksTextStyle,
tickBorderData,
gridBorderData,
radarTouchData,
isMinValueAtCenter,
];
}
/// the data values for drawing [RadarChart] sections
class RadarDataSet with EquatableMixin {
/// [RadarChart] can contain multiple [RadarDataSet] And it shows them on top of each other.
/// each [RadarDataSet] has a set of [dataEntries]
/// and the [RadarChart] uses this [dataEntries] to draw the chart.
///
/// it fill dataSets with [fillColor].
///
/// the [RadarDataSet] can have custom border. for changing border of [RadarDataSet]
/// you can modify the [borderColor] and [borderWidth].
RadarDataSet({
List<RadarEntry>? dataEntries,
Color? fillColor,
this.fillGradient,
Color? borderColor,
double? borderWidth,
double? entryRadius,
}) : assert(
dataEntries == null || dataEntries.isEmpty || dataEntries.length >= 3,
'Radar needs at least 3 RadarEntry',
),
dataEntries = dataEntries ?? const [],
fillColor = fillColor ?? Colors.cyan,
borderColor = borderColor ?? Colors.cyan,
borderWidth = borderWidth ?? 2.0,
entryRadius = entryRadius ?? 5.0;
/// each section or dataSets consists of a set of [dataEntries].
final List<RadarEntry> dataEntries;
/// defines the color that fills the [RadarDataSet].
final Color fillColor;
// defines the gradient color that fills the [RadarDataSet].
final Gradient? fillGradient;
/// defines the border color of the [RadarDataSet].
/// if [borderColor] is not defined it will replaced with [fillColor].
final Color borderColor;
/// defines the width of [RadarDataSet] border.
/// the default value of this field is 2.0
final double borderWidth;
/// defines the radius of each entry
/// the default value of this field is 5.0
final double entryRadius;
/// Copies current [RadarDataSet] to a new [RadarDataSet],
/// and replaces provided values.
RadarDataSet copyWith({
List<RadarEntry>? dataEntries,
Color? fillColor,
Gradient? fillGradient,
Color? borderColor,
double? borderWidth,
double? entryRadius,
}) =>
RadarDataSet(
dataEntries: dataEntries ?? this.dataEntries,
fillColor: fillColor ?? this.fillColor,
fillGradient: fillGradient,
borderColor: borderColor ?? this.borderColor,
borderWidth: borderWidth ?? this.borderWidth,
entryRadius: entryRadius ?? this.entryRadius,
);
/// Lerps a [RadarDataSet] based on [t] value, check [Tween.lerp].
static RadarDataSet lerp(RadarDataSet a, RadarDataSet b, double t) =>
RadarDataSet(
dataEntries: lerpRadarEntryList(a.dataEntries, b.dataEntries, t),
fillColor: Color.lerp(a.fillColor, b.fillColor, t),
fillGradient: Gradient.lerp(a.fillGradient, b.fillGradient, t),
borderColor: Color.lerp(a.borderColor, b.borderColor, t),
borderWidth: lerpDouble(a.borderWidth, b.borderWidth, t),
entryRadius: lerpDouble(a.entryRadius, b.entryRadius, t),
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
dataEntries,
fillColor,
fillGradient,
borderColor,
borderWidth,
entryRadius,
];
}
/// holds the data about each entry or point in [RadarChart]
class RadarEntry with EquatableMixin {
/// [RadarChart] draws every point or entry with [RadarEntry]
const RadarEntry({required this.value});
/// [RadarChart] uses this field to render every point in chart.
final double value;
/// Lerps a [RadarEntry] based on [t] value, check [Tween.lerp].
RadarEntry copyWith({double? value}) =>
RadarEntry(value: value ?? this.value);
/// Lerps a [RadarDataSet] based on [t] value, check [Tween.lerp].
static RadarEntry lerp(RadarEntry a, RadarEntry b, double t) =>
RadarEntry(value: lerpDouble(a.value, b.value, t)!);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [value];
}
/// Holds data to handle touch events, and touch responses in the [RadarChart].
///
/// There is a touch flow, explained [here](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/handle_touches.md)
/// in a simple way, each chart's renderer captures the touch events, and passes the pointerEvent
/// to the painter, and gets touched spot, and wraps it into a concrete [RadarTouchResponse].
class RadarTouchData extends FlTouchData<RadarTouchResponse>
with EquatableMixin {
/// You can disable or enable the touch system using [enabled] flag,
///
/// [touchCallback] notifies you about the happened touch/pointer events.
/// It gives you a [FlTouchEvent] which is the happened event such as [FlPointerHoverEvent], [FlTapUpEvent], ...
/// It also gives you a [RadarTouchResponse] which contains information
/// about the elements that has touched.
///
/// Using [mouseCursorResolver] you can change the mouse cursor
/// based on the provided [FlTouchEvent] and [RadarTouchResponse]
RadarTouchData({
bool? enabled,
BaseTouchCallback<RadarTouchResponse>? touchCallback,
MouseCursorResolver<RadarTouchResponse>? mouseCursorResolver,
Duration? longPressDuration,
double? touchSpotThreshold,
}) : touchSpotThreshold = touchSpotThreshold ?? 10,
super(
enabled ?? true,
touchCallback,
mouseCursorResolver,
longPressDuration,
);
/// we find the nearest spots on touched position based on this threshold
final double touchSpotThreshold;
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
enabled,
touchCallback,
mouseCursorResolver,
longPressDuration,
touchSpotThreshold,
];
}
/// Holds information about touch response in the [RadarTouchResponse].
///
/// You can override [RadarTouchData.touchCallback] to handle touch events,
/// it gives you a [RadarTouchResponse] and you can do whatever you want.
class RadarTouchResponse extends BaseTouchResponse {
/// If touch happens, [RadarChart] processes it internally and passes out a [RadarTouchResponse]
/// that contains a [touchedSpot], it gives you information about the touched spot.
RadarTouchResponse({
required super.touchLocation,
required this.touchedSpot,
});
/// touch happened on this spot. this spot has useful information about spot or entry
final RadarTouchedSpot? touchedSpot;
/// Copies current [RadarTouchResponse] to a new [RadarTouchResponse],
/// and replaces provided values.
RadarTouchResponse copyWith({
Offset? touchLocation,
RadarTouchedSpot? touchedSpot,
}) =>
RadarTouchResponse(
touchLocation: touchLocation ?? this.touchLocation,
touchedSpot: touchedSpot ?? this.touchedSpot,
);
}
/// It gives you information about the touched spot.
class RadarTouchedSpot extends TouchedSpot with EquatableMixin {
/// When touch happens, a [RadarTouchedSpot] returns as a output,
/// it tells you where the touch happened.
/// [touchedDataSet], and [touchedDataSetIndex] tell you in which dataSet touch happened,
/// [touchedRadarEntry], and [touchedRadarEntryIndex] tell you in which entry touch happened,
/// You can also have the touched x and y in the chart as a [FlSpot] using [spot] value,
/// and you can have the local touch coordinates on the screen as a [Offset] using [offset] value.
RadarTouchedSpot(
this.touchedDataSet,
this.touchedDataSetIndex,
this.touchedRadarEntry,
this.touchedRadarEntryIndex,
FlSpot spot,
Offset offset,
) : super(spot, offset);
final RadarDataSet touchedDataSet;
final int touchedDataSetIndex;
final RadarEntry touchedRadarEntry;
final int touchedRadarEntryIndex;
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
spot,
offset,
touchedDataSet,
touchedDataSetIndex,
touchedRadarEntry,
touchedRadarEntryIndex,
];
}
/// It lerps a [RadarChartData] to another [RadarChartData] (handles animation for updating values)
class RadarChartDataTween extends Tween<RadarChartData> {
RadarChartDataTween({
required RadarChartData begin,
required RadarChartData end,
}) : super(begin: begin, end: end);
/// Lerps a [RadarChartData] based on [t] value, check [Tween.lerp].
@override
RadarChartData lerp(double t) => begin!.lerp(begin!, end!, t);
}

View File

@@ -0,0 +1,499 @@
import 'dart:math' show cos, min, pi, sin;
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:fl_chart/src/utils/utils.dart';
import 'package:flutter/material.dart';
/// Paints [RadarChartData] in the canvas, it can be used in a [CustomPainter]
class RadarChartPainter extends BaseChartPainter<RadarChartData> {
/// Paints [dataList] into canvas, it is the animating [RadarChartData],
/// [targetData] is the animation's target and remains the same
/// during animation, then we should use it when we need to show
/// tooltips or something like that, because [dataList] is changing constantly.
///
/// [textScale] used for scaling texts inside the chart,
/// parent can use [MediaQuery.textScaleFactor] to respect
/// the system's font size.
RadarChartPainter() : super() {
_backgroundPaint = Paint()
..style = PaintingStyle.fill
..isAntiAlias = true;
_borderPaint = Paint()..style = PaintingStyle.stroke;
_gridPaint = Paint()..style = PaintingStyle.stroke;
_tickPaint = Paint()..style = PaintingStyle.stroke;
_graphPaint = Paint();
_graphBorderPaint = Paint();
_graphPointPaint = Paint();
_ticksTextPaint = TextPainter();
_titleTextPaint = TextPainter();
}
late Paint _borderPaint;
late Paint _backgroundPaint;
late Paint _gridPaint;
late Paint _tickPaint;
late Paint _graphPaint;
late Paint _graphBorderPaint;
late Paint _graphPointPaint;
late TextPainter _ticksTextPaint;
late TextPainter _titleTextPaint;
List<RadarDataSetsPosition>? dataSetsPosition;
/// Paints [RadarChartData] into the provided canvas.
@override
void paint(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<RadarChartData> holder,
) {
super.paint(context, canvasWrapper, holder);
final data = holder.data;
if (data.dataSets.isEmpty) {
return;
}
dataSetsPosition = calculateDataSetsPosition(canvasWrapper.size, holder);
drawGrids(canvasWrapper, holder);
drawTicks(context, canvasWrapper, holder);
drawTitles(context, canvasWrapper, holder);
drawDataSets(canvasWrapper, holder);
}
@visibleForTesting
double getDefaultChartCenterValue() => 0;
double getChartCenterValue(RadarChartData data) {
final dataSetMaxValue = data.maxEntry.value;
final dataSetMinValue = data.minEntry.value;
if (data.isMinValueAtCenter) {
return dataSetMinValue;
}
final tickSpace = getSpaceBetweenTicks(data);
final centerValue = dataSetMinValue - tickSpace;
return dataSetMaxValue == dataSetMinValue
? getDefaultChartCenterValue()
: centerValue;
}
@visibleForTesting
double getScaledPoint(RadarEntry point, double radius, RadarChartData data) {
final centerValue = getChartCenterValue(data);
final distanceFromPointToCenter = point.value - centerValue;
final distanceFromMaxToCenter = data.maxEntry.value - centerValue;
if (distanceFromMaxToCenter == 0) {
return radius * distanceFromPointToCenter / 0.001;
}
return radius * distanceFromPointToCenter / distanceFromMaxToCenter;
}
@visibleForTesting
double getFirstTickValue(RadarChartData data) {
final defaultCenterValue = getDefaultChartCenterValue();
if (data.isMinValueAtCenter) {
return defaultCenterValue;
}
final dataSetMaxValue = data.maxEntry.value;
final dataSetMinValue = data.minEntry.value;
return dataSetMaxValue == dataSetMinValue
? (dataSetMaxValue - defaultCenterValue) / (data.tickCount + 1) +
defaultCenterValue
: dataSetMinValue;
}
@visibleForTesting
double getSpaceBetweenTicks(RadarChartData data) {
final dataSetMaxValue = data.maxEntry.value;
final dataSetMinValue = data.minEntry.value;
if (data.isMinValueAtCenter) {
return (dataSetMaxValue - dataSetMinValue) / (data.tickCount);
}
final defaultCenterValue = getDefaultChartCenterValue();
final tickSpace = (dataSetMaxValue - dataSetMinValue) / data.tickCount;
final defaultTickSpace =
(dataSetMaxValue - defaultCenterValue) / (data.tickCount + 1);
return dataSetMaxValue == dataSetMinValue ? defaultTickSpace : tickSpace;
}
@visibleForTesting
void drawTicks(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<RadarChartData> holder,
) {
final data = holder.data;
final size = canvasWrapper.size;
final centerX = radarCenterX(size);
final centerY = radarCenterY(size);
final centerOffset = Offset(centerX, centerY);
/// controls Radar chart size
final radius = radarRadius(size);
_backgroundPaint.color = data.radarBackgroundColor;
_borderPaint
..color = data.radarBorderData.color
..strokeWidth = data.radarBorderData.width;
if (data.radarShape == RadarShape.circle) {
/// draw radar background
canvasWrapper
..drawCircle(centerOffset, radius, _backgroundPaint)
/// draw radar border
..drawCircle(centerOffset, radius, _borderPaint);
} else {
final path =
_generatePolygonPath(centerX, centerY, radius, data.titleCount);
/// draw radar background
canvasWrapper
..drawPath(path, _backgroundPaint)
/// draw radar border
..drawPath(path, _borderPaint);
}
final tickSpace = getSpaceBetweenTicks(data);
final ticks = <double>[];
var tickValue = getFirstTickValue(data);
for (var i = 0; i <= data.tickCount; i++) {
ticks.add(tickValue);
tickValue += tickSpace;
}
final tickDistance = data.isMinValueAtCenter
? radius / (ticks.length - 1)
: radius / ticks.length;
_tickPaint
..color = data.tickBorderData.color
..strokeWidth = data.tickBorderData.width;
/// draw radar ticks
ticks.sublist(0, ticks.length - 1).asMap().forEach(
(index, tick) {
final tickRadius =
tickDistance * (index + (data.isMinValueAtCenter ? 0 : 1));
if (data.radarShape == RadarShape.circle) {
canvasWrapper.drawCircle(centerOffset, tickRadius, _tickPaint);
} else {
canvasWrapper.drawPath(
_generatePolygonPath(centerX, centerY, tickRadius, data.titleCount),
_tickPaint,
);
}
_ticksTextPaint
..text = TextSpan(
text: tick.toStringAsFixed(1),
style: Utils().getThemeAwareTextStyle(context, data.ticksTextStyle),
)
..textDirection = TextDirection.ltr
..layout(maxWidth: size.width);
canvasWrapper.drawText(
_ticksTextPaint,
Offset(centerX + 5, centerY - tickRadius - _ticksTextPaint.height),
);
},
);
}
Path _generatePolygonPath(
double centerX,
double centerY,
double radius,
int count,
) {
final path = Path()..moveTo(centerX, centerY - radius);
final angle = (2 * pi) / count;
for (var index = 0; index < count; index++) {
final xAngle = cos(angle * index - pi / 2);
final yAngle = sin(angle * index - pi / 2);
path.lineTo(centerX + radius * xAngle, centerY + radius * yAngle);
}
path.lineTo(centerX, centerY - radius);
return path;
}
void drawGrids(
CanvasWrapper canvasWrapper,
PaintHolder<RadarChartData> holder,
) {
final data = holder.data;
final size = canvasWrapper.size;
final centerX = radarCenterX(size);
final centerY = radarCenterY(size);
final centerOffset = Offset(centerX, centerY);
/// controls Radar chart size
final radius = radarRadius(size);
final angle = (2 * pi) / data.titleCount;
/// drawing grids
for (var index = 0; index < data.titleCount; index++) {
final endX = centerX + radius * cos(angle * index - pi / 2);
final endY = centerY + radius * sin(angle * index - pi / 2);
final gridOffset = Offset(endX, endY);
_gridPaint
..color = data.gridBorderData.color
..strokeWidth = data.gridBorderData.width;
canvasWrapper.drawLine(centerOffset, gridOffset, _gridPaint);
}
}
@visibleForTesting
void drawTitles(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<RadarChartData> holder,
) {
final data = holder.data;
if (data.getTitle == null) return;
final size = canvasWrapper.size;
final centerX = radarCenterX(size);
final centerY = radarCenterY(size);
/// controls Radar chart size
final radius = radarRadius(size);
final diffAngle = (2 * pi) / data.titleCount;
final style = Utils().getThemeAwareTextStyle(context, data.titleTextStyle);
_titleTextPaint
..textAlign = TextAlign.center
..textDirection = TextDirection.ltr
..textScaler = holder.textScaler;
for (var index = 0; index < data.titleCount; index++) {
final baseTitleAngle = Utils().degrees(diffAngle * index);
final title = data.getTitle!(index, baseTitleAngle);
final span =
TextSpan(text: title.text, children: title.children, style: style);
_titleTextPaint
..text = span
..layout();
final angle = diffAngle * index - pi / 2;
final threshold = 1.0 +
(title.positionPercentageOffset ??
data.titlePositionPercentageOffset);
final titleX = centerX +
cos(angle) * (radius * threshold + (_titleTextPaint.height / 2));
final titleY = centerY +
sin(angle) * (radius * threshold + (_titleTextPaint.height / 2));
final rect = Rect.fromLTWH(
titleX,
titleY,
_titleTextPaint.width,
_titleTextPaint.height,
);
final rectDrawOffset = Offset(rect.left, rect.top);
final drawTitleDegrees = (angle * 180 / pi) + 90;
canvasWrapper.drawRotated(
size: rect.size,
rotationOffset: Offset(
-rect.width / 2,
-rect.height / 2,
),
drawOffset: rectDrawOffset,
angle: drawTitleDegrees,
drawCallback: () {
canvasWrapper.drawText(
_titleTextPaint,
rect.topLeft,
title.angle - baseTitleAngle,
);
},
);
}
}
@visibleForTesting
void drawDataSets(
CanvasWrapper canvasWrapper,
PaintHolder<RadarChartData> holder,
) {
final data = holder.data;
// we will use dataSetsPosition to draw the graphs
dataSetsPosition ??= calculateDataSetsPosition(canvasWrapper.size, holder);
final size = canvasWrapper.size;
final centerX = radarCenterX(size);
final centerY = radarCenterY(size);
final centerOffset = Offset(centerX, centerY);
final radius = radarRadius(size);
dataSetsPosition!.asMap().forEach((index, dataSetOffset) {
final graph = data.dataSets[index];
// if fillGradient exists
if (graph.fillGradient != null) {
// Create the shader
final rect = Rect.fromCircle(center: centerOffset, radius: radius);
_graphPaint
..shader = graph.fillGradient!.createShader(rect)
..style = PaintingStyle.fill;
} else {
// else solid fill color
_graphPaint
..color = graph.fillColor
..style = PaintingStyle.fill;
}
_graphBorderPaint
..color = graph.borderColor
..style = PaintingStyle.stroke
..strokeWidth = graph.borderWidth;
_graphPointPaint
..color = _graphBorderPaint.color
..style = PaintingStyle.fill;
final path = Path();
final firstOffset = Offset(
dataSetOffset.entriesOffset.first.dx,
dataSetOffset.entriesOffset.first.dy,
);
path.moveTo(firstOffset.dx, firstOffset.dy);
canvasWrapper.drawCircle(
firstOffset,
graph.entryRadius,
_graphPointPaint,
);
dataSetOffset.entriesOffset.asMap().forEach((index, pointOffset) {
if (index == 0) return;
path.lineTo(pointOffset.dx, pointOffset.dy);
canvasWrapper.drawCircle(
pointOffset,
graph.entryRadius,
_graphPointPaint,
);
});
path.close();
canvasWrapper
..drawPath(path, _graphPaint)
..drawPath(path, _graphBorderPaint);
});
}
RadarTouchedSpot? handleTouch(
Offset touchedPoint,
Size viewSize,
PaintHolder<RadarChartData> holder,
) {
final targetData = holder.targetData;
dataSetsPosition ??= calculateDataSetsPosition(viewSize, holder);
for (var i = 0; i < dataSetsPosition!.length; i++) {
final dataSetPosition = dataSetsPosition![i];
for (var j = 0; j < dataSetPosition.entriesOffset.length; j++) {
final entryOffset = dataSetPosition.entriesOffset[j];
if ((touchedPoint.dx - entryOffset.dx).abs() <=
targetData.radarTouchData.touchSpotThreshold &&
(touchedPoint.dy - entryOffset.dy).abs() <=
targetData.radarTouchData.touchSpotThreshold) {
return RadarTouchedSpot(
targetData.dataSets[i],
i,
targetData.dataSets[i].dataEntries[j],
j,
FlSpot(entryOffset.dx, entryOffset.dy),
entryOffset,
);
}
}
}
return null;
}
@visibleForTesting
double radarCenterY(Size size) => size.height / 2.0;
@visibleForTesting
double radarCenterX(Size size) => size.width / 2.0;
@visibleForTesting
double radarRadius(Size size) =>
min(radarCenterX(size), radarCenterY(size)) * 0.8;
@visibleForTesting
List<RadarDataSetsPosition> calculateDataSetsPosition(
Size viewSize,
PaintHolder<RadarChartData> holder,
) {
final data = holder.data;
final centerX = radarCenterX(viewSize);
final centerY = radarCenterY(viewSize);
final radius = radarRadius(viewSize);
final angle = (2 * pi) / data.titleCount;
final dataSetsPosition = List<RadarDataSetsPosition>.filled(
data.dataSets.length,
const RadarDataSetsPosition([]),
);
for (var i = 0; i < data.dataSets.length; i++) {
final dataSet = data.dataSets[i];
final entriesOffset =
List<Offset>.filled(dataSet.dataEntries.length, Offset.zero);
for (var j = 0; j < dataSet.dataEntries.length; j++) {
final point = dataSet.dataEntries[j];
final xAngle = cos(angle * j - pi / 2);
final yAngle = sin(angle * j - pi / 2);
final scaledPoint = getScaledPoint(point, radius, data);
final entryOffset = Offset(
centerX + scaledPoint * xAngle,
centerY + scaledPoint * yAngle,
);
entriesOffset[j] = entryOffset;
}
dataSetsPosition[i] = RadarDataSetsPosition(entriesOffset);
}
return dataSetsPosition;
}
}
class RadarDataSetsPosition {
const RadarDataSetsPosition(this.entriesOffset);
final List<Offset> entriesOffset;
}

View File

@@ -0,0 +1,114 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/chart/base/base_chart/render_base_chart.dart';
import 'package:fl_chart/src/chart/radar_chart/radar_chart_painter.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:flutter/cupertino.dart';
// coverage:ignore-start
/// Low level RadarChart Widget.
class RadarChartLeaf extends LeafRenderObjectWidget {
const RadarChartLeaf({
super.key,
required this.data,
required this.targetData,
});
final RadarChartData data;
final RadarChartData targetData;
@override
RenderRadarChart createRenderObject(BuildContext context) => RenderRadarChart(
context,
data,
targetData,
MediaQuery.of(context).textScaler,
);
@override
void updateRenderObject(BuildContext context, RenderRadarChart renderObject) {
renderObject
..data = data
..targetData = targetData
..textScaler = MediaQuery.of(context).textScaler
..buildContext = context;
}
}
// coverage:ignore-end
/// Renders our RadarChart, also handles hitTest.
class RenderRadarChart extends RenderBaseChart<RadarTouchResponse> {
RenderRadarChart(
BuildContext context,
RadarChartData data,
RadarChartData targetData,
TextScaler textScaler,
) : _data = data,
_targetData = targetData,
_textScaler = textScaler,
super(targetData.radarTouchData, context, canBeScaled: false);
RadarChartData get data => _data;
RadarChartData _data;
set data(RadarChartData value) {
if (_data == value) return;
_data = value;
markNeedsPaint();
}
RadarChartData get targetData => _targetData;
RadarChartData _targetData;
set targetData(RadarChartData value) {
if (_targetData == value) return;
_targetData = value;
super.updateBaseTouchData(_targetData.radarTouchData);
markNeedsPaint();
}
TextScaler get textScaler => _textScaler;
TextScaler _textScaler;
set textScaler(TextScaler value) {
if (_textScaler == value) return;
_textScaler = value;
markNeedsPaint();
}
// We couldn't mock [size] property of this class, that's why we have this
@visibleForTesting
Size? mockTestSize;
@visibleForTesting
RadarChartPainter painter = RadarChartPainter();
PaintHolder<RadarChartData> get paintHolder =>
PaintHolder(data, targetData, textScaler);
@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas
..save()
..translate(offset.dx, offset.dy);
painter.paint(
buildContext,
CanvasWrapper(canvas, mockTestSize ?? size),
paintHolder,
);
canvas.restore();
}
@override
RadarTouchResponse getResponseAtLocation(Offset localPosition) {
return RadarTouchResponse(
touchLocation: localPosition,
touchedSpot: painter.handleTouch(
localPosition,
mockTestSize ?? size,
paintHolder,
),
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:fl_chart/src/chart/radar_chart/radar_chart_data.dart';
/// Defines extensions on the [List<RadarDataSet>]
extension DashedPath on List<RadarDataSet> {
/// check all the [RadarDataSet] has a same [dataEntries] length
bool get hasEqualDataEntriesLength {
if (length == 0) return false;
final firstDataEntriesLength = this[0].dataEntries.length;
return every(
(element) => element.dataEntries.length == firstDataEntriesLength,
);
}
}

View File

@@ -0,0 +1,130 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart';
import 'package:fl_chart/src/chart/scatter_chart/scatter_chart_renderer.dart';
import 'package:flutter/cupertino.dart';
/// Renders a pie chart as a widget, using provided [ScatterChartData].
class ScatterChart extends ImplicitlyAnimatedWidget {
/// [data] determines how the [ScatterChart] should be look like,
/// when you make any change in the [ScatterChartData], it updates
/// new values with animation, and duration is [duration].
/// also you can change the [curve]
/// which default is [Curves.linear].
const ScatterChart(
this.data, {
this.chartRendererKey,
super.key,
@Deprecated('Please use [duration] instead')
Duration? swapAnimationDuration,
Duration duration = const Duration(milliseconds: 150),
@Deprecated('Please use [curve] instead') Curve? swapAnimationCurve,
Curve curve = Curves.linear,
this.transformationConfig = const FlTransformationConfig(),
}) : super(
duration: swapAnimationDuration ?? duration,
curve: swapAnimationCurve ?? curve,
);
/// Determines how the [ScatterChart] should be look like.
final ScatterChartData data;
/// {@macro fl_chart.AxisChartScaffoldWidget.transformationConfig}
final FlTransformationConfig transformationConfig;
/// We pass this key to our renderers which are responsible to
/// render the chart itself (without anything around the chart).
final Key? chartRendererKey;
/// Creates a [_ScatterChartState]
@override
_ScatterChartState createState() => _ScatterChartState();
}
class _ScatterChartState extends AnimatedWidgetBaseState<ScatterChart> {
/// we handle under the hood animations (implicit animations) via this tween,
/// it lerps between the old [ScatterChartData] to the new one.
ScatterChartDataTween? _scatterChartDataTween;
/// If [ScatterTouchData.handleBuiltInTouches] is true, we override the callback to handle touches internally,
/// but we need to keep the provided callback to notify it too.
BaseTouchCallback<ScatterTouchResponse>? _providedTouchCallback;
List<int> touchedSpots = [];
@override
Widget build(BuildContext context) {
final showingData = _getData();
return AxisChartScaffoldWidget(
data: showingData,
transformationConfig: widget.transformationConfig,
chartBuilder: (context, chartVirtualRect) => ScatterChartLeaf(
data:
_withTouchedIndicators(_scatterChartDataTween!.evaluate(animation)),
targetData: _withTouchedIndicators(showingData),
key: widget.chartRendererKey,
chartVirtualRect: chartVirtualRect,
canBeScaled: widget.transformationConfig.scaleAxis != FlScaleAxis.none,
),
);
}
ScatterChartData _withTouchedIndicators(ScatterChartData scatterChartData) {
if (!scatterChartData.scatterTouchData.enabled ||
!scatterChartData.scatterTouchData.handleBuiltInTouches) {
return scatterChartData;
}
return scatterChartData.copyWith(
showingTooltipIndicators: touchedSpots,
);
}
ScatterChartData _getData() {
final scatterTouchData = widget.data.scatterTouchData;
if (scatterTouchData.enabled && scatterTouchData.handleBuiltInTouches) {
_providedTouchCallback = scatterTouchData.touchCallback;
return widget.data.copyWith(
scatterTouchData: widget.data.scatterTouchData
.copyWith(touchCallback: _handleBuiltInTouch),
);
}
return widget.data;
}
void _handleBuiltInTouch(
FlTouchEvent event,
ScatterTouchResponse? touchResponse,
) {
if (!mounted) {
return;
}
_providedTouchCallback?.call(event, touchResponse);
final desiredTouch = event.isInterestedForInteractions;
if (!desiredTouch ||
touchResponse == null ||
touchResponse.touchedSpot == null) {
setState(() {
touchedSpots = [];
});
return;
}
setState(() {
touchedSpots = [touchResponse.touchedSpot!.spotIndex];
});
}
@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_scatterChartDataTween = visitor(
_scatterChartDataTween,
_getData(),
(dynamic value) => ScatterChartDataTween(
begin: value as ScatterChartData,
end: widget.data,
),
) as ScatterChartDataTween?;
}
}

View File

@@ -0,0 +1,796 @@
// coverage:ignore-file
import 'dart:ui';
import 'package:equatable/equatable.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/scatter_chart/scatter_chart_helper.dart';
import 'package:fl_chart/src/extensions/color_extension.dart';
import 'package:fl_chart/src/utils/lerp.dart';
import 'package:flutter/material.dart';
/// [ScatterChart] needs this class to render itself.
///
/// It holds data needed to draw a scatter chart,
/// including background color, scatter spots, ...
class ScatterChartData extends AxisChartData with EquatableMixin {
/// [ScatterChart] draws some points in a square space,
/// points are defined by [scatterSpots],
///
/// It draws some titles on left, top, right, bottom sides per each axis number,
/// you can modify [titlesData] to have your custom titles,
/// also you can define the axis title (one text per axis) for each side
/// using [axisTitleData], you can restrict the y axis using [minY] and [maxY] value,
/// and restrict x axis using [minX] and [maxX].
///
/// It draws a color as a background behind everything you can set it using [backgroundColor],
/// then a grid over it, you can customize it using [gridData],
/// and it draws 4 borders around your chart, you can customize it using [borderData].
///
/// You can modify [scatterTouchData] to customize touch behaviors and responses.
///
/// You can show some tooltipIndicators (a popup with an information)
/// on top of each [ScatterChartData.scatterSpots] using [showingTooltipIndicators],
/// just put spot indices you want to show it on top of them.
///
/// [clipData] forces the [LineChart] to draw lines inside the chart bounding box.
ScatterChartData({
List<ScatterSpot>? scatterSpots,
FlTitlesData? titlesData,
ScatterTouchData? scatterTouchData,
List<int>? showingTooltipIndicators,
FlGridData? gridData,
super.borderData,
double? minX,
double? maxX,
super.baselineX,
double? minY,
double? maxY,
super.baselineY,
FlClipData? clipData,
super.backgroundColor,
ScatterLabelSettings? scatterLabelSettings,
super.rotationQuarterTurns,
this.errorIndicatorData =
const FlErrorIndicatorData<ScatterChartSpotErrorRangeCallbackInput>(),
}) : scatterSpots = scatterSpots ?? const [],
scatterTouchData = scatterTouchData ?? ScatterTouchData(),
showingTooltipIndicators = showingTooltipIndicators ?? const [],
scatterLabelSettings = scatterLabelSettings ?? ScatterLabelSettings(),
super(
gridData: gridData ?? const FlGridData(),
titlesData: titlesData ?? const FlTitlesData(),
clipData: clipData ?? const FlClipData.none(),
minX: minX ??
ScatterChartHelper.calculateMaxAxisValues(
scatterSpots ?? const [],
).$1,
maxX: maxX ??
ScatterChartHelper.calculateMaxAxisValues(
scatterSpots ?? const [],
).$2,
minY: minY ??
ScatterChartHelper.calculateMaxAxisValues(
scatterSpots ?? const [],
).$3,
maxY: maxY ??
ScatterChartHelper.calculateMaxAxisValues(
scatterSpots ?? const [],
).$4,
);
final List<ScatterSpot> scatterSpots;
final ScatterTouchData scatterTouchData;
/// you can show some tooltipIndicators (a popup with an information)
/// on top of each [ScatterSpot] using [showingTooltipIndicators],
/// just put indices you want to show it on top of them.
///
/// An important point is that you have to disable the default touch behaviour
/// to show the tooltip manually, see [ScatterTouchData.handleBuiltInTouches].
final List<int> showingTooltipIndicators;
final ScatterLabelSettings scatterLabelSettings;
/// Holds data for showing error indicators on the [scatterSpots]
final FlErrorIndicatorData<ScatterChartSpotErrorRangeCallbackInput>
errorIndicatorData;
/// Lerps a [ScatterChartData] based on [t] value, check [Tween.lerp].
@override
ScatterChartData lerp(BaseChartData a, BaseChartData b, double t) {
if (a is ScatterChartData && b is ScatterChartData) {
return ScatterChartData(
scatterSpots: lerpScatterSpotList(a.scatterSpots, b.scatterSpots, t),
titlesData: FlTitlesData.lerp(a.titlesData, b.titlesData, t),
scatterTouchData: b.scatterTouchData,
showingTooltipIndicators: lerpIntList(
a.showingTooltipIndicators,
b.showingTooltipIndicators,
t,
),
gridData: FlGridData.lerp(a.gridData, b.gridData, t),
borderData: FlBorderData.lerp(a.borderData, b.borderData, t),
minX: lerpDouble(a.minX, b.minX, t),
maxX: lerpDouble(a.maxX, b.maxX, t),
baselineX: lerpDouble(a.baselineX, b.baselineX, t),
minY: lerpDouble(a.minY, b.minY, t),
maxY: lerpDouble(a.maxY, b.maxY, t),
baselineY: lerpDouble(a.baselineY, b.baselineY, t),
clipData: b.clipData,
backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t),
scatterLabelSettings: ScatterLabelSettings.lerp(
a.scatterLabelSettings,
b.scatterLabelSettings,
t,
),
rotationQuarterTurns: b.rotationQuarterTurns,
errorIndicatorData: FlErrorIndicatorData.lerp(
a.errorIndicatorData,
b.errorIndicatorData,
t,
),
);
} else {
throw Exception('Illegal State');
}
}
/// Copies current [ScatterChartData] to a new [ScatterChartData],
/// and replaces provided values.
ScatterChartData copyWith({
List<ScatterSpot>? scatterSpots,
FlTitlesData? titlesData,
ScatterTouchData? scatterTouchData,
List<int>? showingTooltipIndicators,
FlGridData? gridData,
FlBorderData? borderData,
double? minX,
double? maxX,
double? baselineX,
double? minY,
double? maxY,
double? baselineY,
FlClipData? clipData,
Color? backgroundColor,
ScatterLabelSettings? scatterLabelSettings,
int? rotationQuarterTurns,
FlErrorIndicatorData<ScatterChartSpotErrorRangeCallbackInput>?
errorIndicatorData,
}) =>
ScatterChartData(
scatterSpots: scatterSpots ?? this.scatterSpots,
titlesData: titlesData ?? this.titlesData,
scatterTouchData: scatterTouchData ?? this.scatterTouchData,
showingTooltipIndicators:
showingTooltipIndicators ?? this.showingTooltipIndicators,
gridData: gridData ?? this.gridData,
borderData: borderData ?? this.borderData,
minX: minX ?? this.minX,
maxX: maxX ?? this.maxX,
baselineX: baselineX ?? this.baselineX,
minY: minY ?? this.minY,
maxY: maxY ?? this.maxY,
baselineY: baselineY ?? this.baselineY,
clipData: clipData ?? this.clipData,
backgroundColor: backgroundColor ?? this.backgroundColor,
scatterLabelSettings: scatterLabelSettings ?? this.scatterLabelSettings,
rotationQuarterTurns: rotationQuarterTurns ?? this.rotationQuarterTurns,
errorIndicatorData: errorIndicatorData ?? this.errorIndicatorData,
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
scatterSpots,
scatterTouchData,
showingTooltipIndicators,
gridData,
titlesData,
rangeAnnotations,
minX,
maxX,
baselineX,
minY,
maxY,
baselineY,
rangeAnnotations,
scatterLabelSettings,
clipData,
backgroundColor,
borderData,
rotationQuarterTurns,
errorIndicatorData,
];
}
/// Defines information about a spot in the [ScatterChart]
class ScatterSpot extends FlSpot with EquatableMixin {
/// You can change [show] value to show or hide the spot,
/// [x], and [y] defines the location of spot in the [ScatterChart],
/// [radius] defines the size of spot, and [color] defines the color of it.
ScatterSpot(
super.x,
super.y, {
bool? show,
int? renderPriority,
FlDotPainter? dotPainter,
super.xError,
super.yError,
}) : show = show ?? true,
renderPriority = renderPriority ?? 0,
dotPainter = dotPainter ??
FlDotCirclePainter(
radius: 6,
color:
Colors.primaries[((x * y) % Colors.primaries.length).toInt()],
);
/// Determines show or hide the spot.
final bool show;
// Determines Z-Index of the spot
final int renderPriority;
/// Determines shape of the spot
final FlDotPainter dotPainter;
Size get size => dotPainter.getSize(this);
String get defaultLabel {
if (dotPainter is FlDotCirclePainter) {
return '${(dotPainter as FlDotCirclePainter).radius.toInt()}';
} else {
return '${x.toInt()}, ${y.toInt()}';
}
}
@override
ScatterSpot copyWith({
double? x,
double? y,
bool? show,
int? renderPriority,
FlDotPainter? dotPainter,
FlErrorRange? xError,
FlErrorRange? yError,
}) =>
ScatterSpot(
x ?? this.x,
y ?? this.y,
show: show ?? this.show,
renderPriority: renderPriority ?? this.renderPriority,
dotPainter: dotPainter ?? this.dotPainter,
xError: xError ?? this.xError,
yError: yError ?? this.yError,
);
/// Lerps a [ScatterSpot] based on [t] value, check [Tween.lerp].
static ScatterSpot lerp(ScatterSpot a, ScatterSpot b, double t) =>
ScatterSpot(
lerpDouble(a.x, b.x, t)!,
lerpDouble(a.y, b.y, t)!,
show: b.show,
renderPriority: a.renderPriority +
(t * (b.renderPriority - a.renderPriority)).round(),
dotPainter: a.dotPainter.lerp(a.dotPainter, b.dotPainter, t),
xError: FlErrorRange.lerp(a.xError, b.xError, t),
yError: FlErrorRange.lerp(a.yError, b.yError, t),
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
x,
y,
show,
renderPriority,
dotPainter,
xError,
yError,
];
}
/// Holds data to handle touch events, and touch responses in the [ScatterChart].
///
/// There is a touch flow, explained [here](https://github.com/imaNNeo/fl_chart/blob/main/repo_files/documentations/handle_touches.md)
/// in a simple way, each chart's renderer captures the touch events, and passes the pointerEvent
/// to the painter, and gets touched spot, and wraps it into a concrete [ScatterTouchResponse].
class ScatterTouchData extends FlTouchData<ScatterTouchResponse>
with EquatableMixin {
/// You can disable or enable the touch system using [enabled] flag,
///
/// [touchCallback] notifies you about the happened touch/pointer events.
/// It gives you a [FlTouchEvent] which is the happened event such as [FlPointerHoverEvent], [FlTapUpEvent], ...
/// It also gives you a [ScatterTouchResponse] which contains information
/// about the elements that has touched.
///
/// Using [mouseCursorResolver] you can change the mouse cursor
/// based on the provided [FlTouchEvent] and [ScatterTouchResponse]
///
/// if [handleBuiltInTouches] is true, [ScatterChart] shows a tooltip popup on top of the spots if
/// touch occurs (or you can show it manually using, [ScatterChartData.showingTooltipIndicators])
/// You can customize this tooltip using [touchTooltipData],
///
/// If you need to have a distance threshold for handling touches, use [touchSpotThreshold].
ScatterTouchData({
bool? enabled,
BaseTouchCallback<ScatterTouchResponse>? touchCallback,
MouseCursorResolver<ScatterTouchResponse>? mouseCursorResolver,
Duration? longPressDuration,
ScatterTouchTooltipData? touchTooltipData,
double? touchSpotThreshold,
bool? handleBuiltInTouches,
}) : touchTooltipData = touchTooltipData ?? const ScatterTouchTooltipData(),
touchSpotThreshold = touchSpotThreshold ?? 0,
handleBuiltInTouches = handleBuiltInTouches ?? true,
super(
enabled ?? true,
touchCallback,
mouseCursorResolver,
longPressDuration,
);
/// show a tooltip on touched spots
final ScatterTouchTooltipData touchTooltipData;
/// we find the nearest spots on touched position based on this threshold
final double touchSpotThreshold;
/// set this true if you want the built in touch handling
/// (show a tooltip bubble and an indicator on touched spots)
final bool handleBuiltInTouches;
/// Copies current [ScatterTouchData] to a new [ScatterTouchData],
/// and replaces provided values.
ScatterTouchData copyWith({
bool? enabled,
BaseTouchCallback<ScatterTouchResponse>? touchCallback,
MouseCursorResolver<ScatterTouchResponse>? mouseCursorResolver,
Duration? longPressDuration,
ScatterTouchTooltipData? touchTooltipData,
double? touchSpotThreshold,
bool? handleBuiltInTouches,
}) =>
ScatterTouchData(
enabled: enabled ?? this.enabled,
touchCallback: touchCallback ?? this.touchCallback,
mouseCursorResolver: mouseCursorResolver ?? this.mouseCursorResolver,
longPressDuration: longPressDuration ?? this.longPressDuration,
touchTooltipData: touchTooltipData ?? this.touchTooltipData,
handleBuiltInTouches: handleBuiltInTouches ?? this.handleBuiltInTouches,
touchSpotThreshold: touchSpotThreshold ?? this.touchSpotThreshold,
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
enabled,
touchCallback,
mouseCursorResolver,
longPressDuration,
touchTooltipData,
touchSpotThreshold,
handleBuiltInTouches,
];
}
/// [ScatterChart]'s touch callback.
typedef ScatterTouchCallback = void Function(ScatterTouchResponse);
/// Holds information about touch response in the [ScatterChart].
///
/// You can override [ScatterTouchData.touchCallback] to handle touch events,
/// it gives you a [ScatterTouchResponse] and you can do whatever you want.
class ScatterTouchResponse extends AxisBaseTouchResponse {
/// If touch happens, [ScatterChart] processes it internally and
/// passes out a [ScatterTouchResponse], it gives you information about the touched spot.
///
/// [touchedSpot] tells you
/// in which spot (of [ScatterChartData.scatterSpots]) touch happened.
ScatterTouchResponse({
required super.touchLocation,
required super.touchChartCoordinate,
required this.touchedSpot,
});
final ScatterTouchedSpot? touchedSpot;
/// Copies current [ScatterTouchResponse] to a new [ScatterTouchResponse],
/// and replaces provided values.
ScatterTouchResponse copyWith({
Offset? touchLocation,
Offset? touchChartCoordinate,
ScatterTouchedSpot? touchedSpot,
}) =>
ScatterTouchResponse(
touchLocation: touchLocation ?? this.touchLocation,
touchChartCoordinate: touchChartCoordinate ?? this.touchChartCoordinate,
touchedSpot: touchedSpot ?? this.touchedSpot,
);
}
/// Holds the touched spot data
class ScatterTouchedSpot with EquatableMixin {
/// [spot], and [spotIndex] tells you
/// in which spot (of [ScatterChartData.scatterSpots]) touch happened.
const ScatterTouchedSpot(this.spot, this.spotIndex);
/// Touch happened on this spot
final ScatterSpot spot;
/// Touch happened on this spot index
final int spotIndex;
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
spot,
spotIndex,
];
/// Copies current [ScatterTouchedSpot] to a new [ScatterTouchedSpot],
/// and replaces provided values.
ScatterTouchedSpot copyWith({
ScatterSpot? spot,
int? spotIndex,
}) =>
ScatterTouchedSpot(spot ?? this.spot, spotIndex ?? this.spotIndex);
}
/// Holds representation data for showing tooltip popup on top of spots.
class ScatterTouchTooltipData with EquatableMixin {
/// if [ScatterTouchData.handleBuiltInTouches] is true,
/// [ScatterChart] shows a tooltip popup on top of spots automatically when touch happens,
/// otherwise you can show it manually using [ScatterChartData.showingTooltipIndicators].
/// Tooltip shows on top of rods, with [getTooltipColor] as a background color.
/// You can set the corner radius using [tooltipBorderRadius],
/// If you want to have a padding inside the tooltip, fill [tooltipPadding].
/// Content of the tooltip will provide using [getTooltipItems] callback, you can override it
/// and pass your custom data to show in the tooltip.
/// You can restrict the tooltip's width using [maxContentWidth].
/// Sometimes, [ScatterChart] shows the tooltip outside of the chart,
/// you can set [fitInsideHorizontally] true to force it to shift inside the chart horizontally,
/// also you can set [fitInsideVertically] true to force it to shift inside the chart vertically.
const ScatterTouchTooltipData({
BorderRadius? tooltipBorderRadius,
EdgeInsets? tooltipPadding,
FLHorizontalAlignment? tooltipHorizontalAlignment,
double? tooltipHorizontalOffset,
double? maxContentWidth,
GetScatterTooltipItems? getTooltipItems,
bool? fitInsideHorizontally,
bool? fitInsideVertically,
double? rotateAngle,
BorderSide? tooltipBorder,
GetScatterTooltipColor? getTooltipColor,
}) : _tooltipBorderRadius = tooltipBorderRadius,
tooltipPadding = tooltipPadding ??
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
tooltipHorizontalAlignment =
tooltipHorizontalAlignment ?? FLHorizontalAlignment.center,
tooltipHorizontalOffset = tooltipHorizontalOffset ?? 0,
maxContentWidth = maxContentWidth ?? 120,
getTooltipItems = getTooltipItems ?? defaultScatterTooltipItem,
fitInsideHorizontally = fitInsideHorizontally ?? false,
fitInsideVertically = fitInsideVertically ?? false,
rotateAngle = rotateAngle ?? 0.0,
tooltipBorder = tooltipBorder ?? BorderSide.none,
getTooltipColor = getTooltipColor ?? defaultScatterTooltipColor,
super();
/// Sets a rounded radius for the tooltip.
final BorderRadius? _tooltipBorderRadius;
/// Sets a rounded radius for the tooltip.
BorderRadius get tooltipBorderRadius =>
_tooltipBorderRadius ?? BorderRadius.circular(4);
/// Applies a padding for showing contents inside the tooltip.
final EdgeInsets tooltipPadding;
/// Controls showing tooltip on left side, right side or center aligned with spot, default is center
final FLHorizontalAlignment tooltipHorizontalAlignment;
/// Applies horizontal offset for showing tooltip, default is zero.
final double tooltipHorizontalOffset;
/// Restricts the tooltip's width.
final double maxContentWidth;
/// Retrieves data for showing content inside the tooltip.
final GetScatterTooltipItems getTooltipItems;
/// Forces the tooltip to shift horizontally inside the chart, if overflow happens.
final bool fitInsideHorizontally;
/// Forces the tooltip to shift vertically inside the chart, if overflow happens.
final bool fitInsideVertically;
/// Controls the rotation of the tooltip.
final double rotateAngle;
/// The tooltip border color.
final BorderSide tooltipBorder;
/// Retrieves data for showing content inside the tooltip.
final GetScatterTooltipColor getTooltipColor;
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
_tooltipBorderRadius,
tooltipPadding,
tooltipHorizontalAlignment,
tooltipHorizontalOffset,
maxContentWidth,
getTooltipItems,
fitInsideHorizontally,
fitInsideVertically,
rotateAngle,
tooltipBorder,
getTooltipColor,
];
/// Copies current [ScatterTouchTooltipData] to a new [ScatterTouchTooltipData],
/// and replaces provided values.
ScatterTouchTooltipData copyWith({
EdgeInsets? tooltipPadding,
FLHorizontalAlignment? tooltipHorizontalAlignment,
double? tooltipHorizontalOffset,
double? maxContentWidth,
GetScatterTooltipItems? getTooltipItems,
bool? fitInsideHorizontally,
bool? fitInsideVertically,
double? rotateAngle,
BorderSide? tooltipBorder,
GetScatterTooltipColor? getTooltipColor,
}) =>
ScatterTouchTooltipData(
tooltipPadding: tooltipPadding ?? this.tooltipPadding,
tooltipHorizontalAlignment:
tooltipHorizontalAlignment ?? this.tooltipHorizontalAlignment,
tooltipHorizontalOffset:
tooltipHorizontalOffset ?? this.tooltipHorizontalOffset,
maxContentWidth: maxContentWidth ?? this.maxContentWidth,
getTooltipItems: getTooltipItems ?? this.getTooltipItems,
fitInsideHorizontally:
fitInsideHorizontally ?? this.fitInsideHorizontally,
fitInsideVertically: fitInsideVertically ?? this.fitInsideVertically,
rotateAngle: rotateAngle ?? this.rotateAngle,
tooltipBorder: tooltipBorder ?? this.tooltipBorder,
getTooltipColor: getTooltipColor ?? this.getTooltipColor,
);
}
/// Provides a [ScatterTooltipItem] for showing content inside the [ScatterTouchTooltipData].
///
/// You can override [ScatterTouchTooltipData.getTooltipItems], it gives you
/// [touchedSpot] that touch happened on,
/// then you should and pass your custom [ScatterTooltipItem]
/// to show it inside the tooltip popup.
typedef GetScatterTooltipItems = ScatterTooltipItem? Function(
ScatterSpot touchedSpot,
);
/// Default implementation for [ScatterTouchTooltipData.getTooltipItems].
ScatterTooltipItem? defaultScatterTooltipItem(ScatterSpot touchedSpot) {
final textStyle = TextStyle(
color: touchedSpot.dotPainter.mainColor,
fontWeight: FontWeight.bold,
fontSize: 14,
);
String text;
if (touchedSpot.dotPainter is FlDotCirclePainter) {
text = '${(touchedSpot.dotPainter as FlDotCirclePainter).radius.toInt()}';
} else {
text = '${touchedSpot.x.toInt()}, ${touchedSpot.y.toInt()}';
}
return ScatterTooltipItem(
text,
textStyle: textStyle,
);
}
/// Provides a [Color] to show different background color inside the [ScatterTouchTooltipData].
///
/// You can override [ScatterTouchTooltipData.getTooltipColor], it gives you
/// [touchedSpot] that touch happened on,
/// then you should and pass your custom [Color]
/// to show it inside the tooltip popup.
typedef GetScatterTooltipColor = Color Function(
ScatterSpot touchedSpot,
);
/// Default implementation for [ScatterTouchTooltipData.getTooltipItems].
Color defaultScatterTooltipColor(ScatterSpot touchedSpot) =>
Colors.blueGrey.darken(15);
/// Holds data of showing each item in the tooltip popup.
class ScatterTooltipItem with EquatableMixin {
/// Shows a [text] with [textStyle], [textDirection], and optional [children] in the tooltip popup,
/// [bottomMargin] is the bottom space from spot.
ScatterTooltipItem(
this.text, {
this.textStyle,
double? bottomMargin,
TextAlign? textAlign,
TextDirection? textDirection,
this.children,
}) : bottomMargin = bottomMargin ?? 8,
textAlign = textAlign ?? TextAlign.center,
textDirection = textDirection ?? TextDirection.ltr;
/// Showing text.
final String text;
/// Style of showing text.
final TextStyle? textStyle;
/// Defines bottom space from spot.
final double bottomMargin;
/// TextAlign of the showing content.
final TextAlign textAlign;
/// Direction of showing text.
final TextDirection textDirection;
/// Add further style and format to the text of the tooltip
final List<TextSpan>? children;
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
text,
textStyle,
bottomMargin,
textAlign,
textDirection,
children,
];
/// Copies current [ScatterTooltipItem] to a new [ScatterTooltipItem],
/// and replaces provided values.
ScatterTooltipItem copyWith({
String? text,
TextStyle? textStyle,
double? bottomMargin,
TextAlign? textAlign,
TextDirection? textDirection,
List<TextSpan>? children,
}) =>
ScatterTooltipItem(
text ?? this.text,
textStyle: textStyle ?? this.textStyle,
bottomMargin: bottomMargin ?? this.bottomMargin,
textAlign: textAlign ?? this.textAlign,
textDirection: textDirection ?? this.textDirection,
children: children ?? this.children,
);
}
/// It lerps a [ScatterChartData] to another [ScatterChartData] (handles animation for updating values)
class ScatterChartDataTween extends Tween<ScatterChartData> {
ScatterChartDataTween({
required ScatterChartData begin,
required ScatterChartData end,
}) : super(begin: begin, end: end);
/// Lerps a [ScatterChartData] based on [t] value, check [Tween.lerp].
@override
ScatterChartData lerp(double t) => begin!.lerp(begin!, end!, t);
}
/// It gives you the index value as well as the spot and gets the text style of the label.
typedef GetLabelTextStyleFunction = TextStyle? Function(
int spotIndex,
ScatterSpot spot,
);
/// It gives you the index value as well as the spot and returns the label of the spot.
typedef GetLabelFunction = String Function(
int spotIndex,
ScatterSpot spot,
);
/// It gives you the default text style of the label for a spot.
TextStyle? getDefaultLabelTextStyleFunction(
int spotIndex,
ScatterSpot spot,
) {
return null;
}
/// It gives you the default label of the spot.
String getDefaultLabelFunction(
int spotIndex,
ScatterSpot spot,
) =>
spot.defaultLabel;
/// Defines information about the labels in the [ScatterChart]
class ScatterLabelSettings with EquatableMixin {
/// You can change [showLabel] value to show or hide the label,
/// [textStyle] defines the style of label in the [ScatterChart].
ScatterLabelSettings({
bool? showLabel,
GetLabelTextStyleFunction? getLabelTextStyleFunction,
GetLabelFunction? getLabelFunction,
TextDirection? textDirection,
}) : showLabel = showLabel ?? false,
getLabelTextStyleFunction =
getLabelTextStyleFunction ?? getDefaultLabelTextStyleFunction,
getLabelFunction = getLabelFunction ?? getDefaultLabelFunction,
textDirection = textDirection ?? TextDirection.ltr;
/// Determines whether to show or hide the labels.
final bool showLabel;
/// This function gives you the index value of the spot in the list and returns the text style.
final GetLabelTextStyleFunction getLabelTextStyleFunction;
/// This function gives you the index value of the spot in the list and returns the label.
final GetLabelFunction getLabelFunction;
/// Determines the direction of the text for the labels.
final TextDirection textDirection;
ScatterLabelSettings copyWith({
bool? showLabel,
GetLabelTextStyleFunction? getLabelTextStyleFunction,
GetLabelFunction? getLabelFunction,
TextDirection? textDirection,
}) {
return ScatterLabelSettings(
showLabel: showLabel ?? this.showLabel,
getLabelTextStyleFunction:
getLabelTextStyleFunction ?? this.getLabelTextStyleFunction,
getLabelFunction: getLabelFunction ?? this.getLabelFunction,
textDirection: textDirection ?? this.textDirection,
);
}
/// Lerps a [ScatterLabelSettings] based on [t] value, check [Tween.lerp].
static ScatterLabelSettings lerp(
ScatterLabelSettings a,
ScatterLabelSettings b,
double t,
) =>
ScatterLabelSettings(
showLabel: b.showLabel,
getLabelTextStyleFunction: b.getLabelTextStyleFunction,
getLabelFunction: b.getLabelFunction,
textDirection: b.textDirection,
);
/// Used for equality check, see [EquatableMixin].
@override
List<Object?> get props => [
showLabel,
getLabelTextStyleFunction,
getLabelFunction,
textDirection,
];
}
/// It is the input of the [GetSpotRangeErrorPainter] callback in
/// the [ScatterChartData.errorIndicatorData]
///
/// It contains the [spot] and [spotIndex] that the error range
/// should be drawn for.
/// It works based on the [ScatterSpot.xError] and [ScatterSpot.yError] values.
class ScatterChartSpotErrorRangeCallbackInput
extends FlSpotErrorRangeCallbackInput {
ScatterChartSpotErrorRangeCallbackInput({
required this.spot,
required this.spotIndex,
});
final ScatterSpot spot;
final int spotIndex;
@override
List<Object?> get props => [
spot,
spotIndex,
];
}

View File

@@ -0,0 +1,43 @@
import 'package:fl_chart/src/chart/scatter_chart/scatter_chart_data.dart';
/// Contains anything that helps ScatterChart works
class ScatterChartHelper {
/// Calculates minX, maxX, minY, and maxY based on [scatterSpots],
/// returns cached values, to prevent redundant calculations.
static (
double minX,
double maxX,
double minY,
double maxY,
) calculateMaxAxisValues(
List<ScatterSpot> scatterSpots,
) {
if (scatterSpots.isEmpty) {
return (0, 0, 0, 0);
}
var minX = scatterSpots[0].x;
var maxX = scatterSpots[0].x;
var minY = scatterSpots[0].y;
var maxY = scatterSpots[0].y;
for (var j = 0; j < scatterSpots.length; j++) {
final spot = scatterSpots[j];
if (spot.x > maxX) {
maxX = spot.x;
}
if (spot.x < minX) {
minX = spot.x;
}
if (spot.y > maxY) {
maxY = spot.y;
}
if (spot.y < minY) {
minY = spot.y;
}
}
return (minX, maxX, minY, maxY);
}
}

View File

@@ -0,0 +1,472 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_painter.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:fl_chart/src/utils/utils.dart';
import 'package:flutter/material.dart';
/// Paints [ScatterChartData] in the canvas, it can be used in a [CustomPainter]
class ScatterChartPainter extends AxisChartPainter<ScatterChartData> {
/// Paints [dataList] into canvas, it is the animating [ScatterChartData],
/// [targetData] is the animation's target and remains the same
/// during animation, then we should use it when we need to show
/// tooltips or something like that, because [dataList] is changing constantly.
///
/// [textScale] used for scaling texts inside the chart,
/// parent can use [MediaQuery.textScaleFactor] to respect
/// the system's font size.
ScatterChartPainter() : super() {
_bgTouchTooltipPaint = Paint()
..style = PaintingStyle.fill
..color = Colors.white;
_borderTouchTooltipPaint = Paint()
..style = PaintingStyle.stroke
..color = Colors.transparent
..strokeWidth = 1.0;
_clipPaint = Paint();
}
late Paint _bgTouchTooltipPaint;
late Paint _borderTouchTooltipPaint;
late Paint _clipPaint;
/// Paints [ScatterChartData] into the provided canvas.
@override
void paint(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<ScatterChartData> holder,
) {
if (holder.chartVirtualRect != null) {
canvasWrapper
..saveLayer(
Offset.zero & canvasWrapper.size,
_clipPaint,
)
..clipRect(Offset.zero & canvasWrapper.size);
}
super.paint(context, canvasWrapper, holder);
drawSpots(context, canvasWrapper, holder);
if (holder.chartVirtualRect != null) {
canvasWrapper.restore();
}
drawTouchTooltips(context, canvasWrapper, holder);
}
@visibleForTesting
void drawSpots(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<ScatterChartData> holder,
) {
final data = holder.data;
final viewSize = canvasWrapper.size;
final clip = data.clipData;
final border = data.borderData.show ? data.borderData.border : null;
if (data.clipData.any) {
canvasWrapper.saveLayer(
Rect.fromLTRB(
0,
0,
canvasWrapper.size.width,
canvasWrapper.size.height,
),
_clipPaint,
);
var left = 0.0;
var top = 0.0;
var right = viewSize.width;
var bottom = viewSize.height;
if (clip.left) {
final borderWidth = border?.left.width ?? 0;
left = borderWidth / 2;
}
if (clip.top) {
final borderWidth = border?.top.width ?? 0;
top = borderWidth / 2;
}
if (clip.right) {
final borderWidth = border?.right.width ?? 0;
right = viewSize.width - (borderWidth / 2);
}
if (clip.bottom) {
final borderWidth = border?.bottom.width ?? 0;
bottom = viewSize.height - (borderWidth / 2);
}
canvasWrapper.clipRect(Rect.fromLTRB(left, top, right, bottom));
}
for (final scatterSpot in data.scatterSpots) {
if (!scatterSpot.show) {
continue;
}
final pixelX = getPixelX(scatterSpot.x, viewSize, holder);
final pixelY = getPixelY(scatterSpot.y, viewSize, holder);
canvasWrapper.drawDot(
scatterSpot.dotPainter,
scatterSpot,
Offset(pixelX, pixelY),
);
}
drawScatterErrorBars(canvasWrapper, holder);
if (data.scatterLabelSettings.showLabel) {
for (var i = 0; i < data.scatterSpots.length; i++) {
final scatterSpot = data.scatterSpots[i];
final spotIndex = i;
final label =
data.scatterLabelSettings.getLabelFunction(spotIndex, scatterSpot);
if (label.isEmpty || !scatterSpot.show) {
continue;
}
final span = TextSpan(
text: label,
style: Utils().getThemeAwareTextStyle(
context,
data.scatterLabelSettings.getLabelTextStyleFunction(
spotIndex,
scatterSpot,
),
),
);
final tp = TextPainter(
text: span,
textAlign: TextAlign.center,
textDirection: holder.data.scatterLabelSettings.textDirection,
textScaler: holder.textScaler,
)..layout(maxWidth: viewSize.width);
final pixelX = getPixelX(scatterSpot.x, viewSize, holder);
final pixelY = getPixelY(scatterSpot.y, viewSize, holder);
double newPixelY;
/// To ensure the label is centered horizontally with respect to the spot.
final newPixelX = pixelX - tp.width / 2;
final centerChartY = viewSize.height / 2;
final radius = scatterSpot.dotPainter.getSize(scatterSpot).width / 2;
/// if the spot is in the lower half of the chart, then draw the label either in the center or above the spot,
/// if the spot is in upper half of the chart, then draw the label either in the center or below the spot.
if (pixelY > centerChartY) {
/// if either the height or the width of the spot is greater than the radius of the spot, then draw the label above the bubble,
/// else draw the label inside the bubble.
final off = (radius * 1.5 < tp.height || radius * 1.5 < tp.width)
? radius + tp.height
: tp.height / 2;
newPixelY = pixelY - off;
} else {
/// if either the height or the width of the spot is greater than the radius of the spot, then draw the label below the bubble,
/// else draw the label inside the bubble.
final off = (radius * 1.5 < tp.height || radius * 1.5 < tp.width)
? radius
: -tp.height / 2;
newPixelY = pixelY + off;
}
canvasWrapper.drawText(
tp,
Offset(newPixelX, newPixelY),
);
}
}
if (data.clipData.any) {
canvasWrapper.restore();
}
}
@visibleForTesting
void drawScatterErrorBars(
CanvasWrapper canvasWrapper,
PaintHolder<ScatterChartData> holder,
) {
final data = holder.data;
final viewSize = canvasWrapper.size;
final errorIndicatorData = data.errorIndicatorData;
if (!errorIndicatorData.show) {
return;
}
for (var i = 0; i < data.scatterSpots.length; i++) {
final spot = data.scatterSpots[i];
if (!spot.show || spot.isNull()) {
continue;
}
final x = getPixelX(spot.x, viewSize, holder);
final y = getPixelY(spot.y, viewSize, holder);
if (spot.xError == null && spot.yError == null) {
continue;
}
var left = 0.0;
var right = 0.0;
if (spot.xError != null) {
left = getPixelX(spot.x - spot.xError!.lowerBy, viewSize, holder) - x;
right = getPixelX(spot.x + spot.xError!.upperBy, viewSize, holder) - x;
}
var top = 0.0;
var bottom = 0.0;
if (spot.yError != null) {
top = getPixelY(spot.y + spot.yError!.lowerBy, viewSize, holder) - y;
bottom = getPixelY(spot.y - spot.yError!.upperBy, viewSize, holder) - y;
}
final relativeErrorPixelsRect = Rect.fromLTRB(
left,
top,
right,
bottom,
);
final painter = errorIndicatorData.painter(
ScatterChartSpotErrorRangeCallbackInput(
spot: spot,
spotIndex: i,
),
);
canvasWrapper.drawErrorIndicator(
painter,
spot,
Offset(x, y),
relativeErrorPixelsRect,
holder.data,
);
}
}
@visibleForTesting
void drawTouchTooltips(
BuildContext context,
CanvasWrapper canvasWrapper,
PaintHolder<ScatterChartData> holder,
) {
final targetData = holder.targetData;
for (var i = 0; i < targetData.scatterSpots.length; i++) {
if (!targetData.showingTooltipIndicators.contains(i)) {
continue;
}
final scatterSpot = targetData.scatterSpots[i];
drawTouchTooltip(
context,
canvasWrapper,
targetData.scatterTouchData.touchTooltipData,
scatterSpot,
holder,
);
}
}
@visibleForTesting
void drawTouchTooltip(
BuildContext context,
CanvasWrapper canvasWrapper,
ScatterTouchTooltipData tooltipData,
ScatterSpot showOnSpot,
PaintHolder<ScatterChartData> holder,
) {
final viewSize = canvasWrapper.size;
final tooltipItem = tooltipData.getTooltipItems(showOnSpot);
if (tooltipItem == null) {
return;
}
final span = TextSpan(
style: Utils().getThemeAwareTextStyle(context, tooltipItem.textStyle),
text: tooltipItem.text,
children: tooltipItem.children,
);
final drawingTextPainter = TextPainter(
text: span,
textAlign: tooltipItem.textAlign,
textDirection: tooltipItem.textDirection,
textScaler: holder.textScaler,
)..layout(maxWidth: tooltipData.maxContentWidth);
final width = drawingTextPainter.width;
final height = drawingTextPainter.height;
final tooltipOriginPoint = Offset(
getPixelX(showOnSpot.x, viewSize, holder),
getPixelY(showOnSpot.y, viewSize, holder),
);
// Get the dot size to create an extended boundary
final dotSize = showOnSpot.dotPainter.getSize(showOnSpot);
final dotRadius = dotSize.width / 2;
final viewRect = Offset.zero & viewSize;
final extendedBoundary = viewRect.inflate(dotRadius);
// Check if any part of the dot is within the extended boundary
if (!extendedBoundary.contains(tooltipOriginPoint)) {
return;
}
final tooltipWidth = width + tooltipData.tooltipPadding.horizontal;
final tooltipHeight = height + tooltipData.tooltipPadding.vertical;
final tooltipLeftPosition = getTooltipLeft(
tooltipOriginPoint.dx,
tooltipWidth,
tooltipData.tooltipHorizontalAlignment,
tooltipData.tooltipHorizontalOffset,
);
/// draw the background rect with rounded radius
var rect = Rect.fromLTWH(
tooltipLeftPosition,
tooltipOriginPoint.dy -
tooltipHeight -
(showOnSpot.size.height / 2) -
tooltipItem.bottomMargin,
tooltipWidth,
tooltipHeight,
);
if (tooltipData.fitInsideHorizontally) {
if (rect.left < 0) {
final shiftAmount = 0 - rect.left;
rect = Rect.fromLTRB(
rect.left + shiftAmount,
rect.top,
rect.right + shiftAmount,
rect.bottom,
);
}
if (rect.right > viewSize.width) {
final shiftAmount = rect.right - viewSize.width;
rect = Rect.fromLTRB(
rect.left - shiftAmount,
rect.top,
rect.right - shiftAmount,
rect.bottom,
);
}
}
if (tooltipData.fitInsideVertically) {
if (rect.top < 0) {
final shiftAmount = 0 - rect.top;
rect = Rect.fromLTRB(
rect.left,
rect.top + shiftAmount,
rect.right,
rect.bottom + shiftAmount,
);
}
if (rect.bottom > viewSize.height) {
final shiftAmount = rect.bottom - viewSize.height;
rect = Rect.fromLTRB(
rect.left,
rect.top - shiftAmount,
rect.right,
rect.bottom - shiftAmount,
);
}
}
final roundedRect = RRect.fromRectAndCorners(
rect,
topLeft: tooltipData.tooltipBorderRadius.topLeft,
topRight: tooltipData.tooltipBorderRadius.topRight,
bottomLeft: tooltipData.tooltipBorderRadius.bottomLeft,
bottomRight: tooltipData.tooltipBorderRadius.bottomRight,
);
_bgTouchTooltipPaint.color = tooltipData.getTooltipColor(showOnSpot);
final rotateAngle = tooltipData.rotateAngle;
final rectRotationOffset =
Offset(0, Utils().calculateRotationOffset(rect.size, rotateAngle).dy);
final rectDrawOffset = Offset(roundedRect.left, roundedRect.top);
final textRotationOffset =
Utils().calculateRotationOffset(drawingTextPainter.size, rotateAngle);
final drawOffset = Offset(
rect.center.dx - (drawingTextPainter.width / 2),
rect.topCenter.dy +
tooltipData.tooltipPadding.top -
textRotationOffset.dy +
rectRotationOffset.dy,
);
if (tooltipData.tooltipBorder != BorderSide.none) {
_borderTouchTooltipPaint
..color = tooltipData.tooltipBorder.color
..strokeWidth = tooltipData.tooltipBorder.width;
}
final reverseQuarterTurnsAngle = -holder.data.rotationQuarterTurns * 90;
canvasWrapper.drawRotated(
size: rect.size,
rotationOffset: rectRotationOffset,
drawOffset: rectDrawOffset,
angle: reverseQuarterTurnsAngle + rotateAngle,
drawCallback: () {
canvasWrapper
..drawRRect(roundedRect, _bgTouchTooltipPaint)
..drawRRect(roundedRect, _borderTouchTooltipPaint)
..drawText(drawingTextPainter, drawOffset);
},
);
}
/// Makes a [ScatterTouchedSpot] based on the provided [localPosition]
///
/// Processes [localPosition] and checks
/// the elements of the chart that are near the offset,
/// then makes a [ScatterTouchedSpot] from the elements that has been touched.
///
/// Returns null if finds nothing!
ScatterTouchedSpot? handleTouch(
Offset localPosition,
Size viewSize,
PaintHolder<ScatterChartData> holder,
) {
final data = holder.data;
for (var i = data.scatterSpots.length - 1; i >= 0; i--) {
// Reverse the loop to check the topmost spot first
final spot = data.scatterSpots[i];
final spotPixelX = getPixelX(spot.x, viewSize, holder);
final spotPixelY = getPixelY(spot.y, viewSize, holder);
final center = Offset(spotPixelX, spotPixelY);
final touched = spot.dotPainter.hitTest(
spot,
localPosition,
center,
data.scatterTouchData.touchSpotThreshold,
);
if (touched) {
return ScatterTouchedSpot(spot, i);
}
}
return null;
}
}

View File

@@ -0,0 +1,144 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart';
import 'package:fl_chart/src/chart/base/base_chart/render_base_chart.dart';
import 'package:fl_chart/src/chart/scatter_chart/scatter_chart_painter.dart';
import 'package:fl_chart/src/utils/canvas_wrapper.dart';
import 'package:flutter/cupertino.dart';
// coverage:ignore-start
/// Low level ScatterChart Widget.
class ScatterChartLeaf extends LeafRenderObjectWidget {
const ScatterChartLeaf({
super.key,
required this.data,
required this.targetData,
required this.chartVirtualRect,
required this.canBeScaled,
});
final ScatterChartData data;
final ScatterChartData targetData;
final Rect? chartVirtualRect;
final bool canBeScaled;
@override
RenderScatterChart createRenderObject(BuildContext context) =>
RenderScatterChart(
context,
data,
targetData,
MediaQuery.of(context).textScaler,
chartVirtualRect,
canBeScaled: canBeScaled,
);
@override
void updateRenderObject(
BuildContext context,
RenderScatterChart renderObject,
) {
renderObject
..data = data
..targetData = targetData
..textScaler = MediaQuery.of(context).textScaler
..buildContext = context
..chartVirtualRect = chartVirtualRect
..canBeScaled = canBeScaled;
}
}
// coverage:ignore-end
/// Renders our ScatterChart, also handles hitTest.
class RenderScatterChart extends RenderBaseChart<ScatterTouchResponse> {
RenderScatterChart(
BuildContext context,
ScatterChartData data,
ScatterChartData targetData,
TextScaler textScaler,
Rect? chartVirtualRect, {
required bool canBeScaled,
}) : _data = data,
_targetData = targetData,
_textScaler = textScaler,
_chartVirtualRect = chartVirtualRect,
super(targetData.scatterTouchData, context, canBeScaled: canBeScaled);
ScatterChartData get data => _data;
ScatterChartData _data;
set data(ScatterChartData value) {
if (_data == value) return;
_data = value;
markNeedsPaint();
}
ScatterChartData get targetData => _targetData;
ScatterChartData _targetData;
set targetData(ScatterChartData value) {
if (_targetData == value) return;
_targetData = value;
super.updateBaseTouchData(_targetData.scatterTouchData);
markNeedsPaint();
}
TextScaler get textScaler => _textScaler;
TextScaler _textScaler;
set textScaler(TextScaler value) {
if (_textScaler == value) return;
_textScaler = value;
markNeedsPaint();
}
Rect? get chartVirtualRect => _chartVirtualRect;
Rect? _chartVirtualRect;
set chartVirtualRect(Rect? value) {
if (_chartVirtualRect == value) return;
_chartVirtualRect = value;
markNeedsPaint();
}
// We couldn't mock [size] property of this class, that's why we have this
@visibleForTesting
Size? mockTestSize;
@visibleForTesting
ScatterChartPainter painter = ScatterChartPainter();
PaintHolder<ScatterChartData> get paintHolder =>
PaintHolder(data, targetData, textScaler, chartVirtualRect);
@override
void paint(PaintingContext context, Offset offset) {
final canvas = context.canvas
..save()
..translate(offset.dx, offset.dy);
painter.paint(
buildContext,
CanvasWrapper(canvas, mockTestSize ?? size),
paintHolder,
);
canvas.restore();
}
@override
ScatterTouchResponse getResponseAtLocation(Offset localPosition) {
final chartSize = mockTestSize ?? size;
return ScatterTouchResponse(
touchLocation: localPosition,
touchChartCoordinate: painter.getChartCoordinateFromPixel(
localPosition,
chartSize,
paintHolder,
),
touchedSpot: painter.handleTouch(
localPosition,
chartSize,
paintHolder,
),
);
}
}

View File

@@ -0,0 +1,100 @@
import 'package:fl_chart/src/chart/bar_chart/bar_chart_data.dart';
extension BarChartDataExtension on BarChartData {
List<double> calculateGroupsX(double viewWidth) {
assert(barGroups.isNotEmpty);
final groupsX = List<double>.filled(barGroups.length, 0);
var sumWidth =
barGroups.map((group) => group.width).reduce((a, b) => a + b);
final spaceAvailable = viewWidth - sumWidth;
void spaceEvenly() {
final eachSpace = spaceAvailable / (barGroups.length + 1);
var tempX = 0.0;
barGroups.asMap().forEach((i, group) {
tempX += eachSpace;
tempX += group.width / 2;
groupsX[i] = tempX;
tempX += group.width / 2;
});
}
switch (alignment) {
case BarChartAlignment.start:
var tempX = 0.0;
for (var i = 0; i < barGroups.length; i++) {
final group = barGroups[i];
groupsX[i] = tempX + group.width / 2;
final groupSpace = i == barGroups.length - 1 ? 0 : groupsSpace;
tempX += group.width + groupSpace;
}
if (tempX > viewWidth) {
spaceEvenly();
}
case BarChartAlignment.end:
sumWidth += groupsSpace * (barGroups.length - 1);
final horizontalMargin = viewWidth - sumWidth;
var tempX = 0.0;
for (var i = 0; i < barGroups.length; i++) {
final group = barGroups[i];
groupsX[i] = horizontalMargin + tempX + group.width / 2;
final groupSpace = i == barGroups.length - 1 ? 0 : groupsSpace;
tempX += group.width + groupSpace;
}
if (tempX > viewWidth) {
spaceEvenly();
}
case BarChartAlignment.center:
sumWidth += groupsSpace * (barGroups.length - 1);
final horizontalMargin = (viewWidth - sumWidth) / 2;
var tempX = 0.0;
for (var i = 0; i < barGroups.length; i++) {
final group = barGroups[i];
groupsX[i] = horizontalMargin + tempX + group.width / 2;
final groupSpace = i == barGroups.length - 1 ? 0 : groupsSpace;
tempX += group.width + groupSpace;
}
if (tempX > viewWidth) {
spaceEvenly();
}
case BarChartAlignment.spaceBetween:
final eachSpace = spaceAvailable / (barGroups.length - 1);
var tempX = 0.0;
barGroups.asMap().forEach((index, group) {
tempX += group.width / 2;
if (index != 0) {
tempX += eachSpace;
}
groupsX[index] = tempX;
tempX += group.width / 2;
});
case BarChartAlignment.spaceAround:
final eachSpace = spaceAvailable / (barGroups.length * 2);
var tempX = 0.0;
barGroups.asMap().forEach((i, group) {
tempX += eachSpace;
tempX += group.width / 2;
groupsX[i] = tempX;
tempX += group.width / 2;
tempX += eachSpace;
});
case BarChartAlignment.spaceEvenly:
spaceEvenly();
}
return groupsX;
}
}

View File

@@ -0,0 +1,20 @@
import 'package:flutter/widgets.dart';
extension BorderExtension on Border {
bool isVisible() {
if (left.width == 0 &&
top.width == 0 &&
right.width == 0 &&
bottom.width == 0) {
return false;
}
if (left.color.a == 0.0 &&
top.color.a == 0.0 &&
right.color.a == 0.0 &&
bottom.color.a == 0.0) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
extension ColorExtension on Color {
/// Convert the color to a darken color based on the [percent]
Color darken([int percent = 40]) {
assert(1 <= percent && percent <= 100);
final value = 1 - percent / 100;
return Color.fromARGB(
_floatToInt8(a),
(_floatToInt8(r) * value).round(),
(_floatToInt8(g) * value).round(),
(_floatToInt8(b) * value).round(),
);
}
// Int color components were deprecated in Flutter 3.27.0.
// This method is used to convert the new double color components to the
// old int color components.
//
// Taken from the Color class.
int _floatToInt8(double x) {
return (x * 255.0).round() & 0xff;
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/widgets.dart';
extension EdgeInsetsExtension on EdgeInsets {
EdgeInsets get onlyTopBottom => EdgeInsets.only(
top: top,
bottom: bottom,
);
EdgeInsets get onlyLeftRight => EdgeInsets.only(
left: left,
right: right,
);
}

View File

@@ -0,0 +1,11 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/widgets.dart';
extension FlBorderDataExtension on FlBorderData {
EdgeInsets get allSidesPadding => EdgeInsets.only(
left: show ? border.left.width : 0.0,
top: show ? border.top.width : 0.0,
right: show ? border.right.width : 0.0,
bottom: show ? border.bottom.width : 0.0,
);
}

View File

@@ -0,0 +1,12 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/extensions/side_titles_extension.dart';
import 'package:flutter/widgets.dart';
extension FlTitlesDataExtension on FlTitlesData {
EdgeInsets get allSidesPadding => EdgeInsets.only(
left: show ? leftTitles.totalReservedSize : 0.0,
top: show ? topTitles.totalReservedSize : 0.0,
right: show ? rightTitles.totalReservedSize : 0.0,
bottom: show ? bottomTitles.totalReservedSize : 0.0,
);
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter/painting.dart';
/// Extensions on [Gradient]
extension GradientExtension on Gradient {
/// Returns color stops.
///
/// If [stops] has the same length as [colors], returns it directly.
/// Otherwise, calculates stops linearly between 0.0 and 1.0.
///
/// Throws [ArgumentError] if [colors] has less than 2 colors.
List<double> getSafeColorStops() {
if (stops?.length == colors.length) {
return stops!;
}
if (colors.length <= 1) {
throw ArgumentError('"colors" must have length > 1.');
}
final stopsStep = 1.0 / (colors.length - 1);
return [
for (var index = 0; index < colors.length; index++) index * stopsStep,
];
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
extension PaintExtension on Paint {
/// Hides the paint's color, if strokeWidth is zero
void transparentIfWidthIsZero() {
if (strokeWidth == 0) {
shader = null;
color = color.withValues(alpha: 0);
}
}
void setColorOrGradient(Color? color, Gradient? gradient, Rect rect) {
if (gradient != null) {
this.color = Colors.black;
shader = gradient.createShader(rect);
} else {
this.color = color ?? Colors.transparent;
shader = null;
}
}
void setColorOrGradientForLine(
Color? color,
Gradient? gradient, {
required Offset from,
required Offset to,
}) {
final rect = Rect.fromPoints(from, to);
setColorOrGradient(color, gradient, rect);
}
}

View File

@@ -0,0 +1,23 @@
import 'dart:ui';
import 'package:fl_chart/src/utils/path_drawing/dash_path.dart';
/// Defines extensions on the [Path]
extension DashedPath on Path {
/// Returns a dashed path based on [dashArray].
///
/// it is a circular array of dash offsets and lengths.
/// For example, the array `[5, 10]` would result in dashes 5 pixels long
/// followed by blank spaces 10 pixels long.
Path toDashedPath(List<int>? dashArray) {
if (dashArray != null) {
final castedArray = dashArray.map((value) => value.toDouble()).toList();
final dashedPath =
dashPath(this, dashArray: CircularIntervalList<double>(castedArray));
return dashedPath;
} else {
return this;
}
}
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter/cupertino.dart';
/// Defines extensions on the [RRect]
extension RRectExtension on RRect {
/// Return [Rect] from [RRect]
Rect getRect() => Rect.fromLTRB(
left,
top,
right,
bottom,
);
}

View File

@@ -0,0 +1,14 @@
import 'package:fl_chart/src/chart/base/axis_chart/axis_chart_data.dart';
extension SideTitlesExtension on AxisTitles {
double get totalReservedSize {
var size = 0.0;
if (showAxisTitles) {
size += axisNameSize;
}
if (showSideTitles) {
size += sideTitles.reservedSize;
}
return size;
}
}

View File

@@ -0,0 +1,13 @@
import 'dart:ui';
extension SizeExtension on Size {
Size rotateByQuarterTurns(int quarterTurns) {
if (quarterTurns < 0) {
throw ArgumentError('quarterTurns must be greater than or equal to 0.');
}
return switch (quarterTurns % 4) {
0 || 2 => this,
_ /*2 || 3*/ => Size(height, width),
};
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
enum HorizontalAlignment { left, center, right }
extension TextAlignExtension on TextAlign {
HorizontalAlignment getFinalHorizontalAlignment(TextDirection? direction) {
if ((this == TextAlign.left) ||
(this == TextAlign.start && direction == TextDirection.ltr) ||
(this == TextAlign.end && direction == TextDirection.rtl)) {
return HorizontalAlignment.left;
} else if ((this == TextAlign.right) ||
(this == TextAlign.end && direction == TextDirection.ltr) ||
(this == TextAlign.start && direction == TextDirection.rtl)) {
return HorizontalAlignment.right;
} else {
return HorizontalAlignment.center;
}
}
}

View File

@@ -0,0 +1,168 @@
import 'dart:ui';
import 'package:fl_chart/fl_chart.dart';
import 'package:fl_chart/src/extensions/path_extension.dart';
import 'package:fl_chart/src/utils/utils.dart';
import 'package:flutter/cupertino.dart' hide Image;
typedef DrawCallback = void Function();
/// Proxies Canvas functions
///
/// We wrapped the canvas here, because we needed to write tests for our drawing system.
/// Now in tests we can verify that these functions called with a specific value.
class CanvasWrapper {
CanvasWrapper(
this.canvas,
this.size,
);
final Canvas canvas;
final Size size;
/// Directly calls [Canvas.drawRRect]
void drawRRect(RRect rrect, Paint paint) => canvas.drawRRect(rrect, paint);
/// Directly calls [Canvas.save]
void save() => canvas.save();
/// Directly calls [Canvas.restore]
void restore() => canvas.restore();
/// Directly calls [Canvas.clipRect]
void clipRect(
Rect rect, {
ClipOp clipOp = ClipOp.intersect,
bool doAntiAlias = true,
}) =>
canvas.clipRect(rect, clipOp: clipOp, doAntiAlias: doAntiAlias);
/// Directly calls [Canvas.translate]
void translate(double dx, double dy) => canvas.translate(dx, dy);
/// Directly calls [Canvas.rotate]
void rotate(double radius) => canvas.rotate(radius);
/// Directly calls [Canvas.drawPath]
void drawPath(Path path, Paint paint) => canvas.drawPath(path, paint);
/// Directly calls [Canvas.saveLayer]
void saveLayer(Rect bounds, Paint paint) => canvas.saveLayer(bounds, paint);
/// Directly calls [Canvas.drawPicture]
void drawPicture(Picture picture) => canvas.drawPicture(picture);
/// Directly calls [Canvas.drawImage]
void drawImage(Image image, Offset offset, Paint paint) =>
canvas.drawImage(image, offset, paint);
/// Directly calls [Canvas.clipPath]
void clipPath(Path path, {bool doAntiAlias = true}) =>
canvas.clipPath(path, doAntiAlias: doAntiAlias);
/// Directly calls [Canvas.drawRect]
void drawRect(Rect rect, Paint paint) => canvas.drawRect(rect, paint);
/// Directly calls [Canvas.drawLine]
void drawLine(Offset p1, Offset p2, Paint paint) =>
canvas.drawLine(p1, p2, paint);
/// Directly calls [Canvas.drawCircle]
void drawCircle(Offset center, double radius, Paint paint) =>
canvas.drawCircle(center, radius, paint);
/// Directly calls [Canvas.drawCircle]
void drawArc(
Rect rect,
double startAngle,
double sweepAngle,
bool useCenter,
Paint paint,
) =>
canvas.drawArc(rect, startAngle, sweepAngle, useCenter, paint);
/// Paints a text on the [Canvas]
///
/// Gets a [TextPainter] and call its [TextPainter.paint] using our canvas
void drawText(TextPainter tp, Offset offset, [double? rotateAngle]) {
if (rotateAngle == null) {
tp.paint(canvas, offset);
} else {
drawRotated(
size: tp.size,
drawOffset: offset,
angle: rotateAngle,
drawCallback: () {
tp.paint(canvas, offset);
},
);
}
}
/// Paints a vertical text on the [Canvas]
///
/// Gets a [TextPainter] and call its [TextPainter.paint] using our canvas
void drawVerticalText(TextPainter tp, Offset offset) {
save();
translate(offset.dx, offset.dy);
rotate(Utils().radians(90));
translate(-offset.dx, -offset.dy);
tp.paint(canvas, offset);
restore();
}
/// Paints a dot using customized [FlDotPainter]
///
/// Paints a customized dot using [FlDotPainter] at the [spot]'s position,
/// with the [offset]
void drawDot(FlDotPainter painter, FlSpot spot, Offset offset) {
painter.draw(canvas, spot, offset);
}
/// Paints a error indicator using the [painter]
void drawErrorIndicator(
FlSpotErrorRangePainter painter,
FlSpot origin,
Offset offset,
Rect errorRelativeRect,
AxisChartData axisData,
) {
painter.draw(canvas, offset, origin, errorRelativeRect, axisData);
}
/// Handles performing multiple draw actions rotated.
void drawRotated({
required Size size,
Offset rotationOffset = Offset.zero,
Offset drawOffset = Offset.zero,
required double angle,
required DrawCallback drawCallback,
}) {
save();
translate(
rotationOffset.dx + drawOffset.dx + size.width / 2,
rotationOffset.dy + drawOffset.dy + size.height / 2,
);
rotate(Utils().radians(angle));
translate(
-drawOffset.dx - size.width / 2,
-drawOffset.dy - size.height / 2,
);
drawCallback();
restore();
}
/// Draws a dashed line from passed in offsets
void drawDashedLine(
Offset from,
Offset to,
Paint painter,
List<int>? dashArray,
) {
var path = Path()
..moveTo(from.dx, from.dy)
..lineTo(to.dx, to.dy);
path = path.toDashedPath(dashArray);
drawPath(path, painter);
}
}

201
lib/src/utils/lerp.dart Normal file
View File

@@ -0,0 +1,201 @@
import 'dart:ui';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
@visibleForTesting
List<T>? lerpList<T>(
List<T>? a,
List<T>? b,
double t, {
required T Function(T, T, double) lerp,
}) {
if (a != null && b != null && a.length == b.length) {
return List.generate(a.length, (i) {
return lerp(a[i], b[i], t);
});
} else if (a != null && b != null) {
return List.generate(b.length, (i) {
return lerp(i >= a.length ? b[i] : a[i], b[i], t);
});
} else {
return b;
}
}
/// Lerps [Color] list based on [t] value, check [Tween.lerp].
List<Color>? lerpColorList(List<Color>? a, List<Color>? b, double t) =>
lerpList(a, b, t, lerp: lerpColor);
/// Lerps [Color] based on [t] value, check [Color.lerp].
Color lerpColor(Color a, Color b, double t) => Color.lerp(a, b, t)!;
/// Lerps [double] list based on [t] value, allows [double.infinity].
double? lerpDoubleAllowInfinity(double? a, double? b, double t) {
if (a == b || (a?.isNaN == true) && (b?.isNaN == true)) {
return a;
}
if (a!.isInfinite || b!.isInfinite) {
return b;
}
assert(a.isFinite, 'Cannot interpolate between finite and non-finite values');
assert(b.isFinite, 'Cannot interpolate between finite and non-finite values');
assert(t.isFinite, 't must be finite when interpolating between values');
return a * (1.0 - t) + b * t;
}
/// Lerps [double] list based on [t] value, check [Tween.lerp].
List<double>? lerpDoubleList(List<double>? a, List<double>? b, double t) =>
lerpList(a, b, t, lerp: lerpNonNullDouble);
/// Lerps [int] list based on [t] value, check [Tween.lerp].
List<int>? lerpIntList(List<int>? a, List<int>? b, double t) =>
lerpList(a, b, t, lerp: lerpInt);
/// Lerps [int] list based on [t] value, check [Tween.lerp].
int lerpInt(int a, int b, double t) => (a + (b - a) * t).round();
@visibleForTesting
double lerpNonNullDouble(double a, double b, double t) => lerpDouble(a, b, t)!;
/// Lerps [FlSpot] list based on [t] value, check [Tween.lerp].
List<FlSpot>? lerpFlSpotList(List<FlSpot>? a, List<FlSpot>? b, double t) =>
lerpList(a, b, t, lerp: FlSpot.lerp);
/// Lerps [HorizontalLine] list based on [t] value, check [Tween.lerp].
List<HorizontalLine>? lerpHorizontalLineList(
List<HorizontalLine>? a,
List<HorizontalLine>? b,
double t,
) =>
lerpList(a, b, t, lerp: HorizontalLine.lerp);
/// Lerps [VerticalLine] list based on [t] value, check [Tween.lerp].
List<VerticalLine>? lerpVerticalLineList(
List<VerticalLine>? a,
List<VerticalLine>? b,
double t,
) =>
lerpList(a, b, t, lerp: VerticalLine.lerp);
/// Lerps [HorizontalRangeAnnotation] list based on [t] value, check [Tween.lerp].
List<HorizontalRangeAnnotation>? lerpHorizontalRangeAnnotationList(
List<HorizontalRangeAnnotation>? a,
List<HorizontalRangeAnnotation>? b,
double t,
) =>
lerpList(a, b, t, lerp: HorizontalRangeAnnotation.lerp);
/// Lerps [VerticalRangeAnnotation] list based on [t] value, check [Tween.lerp].
List<VerticalRangeAnnotation>? lerpVerticalRangeAnnotationList(
List<VerticalRangeAnnotation>? a,
List<VerticalRangeAnnotation>? b,
double t,
) =>
lerpList(a, b, t, lerp: VerticalRangeAnnotation.lerp);
/// Lerps [LineChartBarData] list based on [t] value, check [Tween.lerp].
List<LineChartBarData>? lerpLineChartBarDataList(
List<LineChartBarData>? a,
List<LineChartBarData>? b,
double t,
) =>
lerpList(a, b, t, lerp: LineChartBarData.lerp);
/// Lerps [BetweenBarsData] list based on [t] value, check [Tween.lerp].
List<BetweenBarsData>? lerpBetweenBarsDataList(
List<BetweenBarsData>? a,
List<BetweenBarsData>? b,
double t,
) =>
lerpList(a, b, t, lerp: BetweenBarsData.lerp);
/// Lerps [BarChartGroupData] list based on [t] value, check [Tween.lerp].
List<BarChartGroupData>? lerpBarChartGroupDataList(
List<BarChartGroupData>? a,
List<BarChartGroupData>? b,
double t,
) =>
lerpList(a, b, t, lerp: BarChartGroupData.lerp);
/// Lerps [BarChartRodData] list based on [t] value, check [Tween.lerp].
List<BarChartRodData>? lerpBarChartRodDataList(
List<BarChartRodData>? a,
List<BarChartRodData>? b,
double t,
) =>
lerpList(a, b, t, lerp: BarChartRodData.lerp);
/// Lerps [PieChartSectionData] list based on [t] value, check [Tween.lerp].
List<PieChartSectionData>? lerpPieChartSectionDataList(
List<PieChartSectionData>? a,
List<PieChartSectionData>? b,
double t,
) =>
lerpList(a, b, t, lerp: PieChartSectionData.lerp);
/// Lerps [ScatterSpot] list based on [t] value, check [Tween.lerp].
List<ScatterSpot>? lerpScatterSpotList(
List<ScatterSpot>? a,
List<ScatterSpot>? b,
double t,
) =>
lerpList(a, b, t, lerp: ScatterSpot.lerp);
/// Lerps [CandlestickSpot] list based on [t] value, check [Tween.lerp].
List<CandlestickSpot>? lerpCandleSpotList(
List<CandlestickSpot>? a,
List<CandlestickSpot>? b,
double t,
) =>
lerpList(a, b, t, lerp: CandlestickSpot.lerp);
/// Lerps [BarChartRodStackItem] list based on [t] value, check [Tween.lerp].
List<BarChartRodStackItem>? lerpBarChartRodStackList(
List<BarChartRodStackItem>? a,
List<BarChartRodStackItem>? b,
double t,
) =>
lerpList(a, b, t, lerp: BarChartRodStackItem.lerp);
/// Lerps [RadarDataSet] list based on [t] value, check [Tween.lerp].
List<RadarDataSet>? lerpRadarDataSetList(
List<RadarDataSet>? a,
List<RadarDataSet>? b,
double t,
) =>
lerpList(a, b, t, lerp: RadarDataSet.lerp);
/// Lerps [RadarEntry] list based on [t] value, check [Tween.lerp].
List<RadarEntry>? lerpRadarEntryList(
List<RadarEntry>? a,
List<RadarEntry>? b,
double t,
) =>
lerpList(a, b, t, lerp: RadarEntry.lerp);
/// Lerps between a [LinearGradient] colors, based on [t]
Color lerpGradient(List<Color> colors, List<double> stops, double t) {
final length = colors.length;
if (stops.length != length) {
/// provided gradientColorStops is invalid and we calculate it here
stops = List.generate(length, (i) => (i + 1) / length);
}
for (var s = 0; s < stops.length - 1; s++) {
final leftStop = stops[s];
final rightStop = stops[s + 1];
final leftColor = colors[s];
final rightColor = colors[s + 1];
if (t <= leftStop) {
return leftColor;
} else if (t < rightStop) {
final sectionT = (t - leftStop) / (rightStop - leftStop);
return Color.lerp(leftColor, rightColor, sectionT)!;
}
}
return colors.last;
}

View File

@@ -0,0 +1,86 @@
import 'dart:ui';
/// Came from [flutter_path_drawing](https://github.com/dnfield/flutter_path_drawing) library.
/// Creates a new path that is drawn from the segments of `source`.
///
/// Dash intervals are controlled by the `dashArray` - see [CircularIntervalList]
/// for examples.
///
/// `dashOffset` specifies an initial starting point for the dashing.
///
/// Passing a `source` that is an empty path will return an empty path.
Path dashPath(
Path source, {
required CircularIntervalList<double> dashArray,
DashOffset? dashOffset,
}) {
dashOffset = dashOffset ?? const DashOffset.absolute(0);
// TODO(imaNNeo): Is there some way to determine how much of a path would be visible today?
final dest = Path();
for (final metric in source.computeMetrics()) {
var distance = dashOffset._calculate(metric.length);
var draw = true;
while (distance < metric.length) {
final len = dashArray.next;
if (draw) {
dest.addPath(metric.extractPath(distance, distance + len), Offset.zero);
}
distance += len;
draw = !draw;
}
}
return dest;
}
enum _DashOffsetType { absolute, percentage }
/// Specifies the starting position of a dash array on a path, either as a
/// percentage or absolute value.
///
/// The internal value will be guaranteed to not be null.
class DashOffset {
/// Create a DashOffset that will be measured as a percentage of the length
/// of the segment being dashed.
///
/// `percentage` will be clamped between 0.0 and 1.0.
DashOffset.percentage(double percentage)
: _rawVal = percentage.clamp(0.0, 1.0),
_dashOffsetType = _DashOffsetType.percentage;
/// Create a DashOffset that will be measured in terms of absolute pixels
/// along the length of a [Path] segment.
const DashOffset.absolute(double start)
: _rawVal = start,
_dashOffsetType = _DashOffsetType.absolute;
final double _rawVal;
final _DashOffsetType _dashOffsetType;
double _calculate(double length) =>
_dashOffsetType == _DashOffsetType.absolute ? _rawVal : length * _rawVal;
}
/// A circular array of dash offsets and lengths.
///
/// For example, the array `[5, 10]` would result in dashes 5 pixels long
/// followed by blank spaces 10 pixels long. The array `[5, 10, 5]` would
/// result in a 5 pixel dash, a 10 pixel gap, a 5 pixel dash, a 5 pixel gap,
/// a 10 pixel dash, etc.
///
/// Note that this does not quite conform to an [Iterable<T>], because it does
/// not have a moveNext.
class CircularIntervalList<T> {
CircularIntervalList(this._values);
final List<T> _values;
int _idx = 0;
T get next {
if (_idx >= _values.length) {
_idx = 0;
}
return _values[_idx++];
}
}

314
lib/src/utils/utils.dart Normal file
View File

@@ -0,0 +1,314 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
class Utils {
factory Utils() {
return _singleton;
}
Utils._internal();
static Utils _singleton = Utils._internal();
@visibleForTesting
static void changeInstance(Utils val) => _singleton = val;
static const double _degrees2Radians = math.pi / 180.0;
/// Converts degrees to radians
double radians(double degrees) => degrees * _degrees2Radians;
static const double _radians2Degrees = 180.0 / math.pi;
/// Converts radians to degrees
double degrees(double radians) => radians * _radians2Degrees;
/// Forward the view base on its degree
double translateRotatedPosition(double size, double degree) {
return (size / 4) * math.sin(radians(degree.abs()));
}
Offset calculateRotationOffset(Size size, double degree) {
final rotatedHeight = (size.width * math.sin(radians(degree))).abs() +
(size.height * math.cos(radians(degree))).abs();
final rotatedWidth = (size.width * math.cos(radians(degree))).abs() +
(size.height * math.sin(radians(degree))).abs();
return Offset(
(size.width - rotatedWidth) / 2,
(size.height - rotatedHeight) / 2,
);
}
/// Decreases [borderRadius] to <= width / 2
BorderRadius? normalizeBorderRadius(
BorderRadius? borderRadius,
double width,
) {
if (borderRadius == null) {
return null;
}
Radius topLeft;
if (borderRadius.topLeft.x > width / 2 ||
borderRadius.topLeft.y > width / 2) {
topLeft = Radius.circular(width / 2);
} else {
topLeft = borderRadius.topLeft;
}
Radius topRight;
if (borderRadius.topRight.x > width / 2 ||
borderRadius.topRight.y > width / 2) {
topRight = Radius.circular(width / 2);
} else {
topRight = borderRadius.topRight;
}
Radius bottomLeft;
if (borderRadius.bottomLeft.x > width / 2 ||
borderRadius.bottomLeft.y > width / 2) {
bottomLeft = Radius.circular(width / 2);
} else {
bottomLeft = borderRadius.bottomLeft;
}
Radius bottomRight;
if (borderRadius.bottomRight.x > width / 2 ||
borderRadius.bottomRight.y > width / 2) {
bottomRight = Radius.circular(width / 2);
} else {
bottomRight = borderRadius.bottomRight;
}
return BorderRadius.only(
topLeft: topLeft,
topRight: topRight,
bottomLeft: bottomLeft,
bottomRight: bottomRight,
);
}
/// Default value for BorderSide where borderSide value is not exists
static const BorderSide defaultBorderSide = BorderSide(width: 0);
/// Decreases [borderSide] to <= width / 2
BorderSide normalizeBorderSide(BorderSide? borderSide, double width) {
if (borderSide == null) {
return defaultBorderSide;
}
double borderWidth;
if (borderSide.width > width / 2) {
borderWidth = width / 2.toDouble();
} else {
borderWidth = borderSide.width;
}
return borderSide.copyWith(width: borderWidth);
}
/// Returns an efficient interval for showing axis titles, or grid lines or ...
///
/// If there isn't any provided interval, we use this function to calculate an interval to apply,
/// using [axisViewSize] / [pixelPerInterval], we calculate the allowedCount lines in the axis,
/// then using [diffInAxis] / allowedCount, we can find out how much interval we need,
/// then we round that number by finding nearest number in this pattern:
/// 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 5000, 10000,...
double getEfficientInterval(
double axisViewSize,
double diffInAxis, {
double pixelPerInterval = 40,
}) {
final allowedCount = math.max(axisViewSize ~/ pixelPerInterval, 1);
if (diffInAxis == 0) {
return 1;
}
final accurateInterval =
diffInAxis == 0 ? axisViewSize : diffInAxis / allowedCount;
if (allowedCount <= 2) {
return accurateInterval;
}
return roundInterval(accurateInterval);
}
@visibleForTesting
double roundInterval(double input) {
if (input < 1) {
return _roundIntervalBelowOne(input);
}
return _roundIntervalAboveOne(input);
}
double _roundIntervalBelowOne(double input) {
assert(input < 1.0);
if (input < 0.000001) {
return input;
}
final inputString = input.toString();
var precisionCount = inputString.length - 2;
var zeroCount = 0;
for (var i = 2; i <= inputString.length; i++) {
if (inputString[i] != '0') {
break;
}
zeroCount++;
}
final afterZerosNumberLength = precisionCount - zeroCount;
if (afterZerosNumberLength > 2) {
final numbersToRemove = afterZerosNumberLength - 2;
precisionCount -= numbersToRemove;
}
final pow10onPrecision = math.pow(10, precisionCount);
input *= pow10onPrecision;
return _roundIntervalAboveOne(input) / pow10onPrecision;
}
double _roundIntervalAboveOne(double input) {
assert(input >= 1.0);
final decimalCount = input.toInt().toString().length - 1;
input /= math.pow(10, decimalCount);
final scaled = input >= 10 ? input.round() / 10 : input;
if (scaled >= 7.6) {
return 10 * math.pow(10, decimalCount).toInt().toDouble();
} else if (scaled >= 2.6) {
return 5 * math.pow(10, decimalCount).toInt().toDouble();
} else if (scaled >= 1.6) {
return 2 * math.pow(10, decimalCount).toInt().toDouble();
} else {
return 1 * math.pow(10, decimalCount).toInt().toDouble();
}
}
/// billion number
/// in short scale (https://en.wikipedia.org/wiki/Billion)
static const double billion = 1000000000;
/// million number
static const double million = 1000000;
/// kilo (thousands) number
static const double kilo = 1000;
/// Returns count of fraction digits of a value
int getFractionDigits(double value) {
if (value >= 1) {
return 1;
} else if (value >= 0.1) {
return 2;
} else if (value >= 0.01) {
return 3;
} else if (value >= 0.001) {
return 4;
} else if (value >= 0.0001) {
return 5;
} else if (value >= 0.00001) {
return 6;
} else if (value >= 0.000001) {
return 7;
} else if (value >= 0.0000001) {
return 8;
} else if (value >= 0.00000001) {
return 9;
} else if (value >= 0.000000001) {
return 10;
}
return 1;
}
/// Formats and add symbols (K, M, B) at the end of number.
///
/// if number is larger than [billion], it returns a short number like 13.3B,
/// if number is larger than [million], it returns a short number line 43M,
/// if number is larger than [kilo], it returns a short number like 4K,
/// otherwise it returns number itself.
/// also it removes .0, at the end of number for simplicity.
String formatNumber(double axisMin, double axisMax, double axisValue) {
final isNegative = axisValue < 0;
if (isNegative) {
axisValue = axisValue.abs();
}
String resultNumber;
String symbol;
if (axisValue >= billion) {
resultNumber = (axisValue / billion).toStringAsFixed(1);
symbol = 'B';
} else if (axisValue >= million) {
resultNumber = (axisValue / million).toStringAsFixed(1);
symbol = 'M';
} else if (axisValue >= kilo) {
resultNumber = (axisValue / kilo).toStringAsFixed(1);
symbol = 'K';
} else {
final diff = (axisMin - axisMax).abs();
resultNumber = axisValue.toStringAsFixed(
getFractionDigits(diff),
);
symbol = '';
}
if (resultNumber.endsWith('.0')) {
resultNumber = resultNumber.substring(0, resultNumber.length - 2);
}
if (isNegative) {
resultNumber = '-$resultNumber';
}
if (resultNumber == '-0') {
resultNumber = '0';
}
return resultNumber + symbol;
}
/// Returns a TextStyle based on provided [context], if [providedStyle] provided we try to merge it.
TextStyle getThemeAwareTextStyle(
BuildContext context,
TextStyle? providedStyle,
) {
final defaultTextStyle = DefaultTextStyle.of(context);
var effectiveTextStyle = providedStyle;
if (providedStyle == null || providedStyle.inherit) {
effectiveTextStyle = defaultTextStyle.style.merge(providedStyle);
}
if (MediaQuery.boldTextOf(context)) {
effectiveTextStyle = effectiveTextStyle!
.merge(const TextStyle(fontWeight: FontWeight.bold));
}
return effectiveTextStyle!;
}
/// Finds the best initial interval value
///
/// If there is a zero point in the axis, we want to have a value that passes through it.
/// For example if we have -3 to +3, with interval 2. if we start from -3, we get something like this: -3, -1, +1, +3
/// But the most important point is zero in most cases. with this logic we get this: -2, 0, 2
double getBestInitialIntervalValue(
double min,
double max,
double interval, {
double baseline = 0.0,
}) {
final diff = baseline - min;
final mod = diff % interval;
if ((max - min).abs() <= mod) {
return min;
}
if (mod == 0) {
return min;
}
return min + mod;
}
/// Converts radius number to sigma for drawing shadows
double convertRadiusToSigma(double radius) => radius * 0.57735 + 0.5;
}