357 lines
10 KiB
Dart
357 lines
10 KiB
Dart
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));
|
||
|
||
// 中间小时刻度
|
||
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;
|
||
}
|
||
}
|
||
}
|