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

808 lines
25 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 'dart:math';
// import 'dart:ui' as ui;
// import 'package:ef/ef.dart';
// import 'package:flutter/material.dart';
// import 'package:intl/intl.dart';
// import 'package:vbvs_app/common/util/FitTool.dart';
// import 'package:vbvs_app/common/util/MyUtils.dart';
// class LineChartByRange extends StatefulWidget {
// final List<Map<String, dynamic>> showLabel;
// final int startTime;
// final int endTime;
// final int? threshold;
// /// 新增外部指定的 Y 轴最大值
// final int maxY;
// /// Y 轴分段数默认6段
// final int ySegments;
// const LineChartByRange({
// Key? key,
// required this.showLabel,
// required this.startTime,
// required this.endTime,
// required this.maxY,
// this.threshold,
// this.ySegments = 6,
// }) : super(key: key);
// @override
// State<LineChartByRange> createState() => _LineChartByRangeState();
// }
// class _LineChartByRangeState extends State<LineChartByRange> {
// Offset? selectedOffset;
// Map<String, dynamic>? selectedData;
// @override
// Widget build(BuildContext context) {
// if (widget.showLabel.isEmpty) return const SizedBox();
// DateTime minTime = DateTime.fromMillisecondsSinceEpoch(widget.startTime);
// DateTime maxTime = DateTime.fromMillisecondsSinceEpoch(widget.endTime);
// return GestureDetector(
// onTapDown: (details) {
// RenderBox box = context.findRenderObject() as RenderBox;
// final localPosition = box.globalToLocal(details.globalPosition);
// // 查找是否点击到某个点
// for (var item in widget.showLabel) {
// int start = item['startTime'];
// int end = item['endTime'];
// int times = item['times'];
// double chartWidth = box.size.width - 40.rpx; // 与 painter 内一致处理
// double chartHeight = box.size.height - 30.rpx;
// double xStart = 20.rpx + 12.rpx;
// int totalDuration =
// maxTime.millisecondsSinceEpoch - minTime.millisecondsSinceEpoch;
// double startX = xStart +
// chartWidth *
// (start - minTime.millisecondsSinceEpoch) /
// totalDuration;
// double y = chartHeight * (1 - times / widget.maxY);
// // 判断点击范围圆点半径±6.rpx范围
// if ((localPosition - Offset(startX, y)).distance < 10.rpx) {
// setState(() {
// selectedOffset = Offset(startX, y);
// selectedData = item;
// });
// return;
// }
// double endX = xStart +
// chartWidth *
// (end - minTime.millisecondsSinceEpoch) /
// totalDuration;
// if ((localPosition - Offset(endX, y)).distance < 10.rpx) {
// setState(() {
// selectedOffset = Offset(endX, y);
// selectedData = item;
// });
// return;
// }
// }
// // 没点到,清除选中
// setState(() {
// selectedOffset = null;
// selectedData = null;
// });
// },
// child: Stack(
// children: [
// SizedBox(
// height: 500.rpx,
// child: CustomPaint(
// size: Size(double.infinity, 500.rpx),
// painter: _LineChartByRangePainter(
// data: widget.showLabel,
// maxY: widget.maxY,
// minTime: minTime,
// maxTime: maxTime,
// threshold: widget.threshold,
// ySegments: widget.ySegments,
// ),
// ),
// ),
// if (selectedOffset != null && selectedData != null)
// Positioned(
// left: selectedOffset!.dx - 60.rpx,
// top: selectedOffset!.dy - 50.rpx,
// child: Container(
// padding:
// EdgeInsets.symmetric(horizontal: 12.rpx, vertical: 8.rpx),
// decoration: BoxDecoration(
// color: Colors.black.withOpacity(0.3),
// borderRadius: BorderRadius.circular(10.rpx),
// ),
// child: Text(
// '${DateFormat('HH:mm').format(DateTime.fromMillisecondsSinceEpoch(selectedData!['startTime']))} - '
// '${DateFormat('HH:mm').format(DateTime.fromMillisecondsSinceEpoch(selectedData!['endTime']))}\n'
// "时长"
// .tr +
// ' ${selectedData!['times']}',
// style: TextStyle(
// fontSize: 18.rpx,
// color: Colors.white,
// ),
// ),
// ),
// ),
// ],
// ),
// );
// }
// }
// class _LineChartByRangePainter extends CustomPainter {
// final List<Map<String, dynamic>> data;
// final int maxY;
// final DateTime minTime;
// final DateTime maxTime;
// final int? threshold;
// final int ySegments;
// _LineChartByRangePainter({
// required this.data,
// required this.maxY,
// required this.minTime,
// required this.maxTime,
// this.threshold,
// this.ySegments = 6,
// });
// @override
// void paint(Canvas canvas, Size size) {
// double padding = 20.rpx;
// double labelInset = 12.rpx;
// final double xStart = padding + labelInset;
// final double xEnd = size.width - padding - labelInset;
// final double chartWidth = xEnd - xStart;
// double chartHeight = size.height - 30.rpx;
// int totalDuration =
// maxTime.millisecondsSinceEpoch - minTime.millisecondsSinceEpoch;
// if (totalDuration <= 0) return;
// Paint axisPaint = Paint()
// ..color = Colors.grey.withOpacity(0.4)
// ..strokeWidth = 1.rpx;
// Paint thresholdPaint = Paint()
// ..color = themeController.currentColor.sc9
// ..strokeWidth = 1.rpx;
// // 阈值虚线(红色)
// if (threshold != null && threshold! >= 0 && threshold! <= maxY) {
// double yThreshold = chartHeight * (1 - threshold! / maxY);
// drawDashedLine(
// canvas,
// Offset(xStart, yThreshold),
// Offset(xEnd, yThreshold),
// thresholdPaint,
// dashWidth: 8.rpx,
// dashSpace: 6.rpx,
// );
// }
// // 绘制数据线段和圆点
// for (var item in data) {
// int start = item['startTime'];
// int end = item['endTime'];
// // int times = item['times'];
// int times = item['times'];
// double startX = xStart * 2 +
// chartWidth * (start - minTime.millisecondsSinceEpoch) / totalDuration;
// double endX = xStart * 2 +
// chartWidth * (end - minTime.millisecondsSinceEpoch) / totalDuration;
// double y = chartHeight * (1 - times / maxY);
// // 设置颜色(根据 threshold 判断)
// Color pointColor;
// if (threshold != null && times >= threshold!) {
// pointColor = themeController.currentColor.sc9;
// } else {
// pointColor = stringToColor("#00C1AA");
// }
// Paint dynamicLinePaint = Paint()
// ..style = PaintingStyle.stroke
// ..strokeWidth = 3.rpx
// ..color = pointColor
// ..strokeCap = StrokeCap.round;
// Paint dynamicCirclePaint = Paint()
// ..style = PaintingStyle.fill
// ..color = pointColor;
// // 画线段
// canvas.drawLine(Offset(startX, y), Offset(endX, y), dynamicLinePaint);
// // 画起点和终点圆点
// canvas.drawCircle(Offset(startX, y), 6.rpx, dynamicCirclePaint);
// canvas.drawCircle(Offset(endX, y), 6.rpx, dynamicCirclePaint);
// }
// // Y轴辅助线和文字
// for (int i = 0; i <= ySegments; i++) {
// double y = chartHeight * i / ySegments;
// if (i == ySegments) {
// canvas.drawLine(Offset(xStart, y), Offset(xEnd, y), axisPaint);
// } else {
// drawDashedLine(
// canvas,
// Offset(xStart, y),
// Offset(xEnd, y),
// axisPaint,
// dashWidth: 8.rpx,
// dashSpace: 6.rpx,
// );
// }
// TextPainter tp = TextPainter(
// text: TextSpan(
// text: '${maxY - (maxY * i / ySegments).round()}',
// style: TextStyle(
// fontSize: 18.rpx,
// color: themeController.currentColor.sc4,
// ),
// ),
// textDirection: ui.TextDirection.ltr,
// );
// tp.layout();
// tp.paint(canvas, Offset(0, y - tp.height / 2));
// }
// // X轴主线
// canvas.drawLine(
// Offset(xStart, chartHeight),
// Offset(xEnd, chartHeight),
// axisPaint,
// );
// // X轴时间文字左右两侧
// String leftLabel = DateFormat('HH:mm').format(minTime);
// TextPainter leftTp = TextPainter(
// text: TextSpan(
// text: leftLabel,
// style: TextStyle(
// fontSize: 18.rpx,
// color: themeController.currentColor.sc4,
// ),
// ),
// textDirection: ui.TextDirection.ltr,
// );
// leftTp.layout();
// leftTp.paint(canvas,
// Offset(padding + labelInset - leftTp.width / 2, chartHeight + 8.rpx));
// String rightLabel = DateFormat('HH:mm').format(maxTime);
// TextPainter rightTp = TextPainter(
// text: TextSpan(
// text: rightLabel,
// style: TextStyle(
// fontSize: 18.rpx,
// color: themeController.currentColor.sc4,
// ),
// ),
// textDirection: ui.TextDirection.ltr,
// );
// rightTp.layout();
// rightTp.paint(
// canvas,
// Offset(size.width - padding - labelInset - rightTp.width / 2,
// chartHeight + 8.rpx));
// // 中间小时刻度
// int totalHours = maxTime.difference(minTime).inHours + 1;
// int startHour = minTime.hour;
// for (int i = 1; i < totalHours; i++) {
// double x = xStart + chartWidth * i / totalHours;
// int hourLabelNum = (startHour + i) % 24;
// String hourLabel = '$hourLabelNum';
// TextPainter tp = TextPainter(
// text: TextSpan(
// text: hourLabel,
// style: TextStyle(
// fontSize: 18.rpx,
// color: themeController.currentColor.sc4,
// ),
// ),
// textDirection: ui.TextDirection.ltr,
// );
// tp.layout();
// tp.paint(canvas, Offset(x - tp.width / 2, chartHeight + 8.rpx));
// }
// }
// @override
// bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
// void drawDashedLine(
// Canvas canvas,
// Offset start,
// Offset end,
// Paint paint, {
// required double dashWidth,
// required double dashSpace,
// }) {
// final dx = end.dx - start.dx;
// final dy = end.dy - start.dy;
// final distance = sqrt(dx * dx + dy * dy);
// final direction = Offset(dx / distance, dy / distance);
// double drawn = 0;
// while (drawn < distance) {
// final from = start + direction * drawn;
// final to = start + direction * (drawn + dashWidth).clamp(0, distance);
// canvas.drawLine(from, to, paint);
// drawn += dashWidth + dashSpace;
// }
// }
// }
import 'dart:math';
import 'dart:ui' as ui;
import 'package:ef/ef.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:vbvs_app/common/util/FitTool.dart';
import 'package:vbvs_app/common/util/MyUtils.dart';
class LineChartByRange extends StatefulWidget {
final List<Map<String, dynamic>> showLabel;
final int startTime;
final int endTime;
final int? threshold;
/// 新增外部指定的 Y 轴最大值
final int maxY;
/// Y 轴分段数默认6段
final int ySegments;
const LineChartByRange({
Key? key,
required this.showLabel,
required this.startTime,
required this.endTime,
required this.maxY,
this.threshold,
this.ySegments = 6,
}) : super(key: key);
@override
State<LineChartByRange> createState() => _LineChartByRangeState();
}
class _LineChartByRangeState extends State<LineChartByRange> {
Offset? selectedOffset;
Map<String, dynamic>? selectedData;
@override
Widget build(BuildContext context) {
if (widget.showLabel.isEmpty) return const SizedBox();
DateTime minTime = DateTime.fromMillisecondsSinceEpoch(widget.startTime);
DateTime maxTime = DateTime.fromMillisecondsSinceEpoch(widget.endTime);
return GestureDetector(
onTapDown: (details) {
RenderBox box = context.findRenderObject() as RenderBox;
final localPosition = box.globalToLocal(details.globalPosition);
// 查找是否点击到某个点
for (var item in widget.showLabel) {
int start = item['startTime'];
int end = item['endTime'];
int times = item['times'];
double chartWidth = box.size.width - 40.rpx; // 与 painter 内一致处理
double chartHeight = box.size.height - 30.rpx;
double xStart = 20.rpx + 12.rpx;
int totalDuration =
maxTime.millisecondsSinceEpoch - minTime.millisecondsSinceEpoch;
double startX = xStart +
chartWidth *
(start - minTime.millisecondsSinceEpoch) /
totalDuration;
double y = chartHeight * (1 - times / widget.maxY);
// 判断点击范围圆点半径±6.rpx范围
if ((localPosition - Offset(startX, y)).distance < 10.rpx) {
setState(() {
selectedOffset = Offset(startX, y);
selectedData = item;
});
return;
}
double endX = xStart +
chartWidth *
(end - minTime.millisecondsSinceEpoch) /
totalDuration;
if ((localPosition - Offset(endX, y)).distance < 10.rpx) {
setState(() {
selectedOffset = Offset(endX, y);
selectedData = item;
});
return;
}
}
// 没点到,清除选中
setState(() {
selectedOffset = null;
selectedData = null;
});
},
child: Stack(
children: [
SizedBox(
height: 500.rpx,
child: CustomPaint(
size: Size(double.infinity, 500.rpx),
painter: _LineChartByRangePainter(
data: widget.showLabel,
maxY: widget.maxY,
minTime: minTime,
maxTime: maxTime,
threshold: widget.threshold,
ySegments: widget.ySegments,
),
),
),
if (selectedOffset != null && selectedData != null)
Positioned(
left: selectedOffset!.dx - 60.rpx,
top: selectedOffset!.dy - 50.rpx,
child: Container(
padding:
EdgeInsets.symmetric(horizontal: 12.rpx, vertical: 8.rpx),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
borderRadius: BorderRadius.circular(10.rpx),
),
child: Text(
'${DateFormat('HH:mm').format(DateTime.fromMillisecondsSinceEpoch(selectedData!['startTime']))} - '
'${DateFormat('HH:mm').format(DateTime.fromMillisecondsSinceEpoch(selectedData!['endTime']))}\n'
"时长"
.tr +
' ${selectedData!['times']}',
style: TextStyle(
fontSize: 18.rpx,
color: Colors.white,
),
),
),
),
],
),
);
}
}
class _LineChartByRangePainter extends CustomPainter {
final List<Map<String, dynamic>> data;
final int maxY;
final DateTime minTime;
final DateTime maxTime;
final int? threshold;
final int ySegments;
_LineChartByRangePainter({
required this.data,
required this.maxY,
required this.minTime,
required this.maxTime,
this.threshold,
this.ySegments = 6,
});
@override
void paint(Canvas canvas, Size size) {
double padding = 20.rpx;
double labelInset = 12.rpx;
final double xStart = padding + labelInset;
final double xEnd = size.width - padding - labelInset;
final double chartWidth = xEnd - xStart;
double chartHeight = size.height - 30.rpx;
int totalDuration =
maxTime.millisecondsSinceEpoch - minTime.millisecondsSinceEpoch;
if (totalDuration <= 0) return;
Paint axisPaint = Paint()
..color = Colors.grey.withOpacity(0.4)
..strokeWidth = 1.rpx;
Paint thresholdPaint = Paint()
..color = themeController.currentColor.sc9
..strokeWidth = 1.rpx;
// 阈值虚线(红色)
if (threshold != null && threshold! >= 0 && threshold! <= maxY) {
double yThreshold = chartHeight * (1 - threshold! / maxY);
drawDashedLine(
canvas,
Offset(xStart, yThreshold),
Offset(xEnd, yThreshold),
thresholdPaint,
dashWidth: 8.rpx,
dashSpace: 6.rpx,
);
}
// 绘制数据线段和圆点
for (var item in data) {
int start = item['startTime'];
int end = item['endTime'];
int times = item['times'];
double startX = xStart * 2 +
chartWidth * (start - minTime.millisecondsSinceEpoch) / totalDuration;
double endX = xStart * 2 +
chartWidth * (end - minTime.millisecondsSinceEpoch) / totalDuration;
double y = chartHeight * (1 - times / maxY);
// 设置颜色(根据 threshold 判断)
Color pointColor;
if (threshold != null && times >= threshold!) {
pointColor = themeController.currentColor.sc9;
} else {
pointColor = stringToColor("#00C1AA");
}
Paint dynamicLinePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3.rpx
..color = pointColor
..strokeCap = StrokeCap.round;
Paint dynamicCirclePaint = Paint()
..style = PaintingStyle.fill
..color = pointColor;
// 画线段
canvas.drawLine(Offset(startX, y), Offset(endX, y), dynamicLinePaint);
// 画起点和终点圆点
canvas.drawCircle(Offset(startX, y), 6.rpx, dynamicCirclePaint);
canvas.drawCircle(Offset(endX, y), 6.rpx, dynamicCirclePaint);
}
// Y轴辅助线和文字
for (int i = 0; i <= ySegments; i++) {
double y = chartHeight * i / ySegments;
if (i == ySegments) {
canvas.drawLine(Offset(xStart, y), Offset(xEnd, y), axisPaint);
} else {
drawDashedLine(
canvas,
Offset(xStart, y),
Offset(xEnd, y),
axisPaint,
dashWidth: 8.rpx,
dashSpace: 6.rpx,
);
}
TextPainter tp = TextPainter(
text: TextSpan(
text: '${maxY - (maxY * i / ySegments).round()}',
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
),
textDirection: ui.TextDirection.ltr,
);
tp.layout();
tp.paint(canvas, Offset(0, y - tp.height / 2));
}
// X轴主线
canvas.drawLine(
Offset(xStart, chartHeight),
Offset(xEnd, chartHeight),
axisPaint,
);
// X轴时间文字左右两侧
String leftLabel = DateFormat('HH:mm').format(minTime);
TextPainter leftTp = TextPainter(
text: TextSpan(
text: leftLabel,
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
),
textDirection: ui.TextDirection.ltr,
);
leftTp.layout();
leftTp.paint(canvas,
Offset(padding + labelInset - leftTp.width / 2, chartHeight + 8.rpx));
String rightLabel = DateFormat('HH:mm').format(maxTime);
TextPainter rightTp = TextPainter(
text: TextSpan(
text: rightLabel,
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
),
textDirection: ui.TextDirection.ltr,
);
rightTp.layout();
rightTp.paint(
canvas,
Offset(size.width - padding - labelInset - rightTp.width / 2,
chartHeight + 8.rpx));
// 中间小时刻度 - 使用实际时间比例
final int hourMs = 60 * 60 * 1000;
final int totalHours =
(maxTime.millisecondsSinceEpoch - minTime.millisecondsSinceEpoch) ~/
hourMs;
if (totalHours <= 8) {
// 显示每个整点
DateTime currentHour = DateTime(
minTime.year,
minTime.month,
minTime.day,
minTime.hour,
);
// 如果起始时间不是整点,从下一个整点开始
if (minTime.minute > 0 || minTime.second > 0 || minTime.millisecond > 0) {
currentHour = currentHour.add(Duration(hours: 1));
}
while (
currentHour.millisecondsSinceEpoch < maxTime.millisecondsSinceEpoch) {
final int timeMs = currentHour.millisecondsSinceEpoch;
if (timeMs > minTime.millisecondsSinceEpoch &&
timeMs < maxTime.millisecondsSinceEpoch) {
// 检查是否太靠近边界
if (timeMs - minTime.millisecondsSinceEpoch < 10 * 60 * 1000 ||
maxTime.millisecondsSinceEpoch - timeMs < 10 * 60 * 1000) {
currentHour = currentHour.add(Duration(hours: 1));
continue;
}
// 计算从起始时间到当前整点的位置
final double x = xStart +
chartWidth *
(timeMs - minTime.millisecondsSinceEpoch) /
totalDuration;
final hourLabel = '${currentHour.hour}';
TextPainter tp = TextPainter(
text: TextSpan(
text: hourLabel,
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
),
textDirection: ui.TextDirection.ltr,
);
tp.layout();
tp.paint(
canvas,
Offset(x - tp.width / 2, chartHeight + 8.rpx),
);
}
currentHour = currentHour.add(Duration(hours: 1));
}
} else {
// 超过8小时跳着显示
final int labelInterval = (totalHours / 6).ceil(); // 分成大约6个标签
DateTime firstLabelHour = DateTime(
minTime.year,
minTime.month,
minTime.day,
minTime.hour + (labelInterval - (minTime.hour % labelInterval)),
0,
0,
0,
0,
);
// 确保第一个标签在开始时间之后
if (firstLabelHour.millisecondsSinceEpoch <=
minTime.millisecondsSinceEpoch) {
firstLabelHour = firstLabelHour.add(Duration(hours: labelInterval));
}
DateTime currentHour = firstLabelHour;
while (
currentHour.millisecondsSinceEpoch < maxTime.millisecondsSinceEpoch) {
final int timeMs = currentHour.millisecondsSinceEpoch;
// 确保标签离边界足够远
if (timeMs - minTime.millisecondsSinceEpoch >= hourMs &&
maxTime.millisecondsSinceEpoch - timeMs >= hourMs) {
final double x = xStart +
chartWidth *
(timeMs - minTime.millisecondsSinceEpoch) /
totalDuration;
final hourLabel = '${currentHour.hour}';
TextPainter tp = TextPainter(
text: TextSpan(
text: hourLabel,
style: TextStyle(
fontSize: 18.rpx,
color: themeController.currentColor.sc4,
),
),
textDirection: ui.TextDirection.ltr,
);
tp.layout();
tp.paint(
canvas,
Offset(x - tp.width / 2, chartHeight + 8.rpx),
);
}
currentHour = currentHour.add(Duration(hours: labelInterval));
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
void drawDashedLine(
Canvas canvas,
Offset start,
Offset end,
Paint paint, {
required double dashWidth,
required double dashSpace,
}) {
final dx = end.dx - start.dx;
final dy = end.dy - start.dy;
final distance = sqrt(dx * dx + dy * dy);
final direction = Offset(dx / distance, dy / distance);
double drawn = 0;
while (drawn < distance) {
final from = start + direction * drawn;
final to = start + direction * (drawn + dashWidth).clamp(0, distance);
canvas.drawLine(from, to, paint);
drawn += dashWidth + dashSpace;
}
}
}