454 lines
15 KiB
Dart
454 lines
15 KiB
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';
|
|
import 'dart:ui' as ui;
|
|
|
|
class DotBarChart extends StatefulWidget {
|
|
final List<Map<String, dynamic>> showLabel;
|
|
final int threshold;
|
|
final int startTime;
|
|
final int endTime;
|
|
|
|
const DotBarChart({
|
|
Key? key,
|
|
required this.showLabel,
|
|
required this.threshold,
|
|
required this.startTime,
|
|
required this.endTime,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
_DotBarChartState createState() => _DotBarChartState();
|
|
}
|
|
|
|
class _DotBarChartState extends State<DotBarChart> {
|
|
int? selectedIndex;
|
|
OverlayEntry? _overlayEntry;
|
|
|
|
@override
|
|
void dispose() {
|
|
_removeOverlay();
|
|
super.dispose();
|
|
}
|
|
|
|
void _removeOverlay() {
|
|
_overlayEntry?.remove();
|
|
_overlayEntry = null;
|
|
}
|
|
|
|
void _showTooltip(BuildContext context, Map<String, dynamic> data,
|
|
Offset position, double dotY) {
|
|
_removeOverlay();
|
|
final RenderBox renderBox = context.findRenderObject() as RenderBox;
|
|
final Offset globalPosition = renderBox.localToGlobal(position);
|
|
|
|
_overlayEntry = OverlayEntry(
|
|
builder: (context) => Positioned(
|
|
left: globalPosition.dx - 60.rpx,
|
|
top: globalPosition.dy - 80.rpx,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: Container(
|
|
padding: EdgeInsets.all(12.rpx),
|
|
decoration: BoxDecoration(
|
|
color: themeController.currentColor.sc5,
|
|
borderRadius: BorderRadius.circular(8.rpx),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.5),
|
|
blurRadius: 12.rpx,
|
|
spreadRadius: 2.rpx,
|
|
offset: Offset(0, 6.rpx),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'时间: ${DateFormat('HH:mm').format(DateTime.fromMillisecondsSinceEpoch(data['time']))}',
|
|
style: TextStyle(
|
|
fontSize: 20.rpx,
|
|
color: themeController.currentColor.sc3,
|
|
),
|
|
),
|
|
SizedBox(height: 4.rpx),
|
|
Text(
|
|
'时长: ${data['times']}秒',
|
|
style: TextStyle(
|
|
fontSize: 20.rpx,
|
|
color: themeController.currentColor.sc3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
Overlay.of(context)?.insert(_overlayEntry!);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.showLabel.isEmpty) return const SizedBox();
|
|
|
|
int maxTimes = widget.showLabel
|
|
.map((e) => e['times'] ?? 0)
|
|
.reduce((a, b) => a > b ? a : b);
|
|
int yMax = (maxTimes / 10).ceil() * 10;
|
|
if (yMax == 0) yMax = 10;
|
|
|
|
int maxSteps = 6;
|
|
int step = (yMax / maxSteps).ceil();
|
|
step = ((step + 9) ~/ 10) * 10;
|
|
int displayMax = step * maxSteps;
|
|
List<int> yLabels = List.generate(maxSteps + 1, (index) => step * index);
|
|
|
|
DateTime startDate = DateTime.fromMillisecondsSinceEpoch(widget.startTime);
|
|
DateTime endDate = DateTime.fromMillisecondsSinceEpoch(widget.endTime);
|
|
|
|
// Generate hourly timestamps
|
|
List<int> hourlyTimestamps = [];
|
|
DateTime currentHour = DateTime(
|
|
startDate.year, startDate.month, startDate.day, startDate.hour);
|
|
while (currentHour.isBefore(endDate) ||
|
|
currentHour.isAtSameMomentAs(endDate)) {
|
|
hourlyTimestamps.add(currentHour.millisecondsSinceEpoch);
|
|
currentHour = currentHour.add(const Duration(hours: 1));
|
|
}
|
|
|
|
// Calculate positions for hourly labels
|
|
List<Map<String, dynamic>> hourLabels = [];
|
|
if (widget.showLabel.isNotEmpty) {
|
|
int firstDataTime = widget.showLabel.first['time'];
|
|
int lastDataTime = widget.showLabel.last['time'];
|
|
double totalDuration = (lastDataTime - firstDataTime).toDouble();
|
|
|
|
for (int timestamp in hourlyTimestamps) {
|
|
if (timestamp >= firstDataTime && timestamp <= lastDataTime) {
|
|
double position = (timestamp - firstDataTime) / totalDuration;
|
|
hourLabels.add({
|
|
'time': timestamp,
|
|
'position': position,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
double yAxisWidth = 36.rpx;
|
|
double xAxisHeight = 40.rpx;
|
|
double chartHeight = 491.rpx;
|
|
double bottomPadding = 10.rpx;
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
setState(() {
|
|
selectedIndex = null;
|
|
_removeOverlay();
|
|
});
|
|
},
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
height: chartHeight,
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: yAxisWidth,
|
|
height: chartHeight + bottomPadding,
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
top: 0,
|
|
right: 6.rpx,
|
|
child: Text(
|
|
'秒',
|
|
style: TextStyle(
|
|
fontSize: 18.rpx,
|
|
color: themeController.currentColor.sc4,
|
|
),
|
|
),
|
|
),
|
|
...List.generate(yLabels.length, (index) {
|
|
double yLabelsAreaHeight =
|
|
chartHeight - (30.rpx + 18.rpx) - bottomPadding;
|
|
double y = (30.rpx + 18.rpx) +
|
|
index * (yLabelsAreaHeight / (yLabels.length - 1));
|
|
double textHeight = 18.rpx;
|
|
double topPos = y - textHeight / 2;
|
|
if (index == yLabels.length - 1) {
|
|
topPos -= bottomPadding / 2;
|
|
}
|
|
return Positioned(
|
|
right: 6.rpx,
|
|
top: topPos,
|
|
child: Text(
|
|
'${yLabels.reversed.toList()[index]}',
|
|
style: TextStyle(
|
|
fontSize: 18.rpx,
|
|
color: themeController.currentColor.sc4,
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: CustomPaint(
|
|
size: Size(double.infinity, chartHeight),
|
|
painter: _DotBarChartPainter(
|
|
data: widget.showLabel,
|
|
yMax: displayMax,
|
|
threshold: widget.threshold,
|
|
yLabelsCount: yLabels.length,
|
|
yAxisTopPadding: 30.rpx + 18.rpx,
|
|
horizontalPadding: 20.rpx,
|
|
selectedIndex: selectedIndex,
|
|
),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final double chartWidth =
|
|
constraints.maxWidth - 2 * 20.rpx;
|
|
final double xStep = widget.showLabel.length > 1
|
|
? chartWidth / (widget.showLabel.length - 1)
|
|
: 0;
|
|
final double drawableHeight =
|
|
chartHeight - (30.rpx + 18.rpx);
|
|
|
|
return Stack(
|
|
children:
|
|
widget.showLabel.asMap().entries.map((entry) {
|
|
int index = entry.key;
|
|
Map<String, dynamic> data = entry.value;
|
|
int times = data['times'] ?? 0;
|
|
double x = 20.rpx + index * xStep;
|
|
double y = (30.rpx + 18.rpx) +
|
|
drawableHeight * (1 - times / displayMax);
|
|
|
|
return Positioned(
|
|
left: x - 20.rpx,
|
|
top: y - 20.rpx,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
setState(() {
|
|
selectedIndex = index;
|
|
});
|
|
_showTooltip(context, data, Offset(x, y), y);
|
|
},
|
|
child: Container(
|
|
width: 40.rpx,
|
|
height: 40.rpx,
|
|
color: Colors.transparent,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.only(left: yAxisWidth),
|
|
child: SizedBox(
|
|
height: xAxisHeight,
|
|
child: CustomPaint(
|
|
size: Size(double.infinity, xAxisHeight),
|
|
painter: _XAxisPainter(
|
|
hourLabels: hourLabels,
|
|
textColor: themeController.currentColor.sc4,
|
|
fontSize: 18.rpx,
|
|
startTime: widget.startTime,
|
|
endTime: widget.endTime,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DotBarChartPainter extends CustomPainter {
|
|
final List<Map<String, dynamic>> data;
|
|
final int yMax;
|
|
final int threshold;
|
|
final int yLabelsCount;
|
|
final double yAxisTopPadding;
|
|
final double horizontalPadding;
|
|
final int? selectedIndex;
|
|
|
|
_DotBarChartPainter({
|
|
required this.data,
|
|
required this.yMax,
|
|
required this.threshold,
|
|
required this.yLabelsCount,
|
|
required this.yAxisTopPadding,
|
|
required this.horizontalPadding,
|
|
this.selectedIndex,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final double chartWidth = size.width - 2 * horizontalPadding;
|
|
final double xStep = data.length > 1 ? chartWidth / (data.length - 1) : 0;
|
|
final double chartHeight = size.height;
|
|
final double drawableHeight = chartHeight - yAxisTopPadding;
|
|
|
|
final Paint thresholdPaint = Paint()
|
|
..style = PaintingStyle.stroke
|
|
..color = stringToColor("#FF7159")
|
|
..strokeWidth = 1.rpx;
|
|
|
|
double thresholdY =
|
|
yAxisTopPadding + drawableHeight * (1 - threshold / yMax);
|
|
drawDashedLine(
|
|
canvas,
|
|
Offset(0, thresholdY),
|
|
Offset(size.width, thresholdY),
|
|
thresholdPaint,
|
|
8.rpx,
|
|
6.rpx,
|
|
);
|
|
|
|
final double dotRadius = 13.rpx;
|
|
for (int i = 0; i < data.length; i++) {
|
|
int times = data[i]['times'] ?? 0;
|
|
double x = horizontalPadding + i * xStep;
|
|
double y = yAxisTopPadding + drawableHeight * (1 - times / yMax);
|
|
|
|
Paint dotPaint = Paint()
|
|
..style = PaintingStyle.fill
|
|
..color = times >= threshold
|
|
? stringToColor("#FF7159")
|
|
: stringToColor("#00C1AA");
|
|
|
|
// Draw a larger circle for selected dot
|
|
if (i == selectedIndex) {
|
|
Paint borderPaint = Paint()
|
|
..style = PaintingStyle.stroke
|
|
..color = Colors.white
|
|
..strokeWidth = 3.rpx;
|
|
canvas.drawCircle(Offset(x, y), dotRadius + 1.rpx, borderPaint);
|
|
}
|
|
canvas.drawCircle(Offset(x, y), dotRadius, dotPaint);
|
|
}
|
|
|
|
final Paint solidLinePaint = Paint()
|
|
..color = Colors.grey.withOpacity(0.7)
|
|
..strokeWidth = 1.rpx
|
|
..style = PaintingStyle.stroke;
|
|
|
|
final Paint dashedLinePaint = Paint()
|
|
..color = Colors.grey.withOpacity(0.4)
|
|
..strokeWidth = 1.rpx
|
|
..style = PaintingStyle.stroke;
|
|
|
|
for (int i = 0; i < yLabelsCount; i++) {
|
|
double y = yAxisTopPadding + i * (drawableHeight / (yLabelsCount - 1));
|
|
if (i == yLabelsCount - 1) {
|
|
canvas.drawLine(Offset(0, y), Offset(size.width, y), solidLinePaint);
|
|
} else {
|
|
drawDashedLine(
|
|
canvas,
|
|
Offset(0, y),
|
|
Offset(size.width, y),
|
|
dashedLinePaint,
|
|
8.rpx,
|
|
6.rpx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void drawDashedLine(
|
|
Canvas canvas,
|
|
Offset start,
|
|
Offset end,
|
|
Paint paint,
|
|
double dashWidth,
|
|
double gapWidth,
|
|
) {
|
|
double dx = start.dx;
|
|
final double y = start.dy;
|
|
while (dx < end.dx) {
|
|
final double nextDx = (dx + dashWidth).clamp(start.dx, end.dx);
|
|
canvas.drawLine(Offset(dx, y), Offset(nextDx, y), paint);
|
|
dx = nextDx + gapWidth;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
|
}
|
|
|
|
class _XAxisPainter extends CustomPainter {
|
|
final List<Map<String, dynamic>> hourLabels;
|
|
final Color textColor;
|
|
final double fontSize;
|
|
final int startTime;
|
|
final int endTime;
|
|
|
|
_XAxisPainter({
|
|
required this.hourLabels,
|
|
required this.textColor,
|
|
required this.fontSize,
|
|
required this.startTime,
|
|
required this.endTime,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final textStyle = TextStyle(
|
|
color: textColor,
|
|
fontSize: fontSize,
|
|
);
|
|
final textPainter = TextPainter(
|
|
textDirection: ui.TextDirection.ltr,
|
|
textAlign: TextAlign.center,
|
|
);
|
|
|
|
// Draw start time (leftmost)
|
|
final startText = DateFormat('HH:mm')
|
|
.format(DateTime.fromMillisecondsSinceEpoch(startTime));
|
|
final startTextSpan = TextSpan(text: startText, style: textStyle);
|
|
textPainter.text = startTextSpan;
|
|
textPainter.layout();
|
|
textPainter.paint(canvas, Offset(0, 14.rpx));
|
|
|
|
// Draw end time (rightmost)
|
|
final endText = DateFormat('HH:mm')
|
|
.format(DateTime.fromMillisecondsSinceEpoch(endTime));
|
|
final endTextSpan = TextSpan(text: endText, style: textStyle);
|
|
textPainter.text = endTextSpan;
|
|
textPainter.layout();
|
|
textPainter.paint(canvas, Offset(size.width - textPainter.width, 14.rpx));
|
|
|
|
// Draw hourly labels in between
|
|
for (var label in hourLabels) {
|
|
final position = label['position'] * size.width;
|
|
final time = DateTime.fromMillisecondsSinceEpoch(label['time']);
|
|
final hourText = DateFormat('h').format(time);
|
|
final textSpan = TextSpan(text: hourText, style: textStyle);
|
|
textPainter.text = textSpan;
|
|
textPainter.layout();
|
|
final offset = Offset(
|
|
position - textPainter.width / 2,
|
|
14.rpx, // Padding from bottom
|
|
);
|
|
textPainter.paint(canvas, offset);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
|
}
|