Skip to content

Instantly share code, notes, and snippets.

@CharlVS
Created May 26, 2024 09:51
Show Gist options
  • Save CharlVS/15a93b45be728607a3e32012e1d268ff to your computer and use it in GitHub Desktop.
Save CharlVS/15a93b45be728607a3e32012e1d268ff to your computer and use it in GitHub Desktop.
Custom Dart Charts
import 'package:flutter/material.dart';
import 'dart:math';
import 'dart:async';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
List<ChartData> data1 = [];
List<ChartData> data2 = [];
@override
void initState() {
for (int i = 0; i < 10; i++) {
data1.add(ChartData(x: i.toDouble(), y: Random().nextDouble()));
data2.add(ChartData(x: i.toDouble(), y: Random().nextDouble()));
}
super.initState();
Timer.periodic(const Duration(seconds: 2), (timer) {
setState(() {
// Update the data points with some random values for animation
// data1.forEach((element) {
// element.y = Random().nextDouble();
// });
// data2.forEach((element) {
// element.y = Random().nextDouble();
// });
// data1 = data1.map((element) {
// return ChartData(x: element.x, y: Random().nextDouble());
// }).toList();
data2 = data2.map((element) {
return ChartData(
x: element.x,
// // Move the x by a random amount to the left or right
// element.x + (Random().nextDouble() - 0.5),
y: Random().nextDouble());
}).toList();
// 50% chance to add a new data point
if (Random().nextBool()) {
data2.add(ChartData(x: data2.last.x + 1, y: Random().nextDouble()));
data1.add(ChartData(x: data1.last.x + 1, y: Random().nextDouble()));
// Random x point somewhere between the first and last x points and
// then insert it at the index in the list where it should be
// final newX = Random().nextDouble() * (data2.last.x - data2.first.x) +
// data2.first.x;
// data2.insert(data1.indexWhere((element) => element.x > newX),
// ChartData(x: newX, y: Random().nextDouble()));
}
});
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Custom Line Chart with Animation')),
body: Padding(
padding: const EdgeInsets.all(32),
child: CustomLineChart(
domainExtent: const GraphExtent.tight(),
elements: [
// ChartGridLines(
// isVertical: true,
// count: 5,
// // labelBuilder: null,
// ),
// ChartGridLines(
// isVertical: true,
// count: 4,
// // labelBuilder: null,
// ),
ChartGridLines(
isVertical: false,
count: 5,
// labelBuilder: (value) => value.toString(),
),
ChartAxisLabels(
isVertical: true,
count: 5,
labelBuilder: (value) => value.toStringAsFixed(2),
),
ChartAxisLabels(
isVertical: false,
count: 5,
labelBuilder: (value) => value.toStringAsFixed(2),
),
ChartDataSeries(data: data1, color: Colors.blue),
ChartDataSeries(
data: data2,
color: Colors.red,
lineType: LineType.bezier,
),
],
tooltipBuilder: (context, dataPoints) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(4.0),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: dataPoints.map((data) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const Icon(Icons.circle, color: Colors.white),
Text(
'(${data.x}, ${data.y})',
style: const TextStyle(
color: Colors.white, fontSize: 12),
),
const SizedBox(height: 36),
],
);
}).toList(),
),
);
},
),
),
),
);
}
}
class ChartData {
final double x;
final double y;
ChartData({required this.x, required this.y});
}
enum LineType { straight, bezier }
class ChartDataSeries extends ChartElement {
final List<ChartData> data;
final Color color;
final LineType lineType;
ChartDataSeries({
required this.data,
required this.color,
this.lineType = LineType.straight,
});
ChartDataSeries animateTo(
ChartDataSeries newDataSeries, double animationValue) {
List<ChartData> interpolatedData = [];
for (int i = 0; i < min(data.length, newDataSeries.data.length); i++) {
double oldY = data[i].y;
double newY = newDataSeries.data[i].y;
double interpolatedY = oldY + (newY - oldY) * animationValue;
double oldX = data[i].x;
double newX = newDataSeries.data[i].x;
double interpolatedX = oldX + (newX - oldX) * animationValue;
interpolatedData.add(ChartData(
x: interpolatedX,
y: interpolatedY,
));
}
return ChartDataSeries(
data: interpolatedData,
color: color,
lineType: lineType,
);
}
@override
void paint(Canvas canvas, Size size, ChartDataTransform transform,
double animation) {
Paint linePaint = Paint()
..color = color
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
Paint pointPaint = Paint()
..color = color
..style = PaintingStyle.fill;
Path path = Path();
bool first = true;
if (lineType == LineType.straight) {
for (var point in data) {
double x = transform.transformX(point.x);
double y = transform.transformY(point.y);
if (first) {
path.moveTo(x, y);
first = false;
} else {
path.lineTo(x, y);
}
canvas.drawCircle(
Offset(x, y), 4.0, pointPaint); // Increased point size
}
} else if (lineType == LineType.bezier) {
if (data.isNotEmpty) {
path.moveTo(
transform.transformX(data[0].x), transform.transformY(data[0].y));
for (int i = 0; i < data.length - 1; i++) {
double x1 = transform.transformX(data[i].x);
double y1 = transform.transformY(data[i].y);
double x2 = transform.transformX(data[i + 1].x);
double y2 = transform.transformY(data[i + 1].y);
// Control points for the cubic Bezier curve
double controlPointX1 = x1 + (x2 - x1) / 3;
double controlPointY1 = y1;
double controlPointX2 = x1 + 2 * (x2 - x1) / 3;
double controlPointY2 = y2;
path.cubicTo(controlPointX1, controlPointY1, controlPointX2,
controlPointY2, x2, y2);
canvas.drawCircle(
Offset(x1, y1), 4.0, pointPaint); // Increased point size
}
canvas.drawCircle(
Offset(transform.transformX(data.last.x),
transform.transformY(data.last.y)),
4.0,
pointPaint); // Draw last point
}
}
canvas.drawPath(path, linePaint);
}
}
class ChartDataTransform {
final double minX, maxX, minY, maxY;
final double width, height;
ChartDataTransform({
required this.minX,
required this.maxX,
required this.minY,
required this.maxY,
required this.width,
required this.height,
});
double transformX(double x) => (x - minX) / (maxX - minX) * width;
double transformY(double y) => height - (y - minY) / (maxY - minY) * height;
double invertX(double dx) => minX + (dx / width) * (maxX - minX);
double invertY(double dy) => minY + (1 - dy / height) * (maxY - minY);
}
abstract class ChartElement {
void paint(
Canvas canvas, Size size, ChartDataTransform transform, double animation);
}
class ChartGridLines extends ChartElement {
final bool isVertical;
final int count;
ChartGridLines({required this.isVertical, required this.count});
@override
void paint(Canvas canvas, Size size, ChartDataTransform transform,
double animation) {
Paint gridPaint = Paint()
..color = Colors.grey.withOpacity(0.2)
..strokeWidth = 1.0;
if (isVertical) {
for (double i = 0; i <= count; i++) {
double x = i * size.width / count;
canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint);
}
} else {
for (double i = 0; i <= count; i++) {
double y = i * size.height / count;
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
}
}
}
}
class ChartAxisLabels extends ChartElement {
final bool isVertical;
final int count;
final String Function(double value) labelBuilder;
final double reservedExtent;
ChartAxisLabels({
required this.isVertical,
required this.count,
required this.labelBuilder,
this.reservedExtent = 30.0, // Default reserved extent for labels
});
@override
void paint(Canvas canvas, Size size, ChartDataTransform transform,
double animation) {
if (isVertical) {
for (double i = 0; i <= count; i++) {
double y = i * size.height / count;
TextPainter textPainter = TextPainter(
text: TextSpan(
text: labelBuilder(transform.invertY(y)),
style: const TextStyle(color: Colors.grey, fontSize: 10)),
textDirection: TextDirection.ltr,
);
textPainter.layout();
if (i == 0 || (y - textPainter.height / 2) >= reservedExtent * i) {
textPainter.paint(canvas,
Offset(-textPainter.width - 5, y - textPainter.height / 2));
}
}
} else {
for (double i = 0; i <= count; i++) {
double x = i * size.width / count;
TextPainter textPainter = TextPainter(
text: TextSpan(
text: labelBuilder(transform.invertX(x)),
style: const TextStyle(color: Colors.grey, fontSize: 10)),
textDirection: TextDirection.ltr,
);
textPainter.layout();
if (i == 0 || (x - textPainter.width / 2) >= reservedExtent * i) {
textPainter.paint(
canvas, Offset(x - textPainter.width / 2, size.height + 5));
}
}
}
}
}
class GraphExtent {
final bool auto;
final double padding;
final double? min;
final double? max;
const GraphExtent({
this.auto = true,
this.padding = 0.1,
this.min,
this.max,
});
const GraphExtent.tight() : this(auto: true, padding: 0.0);
}
class CustomLineChart extends StatefulWidget {
final List<ChartElement> elements;
final Duration animationDuration;
final Widget Function(BuildContext, List<ChartData>) tooltipBuilder;
final GraphExtent domainExtent;
final GraphExtent rangeExtent;
const CustomLineChart({
super.key,
required this.elements,
required this.tooltipBuilder,
this.animationDuration = const Duration(milliseconds: 500),
this.domainExtent = const GraphExtent(auto: true, padding: 0.1),
this.rangeExtent = const GraphExtent(auto: true, padding: 0.1),
});
@override
_CustomLineChartState createState() => _CustomLineChartState();
}
class _CustomLineChartState extends State<CustomLineChart>
with SingleTickerProviderStateMixin {
OverlayEntry? _tooltipOverlay;
Offset? _hoverPosition;
List<Offset>? _highlightedPoints;
List<Color> _highlightedColors = [];
late AnimationController _controller;
late Animation<double> _animation;
List<ChartElement> oldElements = [];
List<ChartElement> currentElements = [];
double minX = double.infinity;
double maxX = double.negativeInfinity;
double minY = double.infinity;
double maxY = double.negativeInfinity;
late Animation<double> minXAnimation;
late Animation<double> maxXAnimation;
late Animation<double> minYAnimation;
late Animation<double> maxYAnimation;
@override
void initState() {
super.initState();
_controller =
AnimationController(vsync: this, duration: widget.animationDuration);
_animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut);
_controller.addListener(() {
setState(() {
// Rebuild to reflect animation progress
});
});
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
setState(() {
oldElements = List.from(widget.elements);
});
}
});
oldElements = List.from(widget.elements);
currentElements = List.from(widget.elements);
_updateDomainRange();
_controller.forward();
}
@override
void didUpdateWidget(covariant CustomLineChart oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.elements != widget.elements) {
setState(() {
oldElements = List.from(currentElements);
currentElements = List.from(widget.elements);
_controller.reset();
_updateDomainRange();
_controller.forward();
});
} else {
_updateDomainRange();
}
}
void _updateDomainRange() {
double newMinX = double.infinity;
double newMaxX = double.negativeInfinity;
double newMinY = double.infinity;
double newMaxY = double.negativeInfinity;
for (var element in widget.elements) {
if (element is ChartDataSeries) {
for (var dataPoint in element.data) {
double xValue = dataPoint.x;
if (xValue < newMinX) newMinX = xValue;
if (xValue > newMaxX) newMaxX = xValue;
if (dataPoint.y < newMinY) newMinY = dataPoint.y;
if (dataPoint.y > newMaxY) newMaxY = dataPoint.y;
}
}
}
if (widget.domainExtent.auto) {
double domainPaddingValue =
(newMaxX - newMinX) * widget.domainExtent.padding;
newMinX -= domainPaddingValue;
newMaxX += domainPaddingValue;
} else {
newMinX = widget.domainExtent.min ?? newMinX;
newMaxX = widget.domainExtent.max ?? newMaxX;
}
if (widget.rangeExtent.auto) {
double rangePaddingValue =
(newMaxY - newMinY) * widget.rangeExtent.padding;
newMinY -= rangePaddingValue;
newMaxY += rangePaddingValue;
} else {
newMinY = widget.rangeExtent.min ?? newMinY;
newMaxY = widget.rangeExtent.max ?? newMaxY;
}
minXAnimation =
Tween<double>(begin: minX, end: newMinX).animate(_controller);
maxXAnimation =
Tween<double>(begin: maxX, end: newMaxX).animate(_controller);
minYAnimation =
Tween<double>(begin: minY, end: newMinY).animate(_controller);
maxYAnimation =
Tween<double>(begin: maxY, end: newMaxY).animate(_controller);
minX = newMinX;
maxX = newMaxX;
minY = newMinY;
maxY = newMaxY;
}
void _showTooltip(
BuildContext context, Offset position, List<ChartData> dataPoints) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_hideTooltip();
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
final Size? size = renderBox?.size;
if (size != null) {
double left = position.dx + 10;
double top = position.dy - 30;
// Ensure tooltip stays within bounds
if (left + 100 > size.width) {
left = size.width - 100;
}
if (top < 0) {
top = 0;
}
_tooltipOverlay = OverlayEntry(
builder: (context) => Positioned(
left: left,
top: top,
child: Material(
color: Colors.transparent,
child: widget.tooltipBuilder(context, dataPoints),
),
),
);
Overlay.of(context).insert(_tooltipOverlay!);
}
});
}
void _hideTooltip() {
if (_tooltipOverlay != null) {
_tooltipOverlay!.remove();
_tooltipOverlay = null;
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
Size size = Size(constraints.maxWidth, constraints.maxHeight);
ChartDataTransform transform = ChartDataTransform(
minX: minXAnimation.value,
maxX: maxXAnimation.value,
minY: minYAnimation.value,
maxY: maxYAnimation.value,
width: size.width,
height: size.height,
);
return Container(
color: Colors.black.withOpacity(0.05), // Background color
child: GestureDetector(
onPanUpdate: (details) {
setState(() {
_hoverPosition = details.localPosition;
});
},
child: MouseRegion(
onHover: (details) {
final localPosition = details.localPosition;
List<ChartData> highlightedData = [];
_highlightedPoints = [];
_highlightedColors = [];
for (var element in widget.elements) {
if (element is ChartDataSeries) {
for (var point in element.data) {
double x = transform.transformX(point.x);
double y = transform.transformY(point.y);
if ((Offset(x, y) - localPosition).distance < 10) {
highlightedData.add(point);
_highlightedPoints!.add(Offset(x, y));
_highlightedColors.add(element.color);
}
}
}
}
if (highlightedData.isNotEmpty) {
_showTooltip(context, localPosition, highlightedData);
} else {
_hideTooltip();
}
setState(() {
_hoverPosition = localPosition;
});
},
onExit: (details) {
_hideTooltip();
setState(() {
_hoverPosition = null;
_highlightedPoints = null;
_highlightedColors = [];
});
},
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
List<ChartElement> animatedElements = [];
for (int i = 0; i < currentElements.length; i++) {
if (currentElements[i] is ChartDataSeries &&
oldElements[i] is ChartDataSeries) {
animatedElements.add((oldElements[i] as ChartDataSeries)
.animateTo(currentElements[i] as ChartDataSeries,
_animation.value));
} else {
animatedElements.add(currentElements[i]);
}
}
return CustomPaint(
size: size,
painter: _LineChartPainter(
elements: animatedElements,
transform: transform,
highlightedPoints: _highlightedPoints,
highlightedColors: _highlightedColors,
animation: _animation.value,
),
);
},
),
),
),
);
},
);
}
@override
void dispose() {
_hideTooltip();
_controller.dispose();
super.dispose();
}
}
class _LineChartPainter extends CustomPainter {
final List<ChartElement> elements;
final ChartDataTransform transform;
final List<Offset>? highlightedPoints;
final List<Color> highlightedColors;
final double animation;
_LineChartPainter({
required this.elements,
required this.transform,
required this.highlightedPoints,
required this.highlightedColors,
required this.animation,
});
@override
void paint(Canvas canvas, Size size) {
for (var element in elements) {
element.paint(canvas, size, transform, animation);
}
if (highlightedPoints != null) {
for (int i = 0; i < highlightedPoints!.length; i++) {
var point = highlightedPoints![i];
var color = highlightedColors[i];
Paint highlightPaint = Paint()
..color = color
..style = PaintingStyle.fill;
canvas.drawCircle(
point, 6.0, highlightPaint); // Fixed size for highlight
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment