import 'dart:math' as math; import 'package:flutter/material.dart'; class ConnectionPaintData { const ConnectionPaintData({ required this.from, required this.to, required this.color, this.animated = false, this.label, }); final Offset from; final Offset to; final Color color; final bool animated; final String? label; } class ConnectionPainter extends CustomPainter { const ConnectionPainter({ required this.connections, this.animationPhase = 0, required this.labelTextColor, required this.labelBgColor, required this.labelBorderColor, }); final List connections; final double animationPhase; final Color labelTextColor; final Color labelBgColor; final Color labelBorderColor; @override void paint(Canvas canvas, Size size) { for (final connection in connections) { final paint = Paint() ..color = connection.color ..strokeWidth = 3 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; if (connection.animated) { _drawAnimatedDashedLine(canvas, paint, connection.from, connection.to); } else { canvas.drawLine(connection.from, connection.to, paint); } if (connection.label != null && connection.label!.isNotEmpty) { _drawLineLabel(canvas, connection.from, connection.to, connection.label!); } } } void _drawAnimatedDashedLine( Canvas canvas, Paint paint, Offset from, Offset to, ) { const dashLength = 12.0; const gapLength = 8.0; const cycle = dashLength + gapLength; final dx = to.dx - from.dx; final dy = to.dy - from.dy; final distance = math.sqrt(dx * dx + dy * dy); if (distance == 0) { return; } final ux = dx / distance; final uy = dy / distance; final offsetShift = animationPhase * cycle; var position = -offsetShift; while (position < distance) { final start = position.clamp(0.0, distance); final end = (position + dashLength).clamp(0.0, distance); if (end > start) { final p1 = Offset(from.dx + ux * start, from.dy + uy * start); final p2 = Offset(from.dx + ux * end, from.dy + uy * end); canvas.drawLine(p1, p2, paint); } position += cycle; } } void _drawLineLabel(Canvas canvas, Offset from, Offset to, String label) { final textPainter = TextPainter( text: TextSpan( text: label, style: TextStyle( color: labelTextColor, fontSize: 11, fontWeight: FontWeight.w600, ), ), textDirection: TextDirection.ltr, )..layout(); final mid = Offset((from.dx + to.dx) / 2, (from.dy + to.dy) / 2); final dx = to.dx - from.dx; final dy = to.dy - from.dy; final length = math.sqrt(dx * dx + dy * dy); final normal = length == 0 ? const Offset(0, -1) : Offset(-dy / length, dx / length); final labelOffset = mid + normal * 10; final rect = RRect.fromRectAndRadius( Rect.fromLTWH( labelOffset.dx - textPainter.width / 2 - 6, labelOffset.dy - textPainter.height / 2 - 3, textPainter.width + 12, textPainter.height + 6, ), const Radius.circular(6), ); final backgroundPaint = Paint()..color = labelBgColor; canvas.drawRRect(rect, backgroundPaint); canvas.drawRRect( rect, Paint() ..color = labelBorderColor ..style = PaintingStyle.stroke, ); textPainter.paint( canvas, Offset( labelOffset.dx - textPainter.width / 2, labelOffset.dy - textPainter.height / 2, ), ); } @override bool shouldRepaint(covariant ConnectionPainter oldDelegate) { return oldDelegate.connections != connections || oldDelegate.animationPhase != animationPhase; } }