import 'dart:math'; import 'dart:ui' as ui; import 'package:ef/ef.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:vbvs_app/common/util/FitTool.dart'; import 'package:vbvs_app/common/util/MyUtils.dart'; class LineChartByRange extends StatefulWidget { final List> showLabel; final int startTime; final int endTime; final int? threshold; /// 新增外部指定的 Y 轴最大值 final int maxY; /// Y 轴分段数,默认6段 final int ySegments; const LineChartByRange({ Key? key, required this.showLabel, required this.startTime, required this.endTime, required this.maxY, this.threshold, this.ySegments = 6, }) : super(key: key); @override State createState() => _LineChartByRangeState(); } class _LineChartByRangeState extends State { Offset? selectedOffset; Map? selectedData; @override Widget build(BuildContext context) { if (widget.showLabel.isEmpty) return const SizedBox(); DateTime minTime = DateTime.fromMillisecondsSinceEpoch(widget.startTime); DateTime maxTime = DateTime.fromMillisecondsSinceEpoch(widget.endTime); return GestureDetector( onTapDown: (details) { RenderBox box = context.findRenderObject() as RenderBox; final localPosition = box.globalToLocal(details.globalPosition); // 查找是否点击到某个点 for (var item in widget.showLabel) { int start = item['startTime']; int end = item['endTime']; int times = item['times']; double chartWidth = box.size.width - 40.rpx; // 与 painter 内一致处理 double chartHeight = box.size.height - 30.rpx; double xStart = 20.rpx + 12.rpx; int totalDuration = maxTime.millisecondsSinceEpoch - minTime.millisecondsSinceEpoch; double startX = xStart + chartWidth * (start - minTime.millisecondsSinceEpoch) / totalDuration; double y = chartHeight * (1 - times / widget.maxY); // 判断点击范围(圆点半径±6.rpx范围) if ((localPosition - Offset(startX, y)).distance < 10.rpx) { setState(() { selectedOffset = Offset(startX, y); selectedData = item; }); return; } double endX = xStart + chartWidth * (end - minTime.millisecondsSinceEpoch) / totalDuration; if ((localPosition - Offset(endX, y)).distance < 10.rpx) { setState(() { selectedOffset = Offset(endX, y); selectedData = item; }); return; } } // 没点到,清除选中 setState(() { selectedOffset = null; selectedData = null; }); }, child: Stack( children: [ SizedBox( height: 500.rpx, child: CustomPaint( size: Size(double.infinity, 500.rpx), painter: _LineChartByRangePainter( data: widget.showLabel, maxY: widget.maxY, minTime: minTime, maxTime: maxTime, threshold: widget.threshold, ySegments: widget.ySegments, ), ), ), if (selectedOffset != null && selectedData != null) Positioned( left: selectedOffset!.dx - 60.rpx, top: selectedOffset!.dy - 50.rpx, child: Container( padding: EdgeInsets.symmetric(horizontal: 12.rpx, vertical: 8.rpx), decoration: BoxDecoration( color: Colors.black.withOpacity(0.3), borderRadius: BorderRadius.circular(10.rpx), ), child: Text( '${DateFormat('HH:mm').format(DateTime.fromMillisecondsSinceEpoch(selectedData!['startTime']))} - ' '${DateFormat('HH:mm').format(DateTime.fromMillisecondsSinceEpoch(selectedData!['endTime']))}\n' "时长" .tr + ' ${selectedData!['times']}', style: TextStyle( fontSize: 18.rpx, color: Colors.white, ), ), ), ), ], ), ); } } class _LineChartByRangePainter extends CustomPainter { final List> data; final int maxY; final DateTime minTime; final DateTime maxTime; final int? threshold; final int ySegments; _LineChartByRangePainter({ required this.data, required this.maxY, required this.minTime, required this.maxTime, this.threshold, this.ySegments = 6, }); @override void paint(Canvas canvas, Size size) { double padding = 20.rpx; double labelInset = 12.rpx; final double xStart = padding + labelInset; final double xEnd = size.width - padding - labelInset; final double chartWidth = xEnd - xStart; double chartHeight = size.height - 30.rpx; int totalDuration = maxTime.millisecondsSinceEpoch - minTime.millisecondsSinceEpoch; if (totalDuration <= 0) return; Paint axisPaint = Paint() ..color = Colors.grey.withOpacity(0.4) ..strokeWidth = 1.rpx; Paint thresholdPaint = Paint() ..color = themeController.currentColor.sc9 ..strokeWidth = 1.rpx; // 阈值虚线(红色) if (threshold != null && threshold! >= 0 && threshold! <= maxY) { double yThreshold = chartHeight * (1 - threshold! / maxY); drawDashedLine( canvas, Offset(xStart, yThreshold), Offset(xEnd, yThreshold), thresholdPaint, dashWidth: 8.rpx, dashSpace: 6.rpx, ); } // 绘制数据线段和圆点 for (var item in data) { int start = item['startTime']; int end = item['endTime']; int times = item['times']; double startX = xStart * 2 + chartWidth * (start - minTime.millisecondsSinceEpoch) / totalDuration; double endX = xStart * 2 + chartWidth * (end - minTime.millisecondsSinceEpoch) / totalDuration; double y = chartHeight * (1 - times / maxY); // 设置颜色(根据 threshold 判断) Color pointColor; if (threshold != null && times >= threshold!) { pointColor = themeController.currentColor.sc9; } else { pointColor = stringToColor("#00C1AA"); } Paint dynamicLinePaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3.rpx ..color = pointColor ..strokeCap = StrokeCap.round; Paint dynamicCirclePaint = Paint() ..style = PaintingStyle.fill ..color = pointColor; // 画线段 canvas.drawLine(Offset(startX, y), Offset(endX, y), dynamicLinePaint); // 画起点和终点圆点 canvas.drawCircle(Offset(startX, y), 6.rpx, dynamicCirclePaint); canvas.drawCircle(Offset(endX, y), 6.rpx, dynamicCirclePaint); } // Y轴辅助线和文字 for (int i = 0; i <= ySegments; i++) { double y = chartHeight * i / ySegments; if (i == ySegments) { canvas.drawLine(Offset(xStart, y), Offset(xEnd, y), axisPaint); } else { drawDashedLine( canvas, Offset(xStart, y), Offset(xEnd, y), axisPaint, dashWidth: 8.rpx, dashSpace: 6.rpx, ); } TextPainter tp = TextPainter( text: TextSpan( text: '${maxY - (maxY * i / ySegments).round()}', style: TextStyle( fontSize: 18.rpx, color: themeController.currentColor.sc4, ), ), textDirection: ui.TextDirection.ltr, ); tp.layout(); tp.paint(canvas, Offset(0, y - tp.height / 2)); } // X轴主线 canvas.drawLine( Offset(xStart, chartHeight), Offset(xEnd, chartHeight), axisPaint, ); // X轴时间文字(左右两侧) String leftLabel = DateFormat('HH:mm').format(minTime); TextPainter leftTp = TextPainter( text: TextSpan( text: leftLabel, style: TextStyle( fontSize: 18.rpx, color: themeController.currentColor.sc4, ), ), textDirection: ui.TextDirection.ltr, ); leftTp.layout(); leftTp.paint(canvas, Offset(padding + labelInset - leftTp.width / 2, chartHeight + 8.rpx)); String rightLabel = DateFormat('HH:mm').format(maxTime); TextPainter rightTp = TextPainter( text: TextSpan( text: rightLabel, style: TextStyle( fontSize: 18.rpx, color: themeController.currentColor.sc4, ), ), textDirection: ui.TextDirection.ltr, ); rightTp.layout(); rightTp.paint( canvas, Offset(size.width - padding - labelInset - rightTp.width / 2, chartHeight + 8.rpx)); // 中间小时刻度 int totalHours = maxTime.difference(minTime).inHours + 1; int startHour = minTime.hour; for (int i = 1; i < totalHours; i++) { double x = xStart + chartWidth * i / totalHours; int hourLabelNum = (startHour + i) % 24; String hourLabel = '$hourLabelNum'; TextPainter tp = TextPainter( text: TextSpan( text: hourLabel, style: TextStyle( fontSize: 18.rpx, color: themeController.currentColor.sc4, ), ), textDirection: ui.TextDirection.ltr, ); tp.layout(); tp.paint(canvas, Offset(x - tp.width / 2, chartHeight + 8.rpx)); } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => true; void drawDashedLine( Canvas canvas, Offset start, Offset end, Paint paint, { required double dashWidth, required double dashSpace, }) { final dx = end.dx - start.dx; final dy = end.dy - start.dy; final distance = sqrt(dx * dx + dy * dy); final direction = Offset(dx / distance, dy / distance); double drawn = 0; while (drawn < distance) { final from = start + direction * drawn; final to = start + direction * (drawn + dashWidth).clamp(0, distance); canvas.drawLine(from, to, paint); drawn += dashWidth + dashSpace; } } }