1.0.0
This commit is contained in:
18
lib/fl_chart.dart
Normal file
18
lib/fl_chart.dart
Normal 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';
|
||||
168
lib/src/chart/bar_chart/bar_chart.dart
Normal file
168
lib/src/chart/bar_chart/bar_chart.dart
Normal 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?;
|
||||
}
|
||||
}
|
||||
1020
lib/src/chart/bar_chart/bar_chart_data.dart
Normal file
1020
lib/src/chart/bar_chart/bar_chart_data.dart
Normal file
File diff suppressed because it is too large
Load Diff
48
lib/src/chart/bar_chart/bar_chart_helper.dart
Normal file
48
lib/src/chart/bar_chart/bar_chart_helper.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
841
lib/src/chart/bar_chart/bar_chart_painter.dart
Normal file
841
lib/src/chart/bar_chart/bar_chart_painter.dart
Normal 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;
|
||||
}
|
||||
140
lib/src/chart/bar_chart/bar_chart_renderer.dart
Normal file
140
lib/src/chart/bar_chart/bar_chart_renderer.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
2451
lib/src/chart/base/axis_chart/axis_chart_data.dart
Normal file
2451
lib/src/chart/base/axis_chart/axis_chart_data.dart
Normal file
File diff suppressed because it is too large
Load Diff
23
lib/src/chart/base/axis_chart/axis_chart_extensions.dart
Normal file
23
lib/src/chart/base/axis_chart/axis_chart_extensions.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
104
lib/src/chart/base/axis_chart/axis_chart_helper.dart
Normal file
104
lib/src/chart/base/axis_chart/axis_chart_helper.dart
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
570
lib/src/chart/base/axis_chart/axis_chart_painter.dart
Normal file
570
lib/src/chart/base/axis_chart/axis_chart_painter.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
324
lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart
Normal file
324
lib/src/chart/base/axis_chart/axis_chart_scaffold_widget.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
134
lib/src/chart/base/axis_chart/axis_chart_widgets.dart
Normal file
134
lib/src/chart/base/axis_chart/axis_chart_widgets.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
lib/src/chart/base/axis_chart/scale_axis.dart
Normal file
20
lib/src/chart/base/axis_chart/scale_axis.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
298
lib/src/chart/base/axis_chart/side_titles/side_titles_flex.dart
Normal file
298
lib/src/chart/base/axis_chart/side_titles/side_titles_flex.dart
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/src/chart/base/axis_chart/transformation_config.dart
Normal file
50
lib/src/chart/base/axis_chart/transformation_config.dart
Normal 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;
|
||||
}
|
||||
203
lib/src/chart/base/base_chart/base_chart_data.dart
Normal file
203
lib/src/chart/base/base_chart/base_chart_data.dart
Normal 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,
|
||||
}
|
||||
60
lib/src/chart/base/base_chart/base_chart_painter.dart
Normal file
60
lib/src/chart/base/base_chart/base_chart_painter.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
249
lib/src/chart/base/base_chart/fl_touch_event.dart
Normal file
249
lib/src/chart/base/base_chart/fl_touch_event.dart
Normal 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;
|
||||
}
|
||||
207
lib/src/chart/base/base_chart/render_base_chart.dart
Normal file
207
lib/src/chart/base/base_chart/render_base_chart.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
1179
lib/src/chart/base/custom_interactive_viewer.dart
Normal file
1179
lib/src/chart/base/custom_interactive_viewer.dart
Normal file
File diff suppressed because it is too large
Load Diff
34
lib/src/chart/base/line.dart
Normal file
34
lib/src/chart/base/line.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
184
lib/src/chart/candlestick_chart/candlestick_chart.dart
Normal file
184
lib/src/chart/candlestick_chart/candlestick_chart.dart
Normal 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?;
|
||||
}
|
||||
}
|
||||
999
lib/src/chart/candlestick_chart/candlestick_chart_data.dart
Normal file
999
lib/src/chart/candlestick_chart/candlestick_chart_data.dart
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
381
lib/src/chart/candlestick_chart/candlestick_chart_painter.dart
Normal file
381
lib/src/chart/candlestick_chart/candlestick_chart_painter.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
148
lib/src/chart/candlestick_chart/candlestick_chart_renderer.dart
Normal file
148
lib/src/chart/candlestick_chart/candlestick_chart_renderer.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
171
lib/src/chart/line_chart/line_chart.dart
Normal file
171
lib/src/chart/line_chart/line_chart.dart
Normal 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?;
|
||||
}
|
||||
}
|
||||
1374
lib/src/chart/line_chart/line_chart_data.dart
Normal file
1374
lib/src/chart/line_chart/line_chart_data.dart
Normal file
File diff suppressed because it is too large
Load Diff
61
lib/src/chart/line_chart/line_chart_helper.dart
Normal file
61
lib/src/chart/line_chart/line_chart_helper.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
1473
lib/src/chart/line_chart/line_chart_painter.dart
Normal file
1473
lib/src/chart/line_chart/line_chart_painter.dart
Normal file
File diff suppressed because it is too large
Load Diff
141
lib/src/chart/line_chart/line_chart_renderer.dart
Normal file
141
lib/src/chart/line_chart/line_chart_renderer.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
117
lib/src/chart/pie_chart/pie_chart.dart
Normal file
117
lib/src/chart/pie_chart/pie_chart.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
407
lib/src/chart/pie_chart/pie_chart_data.dart
Normal file
407
lib/src/chart/pie_chart/pie_chart_data.dart
Normal 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);
|
||||
}
|
||||
21
lib/src/chart/pie_chart/pie_chart_helper.dart
Normal file
21
lib/src/chart/pie_chart/pie_chart_helper.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
550
lib/src/chart/pie_chart/pie_chart_painter.dart
Normal file
550
lib/src/chart/pie_chart/pie_chart_painter.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
184
lib/src/chart/pie_chart/pie_chart_renderer.dart
Normal file
184
lib/src/chart/pie_chart/pie_chart_renderer.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
58
lib/src/chart/radar_chart/radar_chart.dart
Normal file
58
lib/src/chart/radar_chart/radar_chart.dart
Normal 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?;
|
||||
}
|
||||
}
|
||||
506
lib/src/chart/radar_chart/radar_chart_data.dart
Normal file
506
lib/src/chart/radar_chart/radar_chart_data.dart
Normal 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);
|
||||
}
|
||||
499
lib/src/chart/radar_chart/radar_chart_painter.dart
Normal file
499
lib/src/chart/radar_chart/radar_chart_painter.dart
Normal 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;
|
||||
}
|
||||
114
lib/src/chart/radar_chart/radar_chart_renderer.dart
Normal file
114
lib/src/chart/radar_chart/radar_chart_renderer.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/src/chart/radar_chart/radar_extension.dart
Normal file
15
lib/src/chart/radar_chart/radar_extension.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
130
lib/src/chart/scatter_chart/scatter_chart.dart
Normal file
130
lib/src/chart/scatter_chart/scatter_chart.dart
Normal 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?;
|
||||
}
|
||||
}
|
||||
796
lib/src/chart/scatter_chart/scatter_chart_data.dart
Normal file
796
lib/src/chart/scatter_chart/scatter_chart_data.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
43
lib/src/chart/scatter_chart/scatter_chart_helper.dart
Normal file
43
lib/src/chart/scatter_chart/scatter_chart_helper.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
472
lib/src/chart/scatter_chart/scatter_chart_painter.dart
Normal file
472
lib/src/chart/scatter_chart/scatter_chart_painter.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
144
lib/src/chart/scatter_chart/scatter_chart_renderer.dart
Normal file
144
lib/src/chart/scatter_chart/scatter_chart_renderer.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
100
lib/src/extensions/bar_chart_data_extension.dart
Normal file
100
lib/src/extensions/bar_chart_data_extension.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
20
lib/src/extensions/border_extension.dart
Normal file
20
lib/src/extensions/border_extension.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
24
lib/src/extensions/color_extension.dart
Normal file
24
lib/src/extensions/color_extension.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
13
lib/src/extensions/edge_insets_extension.dart
Normal file
13
lib/src/extensions/edge_insets_extension.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
11
lib/src/extensions/fl_border_data_extension.dart
Normal file
11
lib/src/extensions/fl_border_data_extension.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
12
lib/src/extensions/fl_titles_data_extension.dart
Normal file
12
lib/src/extensions/fl_titles_data_extension.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
25
lib/src/extensions/gradient_extension.dart
Normal file
25
lib/src/extensions/gradient_extension.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
31
lib/src/extensions/paint_extension.dart
Normal file
31
lib/src/extensions/paint_extension.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
23
lib/src/extensions/path_extension.dart
Normal file
23
lib/src/extensions/path_extension.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
lib/src/extensions/rrect_extension.dart
Normal file
12
lib/src/extensions/rrect_extension.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
14
lib/src/extensions/side_titles_extension.dart
Normal file
14
lib/src/extensions/side_titles_extension.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
13
lib/src/extensions/size_extension.dart
Normal file
13
lib/src/extensions/size_extension.dart
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
19
lib/src/extensions/text_align_extension.dart
Normal file
19
lib/src/extensions/text_align_extension.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
168
lib/src/utils/canvas_wrapper.dart
Normal file
168
lib/src/utils/canvas_wrapper.dart
Normal 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
201
lib/src/utils/lerp.dart
Normal 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;
|
||||
}
|
||||
86
lib/src/utils/path_drawing/dash_path.dart
Normal file
86
lib/src/utils/path_drawing/dash_path.dart
Normal 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
314
lib/src/utils/utils.dart
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user