280 lines
9.4 KiB
Dart
280 lines
9.4 KiB
Dart
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<TimeSeriesPoint> 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<double, String> _generateXLabels() {
|
||
final labels = <double, String>{};
|
||
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<List<FlSpot>> lineSegments = [];
|
||
List<FlSpot> 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(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|