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'; import 'dart:ui' as ui; class DotBarChart extends StatefulWidget { final List> showLabel; final int threshold; final int startTime; final int endTime; const DotBarChart({ Key? key, required this.showLabel, required this.threshold, required this.startTime, required this.endTime, }) : super(key: key); @override _DotBarChartState createState() => _DotBarChartState(); } class _DotBarChartState extends State { int? selectedIndex; OverlayEntry? _overlayEntry; @override void dispose() { _removeOverlay(); super.dispose(); } void _removeOverlay() { _overlayEntry?.remove(); _overlayEntry = null; } void _showTooltip(BuildContext context, Map data, Offset position, double dotY) { _removeOverlay(); final RenderBox renderBox = context.findRenderObject() as RenderBox; final Offset globalPosition = renderBox.localToGlobal(position); _overlayEntry = OverlayEntry( builder: (context) => Positioned( left: globalPosition.dx - 60.rpx, top: globalPosition.dy - 80.rpx, child: Material( color: Colors.transparent, child: Container( padding: EdgeInsets.all(12.rpx), decoration: BoxDecoration( color: themeController.currentColor.sc5, borderRadius: BorderRadius.circular(8.rpx), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.5), blurRadius: 12.rpx, spreadRadius: 2.rpx, offset: Offset(0, 6.rpx), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '时间: ${DateFormat('HH:mm').format(DateTime.fromMillisecondsSinceEpoch(data['time']))}', style: TextStyle( fontSize: 20.rpx, color: themeController.currentColor.sc3, ), ), SizedBox(height: 4.rpx), Text( '时长: ${data['times']}秒', style: TextStyle( fontSize: 20.rpx, color: themeController.currentColor.sc3, ), ), ], ), ), ), ), ); Overlay.of(context)?.insert(_overlayEntry!); } @override Widget build(BuildContext context) { if (widget.showLabel.isEmpty) return const SizedBox(); int maxTimes = widget.showLabel .map((e) => e['times'] ?? 0) .reduce((a, b) => a > b ? a : b); int yMax = (maxTimes / 10).ceil() * 10; if (yMax == 0) yMax = 10; int maxSteps = 6; int step = (yMax / maxSteps).ceil(); step = ((step + 9) ~/ 10) * 10; int displayMax = step * maxSteps; List yLabels = List.generate(maxSteps + 1, (index) => step * index); DateTime startDate = DateTime.fromMillisecondsSinceEpoch(widget.startTime); DateTime endDate = DateTime.fromMillisecondsSinceEpoch(widget.endTime); // Generate hourly timestamps List hourlyTimestamps = []; DateTime currentHour = DateTime( startDate.year, startDate.month, startDate.day, startDate.hour); while (currentHour.isBefore(endDate) || currentHour.isAtSameMomentAs(endDate)) { hourlyTimestamps.add(currentHour.millisecondsSinceEpoch); currentHour = currentHour.add(const Duration(hours: 1)); } // Calculate positions for hourly labels List> hourLabels = []; if (widget.showLabel.isNotEmpty) { int firstDataTime = widget.showLabel.first['time']; int lastDataTime = widget.showLabel.last['time']; double totalDuration = (lastDataTime - firstDataTime).toDouble(); for (int timestamp in hourlyTimestamps) { if (timestamp >= firstDataTime && timestamp <= lastDataTime) { double position = (timestamp - firstDataTime) / totalDuration; hourLabels.add({ 'time': timestamp, 'position': position, }); } } } double yAxisWidth = 36.rpx; double xAxisHeight = 40.rpx; double chartHeight = 491.rpx; double bottomPadding = 10.rpx; return GestureDetector( onTap: () { setState(() { selectedIndex = null; _removeOverlay(); }); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: chartHeight, child: Row( children: [ SizedBox( width: yAxisWidth, height: chartHeight + bottomPadding, child: Stack( children: [ Positioned( top: 0, right: 6.rpx, child: Text( '秒', style: TextStyle( fontSize: 18.rpx, color: themeController.currentColor.sc4, ), ), ), ...List.generate(yLabels.length, (index) { double yLabelsAreaHeight = chartHeight - (30.rpx + 18.rpx) - bottomPadding; double y = (30.rpx + 18.rpx) + index * (yLabelsAreaHeight / (yLabels.length - 1)); double textHeight = 18.rpx; double topPos = y - textHeight / 2; if (index == yLabels.length - 1) { topPos -= bottomPadding / 2; } return Positioned( right: 6.rpx, top: topPos, child: Text( '${yLabels.reversed.toList()[index]}', style: TextStyle( fontSize: 18.rpx, color: themeController.currentColor.sc4, ), ), ); }), ], ), ), Expanded( child: CustomPaint( size: Size(double.infinity, chartHeight), painter: _DotBarChartPainter( data: widget.showLabel, yMax: displayMax, threshold: widget.threshold, yLabelsCount: yLabels.length, yAxisTopPadding: 30.rpx + 18.rpx, horizontalPadding: 20.rpx, selectedIndex: selectedIndex, ), child: LayoutBuilder( builder: (context, constraints) { final double chartWidth = constraints.maxWidth - 2 * 20.rpx; final double xStep = widget.showLabel.length > 1 ? chartWidth / (widget.showLabel.length - 1) : 0; final double drawableHeight = chartHeight - (30.rpx + 18.rpx); return Stack( children: widget.showLabel.asMap().entries.map((entry) { int index = entry.key; Map data = entry.value; int times = data['times'] ?? 0; double x = 20.rpx + index * xStep; double y = (30.rpx + 18.rpx) + drawableHeight * (1 - times / displayMax); return Positioned( left: x - 20.rpx, top: y - 20.rpx, child: GestureDetector( onTap: () { setState(() { selectedIndex = index; }); _showTooltip(context, data, Offset(x, y), y); }, child: Container( width: 40.rpx, height: 40.rpx, color: Colors.transparent, ), ), ); }).toList(), ); }, ), ), ), ], ), ), Padding( padding: EdgeInsets.only(left: yAxisWidth), child: SizedBox( height: xAxisHeight, child: CustomPaint( size: Size(double.infinity, xAxisHeight), painter: _XAxisPainter( hourLabels: hourLabels, textColor: themeController.currentColor.sc4, fontSize: 18.rpx, startTime: widget.startTime, endTime: widget.endTime, ), ), ), ), ], ), ); } } class _DotBarChartPainter extends CustomPainter { final List> data; final int yMax; final int threshold; final int yLabelsCount; final double yAxisTopPadding; final double horizontalPadding; final int? selectedIndex; _DotBarChartPainter({ required this.data, required this.yMax, required this.threshold, required this.yLabelsCount, required this.yAxisTopPadding, required this.horizontalPadding, this.selectedIndex, }); @override void paint(Canvas canvas, Size size) { final double chartWidth = size.width - 2 * horizontalPadding; final double xStep = data.length > 1 ? chartWidth / (data.length - 1) : 0; final double chartHeight = size.height; final double drawableHeight = chartHeight - yAxisTopPadding; final Paint thresholdPaint = Paint() ..style = PaintingStyle.stroke ..color = stringToColor("#FF7159") ..strokeWidth = 1.rpx; double thresholdY = yAxisTopPadding + drawableHeight * (1 - threshold / yMax); drawDashedLine( canvas, Offset(0, thresholdY), Offset(size.width, thresholdY), thresholdPaint, 8.rpx, 6.rpx, ); final double dotRadius = 13.rpx; for (int i = 0; i < data.length; i++) { int times = data[i]['times'] ?? 0; double x = horizontalPadding + i * xStep; double y = yAxisTopPadding + drawableHeight * (1 - times / yMax); Paint dotPaint = Paint() ..style = PaintingStyle.fill ..color = times >= threshold ? stringToColor("#FF7159") : stringToColor("#00C1AA"); // Draw a larger circle for selected dot if (i == selectedIndex) { Paint borderPaint = Paint() ..style = PaintingStyle.stroke ..color = Colors.white ..strokeWidth = 3.rpx; canvas.drawCircle(Offset(x, y), dotRadius + 1.rpx, borderPaint); } canvas.drawCircle(Offset(x, y), dotRadius, dotPaint); } final Paint solidLinePaint = Paint() ..color = Colors.grey.withOpacity(0.7) ..strokeWidth = 1.rpx ..style = PaintingStyle.stroke; final Paint dashedLinePaint = Paint() ..color = Colors.grey.withOpacity(0.4) ..strokeWidth = 1.rpx ..style = PaintingStyle.stroke; for (int i = 0; i < yLabelsCount; i++) { double y = yAxisTopPadding + i * (drawableHeight / (yLabelsCount - 1)); if (i == yLabelsCount - 1) { canvas.drawLine(Offset(0, y), Offset(size.width, y), solidLinePaint); } else { drawDashedLine( canvas, Offset(0, y), Offset(size.width, y), dashedLinePaint, 8.rpx, 6.rpx, ); } } } void drawDashedLine( Canvas canvas, Offset start, Offset end, Paint paint, double dashWidth, double gapWidth, ) { double dx = start.dx; final double y = start.dy; while (dx < end.dx) { final double nextDx = (dx + dashWidth).clamp(start.dx, end.dx); canvas.drawLine(Offset(dx, y), Offset(nextDx, y), paint); dx = nextDx + gapWidth; } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => true; } class _XAxisPainter extends CustomPainter { final List> hourLabels; final Color textColor; final double fontSize; final int startTime; final int endTime; _XAxisPainter({ required this.hourLabels, required this.textColor, required this.fontSize, required this.startTime, required this.endTime, }); @override void paint(Canvas canvas, Size size) { final textStyle = TextStyle( color: textColor, fontSize: fontSize, ); final textPainter = TextPainter( textDirection: ui.TextDirection.ltr, textAlign: TextAlign.center, ); // Draw start time (leftmost) final startText = DateFormat('HH:mm') .format(DateTime.fromMillisecondsSinceEpoch(startTime)); final startTextSpan = TextSpan(text: startText, style: textStyle); textPainter.text = startTextSpan; textPainter.layout(); textPainter.paint(canvas, Offset(0, 14.rpx)); // Draw end time (rightmost) final endText = DateFormat('HH:mm') .format(DateTime.fromMillisecondsSinceEpoch(endTime)); final endTextSpan = TextSpan(text: endText, style: textStyle); textPainter.text = endTextSpan; textPainter.layout(); textPainter.paint(canvas, Offset(size.width - textPainter.width, 14.rpx)); // Draw hourly labels in between for (var label in hourLabels) { final position = label['position'] * size.width; final time = DateTime.fromMillisecondsSinceEpoch(label['time']); final hourText = DateFormat('h').format(time); final textSpan = TextSpan(text: hourText, style: textStyle); textPainter.text = textSpan; textPainter.layout(); final offset = Offset( position - textPainter.width / 2, 14.rpx, // Padding from bottom ); textPainter.paint(canvas, offset); } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => true; }