import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:vbvs_app/common/util/FitTool.dart'; import 'package:vbvs_app/common/util/MyUtils.dart'; class TimeSeriesPoint { final int timestamp; final double value; TimeSeriesPoint(this.timestamp, this.value); } class TimeSeriesChart extends StatelessWidget { final int startTime; // 毫秒时间戳 final int endTime; final double yMin; final double yMax; final List dataPoints; const TimeSeriesChart({ Key? key, required this.startTime, required this.endTime, required this.yMin, required this.yMax, required this.dataPoints, }) : super(key: key); // 计算总分钟数 double get _totalMinutes { return (endTime - startTime) / (1000 * 60); } // 生成X轴刻度标签 Map _generateXLabels() { final labels = {}; final startDate = DateTime.fromMillisecondsSinceEpoch(startTime); final endDate = DateTime.fromMillisecondsSinceEpoch(endTime); // 0分钟位置:起始时间 labels[0.0] = '${startDate.hour.toString().padLeft(2, '0')}:${startDate.minute.toString().padLeft(2, '0')}'; // 计算总小时数 final int hourMs = 60 * 60 * 1000; final int totalHours = (endTime - startTime) ~/ hourMs; // 按照参考代码的逻辑,当小时数超过8时,跳着显示 if (totalHours <= 8) { // 显示每个整点 DateTime currentHour = DateTime( startDate.year, startDate.month, startDate.day, startDate.hour, ); // 如果起始时间不是整点,从下一个整点开始 if (startDate.minute > 0 || startDate.second > 0 || startDate.millisecond > 0) { currentHour = currentHour.add(Duration(hours: 1)); } while (currentHour.millisecondsSinceEpoch < endTime) { final int timeMs = currentHour.millisecondsSinceEpoch; if (timeMs > startTime && timeMs < endTime) { // 检查是否太靠近边界 if (timeMs - startTime < 10 * 60 * 1000 || endTime - timeMs < 10 * 60 * 1000) { currentHour = currentHour.add(Duration(hours: 1)); continue; } // 计算从起始时间到当前整点的分钟数 final minutesFromStart = (currentHour.millisecondsSinceEpoch - startTime) / (1000 * 60); labels[minutesFromStart] = '${currentHour.hour}'; } currentHour = currentHour.add(Duration(hours: 1)); } } else { // 超过8小时,跳着显示 final int labelInterval = (totalHours / 6).ceil(); // 分成大约6个标签 DateTime firstLabelHour = DateTime( startDate.year, startDate.month, startDate.day, startDate.hour + (labelInterval - (startDate.hour % labelInterval)), 0, 0, 0, 0, ); // 确保第一个标签在开始时间之后 if (firstLabelHour.millisecondsSinceEpoch <= startTime) { firstLabelHour = firstLabelHour.add(Duration(hours: labelInterval)); } DateTime currentHour = firstLabelHour; while (currentHour.millisecondsSinceEpoch < endTime) { final int timeMs = currentHour.millisecondsSinceEpoch; // 确保标签离边界足够远 if (timeMs - startTime >= hourMs && endTime - timeMs >= hourMs) { final minutesFromStart = (currentHour.millisecondsSinceEpoch - startTime) / (1000 * 60); labels[minutesFromStart] = '${currentHour.hour}'; } currentHour = currentHour.add(Duration(hours: labelInterval)); } } // 最后位置:结束时间 labels[_totalMinutes] = '${endDate.hour.toString().padLeft(2, '0')}:${endDate.minute.toString().padLeft(2, '0')}'; return labels; } // 时间戳映射到X坐标(分钟数) double _timeToX(double timestamp) { final minutesFromStart = (timestamp - startTime) / (1000 * 60); return minutesFromStart.clamp(0.0, _totalMinutes); } @override Widget build(BuildContext context) { final xLabels = _generateXLabels(); final labelPositions = xLabels.keys.toList()..sort(); final midY = (yMin + yMax) / 2; // 将数据点分割成多个连续段,遇到value=-1时断开 List> lineSegments = []; List currentSegment = []; for (var point in dataPoints) { if (point.value != -1) { // 有效数据点,添加到当前段 currentSegment.add(FlSpot( _timeToX(point.timestamp.toDouble()), point.value, )); } else if (currentSegment.isNotEmpty) { // 遇到无效点且当前段不为空,结束当前段 lineSegments.add(currentSegment); currentSegment = []; } } // 添加最后一个段(如果有) if (currentSegment.isNotEmpty) { lineSegments.add(currentSegment); } return AspectRatio( aspectRatio: 2, child: LineChart( LineChartData( minX: 0, maxX: _totalMinutes, minY: yMin < 0 ? yMin : 0, maxY: yMax, gridData: FlGridData(show: false), extraLinesData: ExtraLinesData( horizontalLines: [ HorizontalLine( y: 0, color: themeController.currentColor.sc4, strokeWidth: 1.rpx), HorizontalLine( y: yMin, color: themeController.currentColor.sc9, strokeWidth: 1.rpx, dashArray: [5, 5]), HorizontalLine( y: yMax, color: themeController.currentColor.sc9, strokeWidth: 1.rpx, dashArray: [5, 5]), HorizontalLine( y: midY, color: themeController.currentColor.sc4, strokeWidth: 1.rpx, dashArray: [5, 5]), ], ), titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 30, interval: 1, // 现在每个单位是1分钟 getTitlesWidget: (value, meta) { // 四舍五入到最接近的整数 final roundedValue = value.roundToDouble(); // 检查是否在标签位置 for (var position in labelPositions) { if ((position - roundedValue).abs() < 0.5) { final label = xLabels[position] ?? ''; return Padding( padding: const EdgeInsets.only(top: 8.0), child: Text(label, style: TextStyle( color: themeController.currentColor.sc4, fontSize: 16.rpx)), ); } } return const SizedBox.shrink(); }, ), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40.rpx, interval: 1, getTitlesWidget: (value, meta) { // 只显示 yMin, midY, yMax, 和 0 if ((value - 0).abs() < 0.01) { return Text('0', style: TextStyle( color: themeController.currentColor.sc4, fontSize: 16.rpx)); } else if ((value - yMin).abs() < 0.01) { return Text(yMin.toStringAsFixed(0), style: TextStyle( color: themeController.currentColor.sc4, fontSize: 16.rpx)); } else if ((value - midY).abs() < 0.01) { return Text(midY.toStringAsFixed(0), style: TextStyle( color: themeController.currentColor.sc4, fontSize: 16.rpx)); } else if ((value - yMax).abs() < 0.01) { return Text(yMax.toStringAsFixed(0), style: TextStyle( color: themeController.currentColor.sc4, fontSize: 16.rpx)); } else { return const SizedBox.shrink(); } }, ), ), rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), ), borderData: FlBorderData( show: true, border: Border( bottom: BorderSide(color: Colors.grey.withOpacity(0.3)), left: BorderSide.none, right: BorderSide.none, top: BorderSide.none, ), ), lineBarsData: lineSegments.map((segment) { return LineChartBarData( spots: segment, isCurved: false, color: themeController.currentColor.sc2, barWidth: 2, dotData: FlDotData(show: false), preventCurveOverShooting: true, ); }).toList(), ), ), ); } }