Files
tuiche/lib/pages/sleep_report/chart/TimeSeriesChart.dart
2026-01-08 11:56:17 +08:00

280 lines
9.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
),
),
);
}
}