Files
tuiche/lib/pages/sleep_report/chart/TimeSeriesChart.dart
2025-06-03 09:00:01 +08:00

228 lines
7.7 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:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:vbvs_app/common/color/appConstants.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);
// X轴刻度数据
List<XLabel> _generateXLabels() {
final labels = <XLabel>[];
final startDate = DateTime.fromMillisecondsSinceEpoch(startTime);
final endDate = DateTime.fromMillisecondsSinceEpoch(endTime);
// 第一个刻度,原始 startTimeHH: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));
}
// 最后一个刻度,原始 endTimeHH: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<XLabel> 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<FlSpot> 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 {
// 中间显示小时H24小时制不补零
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});
}