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); // X轴刻度数据 List _generateXLabels() { final labels = []; final startDate = DateTime.fromMillisecondsSinceEpoch(startTime); final endDate = DateTime.fromMillisecondsSinceEpoch(endTime); // 第一个刻度,原始 startTime,HH:mm格式 labels.add(XLabel( time: startTime, label: '${startDate.hour.toString().padLeft(2, '0')}:${startDate.minute.toString().padLeft(2, '0')}', )); // 生成中间整点小时刻度,注意起点向上取整一个小时 DateTime current = DateTime( startDate.year, startDate.month, startDate.day, startDate.hour, ); if (startDate.minute > 0 || startDate.second > 0 || startDate.millisecond > 0) { // 如果 startTime 不是整点,跳到下一个整点小时 current = current.add(Duration(hours: 1)); } while (current.isBefore(endDate)) { labels.add(XLabel( time: current.millisecondsSinceEpoch, label: current.hour.toString(), )); current = current.add(Duration(hours: 1)); } // 最后一个刻度,原始 endTime,HH:mm格式 labels.add(XLabel( time: endTime, label: '${endDate.hour.toString().padLeft(2, '0')}:${endDate.minute.toString().padLeft(2, '0')}', )); return labels; } // 时间戳映射到0~(labels.length-1)之间 double _timeToX(double timestamp, List labels) { int start = labels.first.time; int end = labels.last.time; double total = (end - start).toDouble(); double pos = (timestamp - start).clamp(0, total).toDouble(); return pos / total * (labels.length - 1); } @override Widget build(BuildContext context) { final xLabels = _generateXLabels(); final midY = (yMin + yMax) / 2; List spots = dataPoints.map((p) { return FlSpot(_timeToX(p.timestamp.toDouble(), xLabels), p.value); }).toList(); return AspectRatio( aspectRatio: 2, child: LineChart( LineChartData( minX: 0, maxX: (xLabels.length - 1).toDouble(), 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, getTitlesWidget: (value, meta) { int index = value.toInt(); if (index < 0 || index >= xLabels.length) return const SizedBox.shrink(); final dateTime = DateTime.fromMillisecondsSinceEpoch(xLabels[index].time); if (index == 0 || index == xLabels.length - 1) { // 开始和结束显示 HH:mm final formatted = '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; return Padding( padding: const EdgeInsets.only(top: 8.0), child: Text(formatted, style: TextStyle( color: themeController.currentColor.sc4, fontSize: 16.rpx)), ); } else { // 中间显示小时H,24小时制,不补零 final formatted = '${dateTime.hour}'; return Padding( padding: const EdgeInsets.only(top: 8.0), child: Text(formatted, style: TextStyle( color: themeController.currentColor.sc4, fontSize: 16.rpx)), ); } }, ), ), 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: [ LineChartBarData( spots: spots, isCurved: false, color: themeController.currentColor.sc2, barWidth: 2, dotData: FlDotData(show: false), ) ], ), ), ); } } class XLabel { final int time; final String label; XLabel({required this.time, required this.label}); }