285 lines
8.7 KiB
Dart
285 lines
8.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../layout/chart_config.dart';
|
|
import '../layout/layout_controller.dart';
|
|
import '../session/session_controller.dart';
|
|
|
|
/// Stateless per-frame painter. Reads everything fresh from the controllers
|
|
/// each `paint()` — no internal state.
|
|
class ChartPainter extends CustomPainter {
|
|
ChartPainter({
|
|
required this.channel,
|
|
required this.session,
|
|
required this.layout,
|
|
required this.isDark,
|
|
this.hoverLocalPos,
|
|
});
|
|
|
|
final int channel;
|
|
final SessionController session;
|
|
final LayoutController layout;
|
|
final bool isDark;
|
|
final Offset? hoverLocalPos;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final padLeft = 32.0, padRight = 4.0, padTop = 4.0, padBottom = 16.0;
|
|
final plotW = size.width - padLeft - padRight;
|
|
final plotH = size.height - padTop - padBottom;
|
|
if (plotW <= 0 || plotH <= 0) return;
|
|
|
|
// Theme-dependent palette.
|
|
final bgColor = isDark ? const Color(0xFF1E1F22) : const Color(0xFFF7F7F4);
|
|
final lineColor =
|
|
isDark ? const Color(0xFF5EA8FF) : const Color(0xFF185FA5);
|
|
final hatchColor =
|
|
isDark ? const Color(0x66D17050) : const Color(0x55993C1D);
|
|
final markerColor =
|
|
isDark ? const Color(0xCCD17050) : const Color(0xAA993C1D);
|
|
final cursorColor =
|
|
isDark ? const Color(0xCCBBBBBB) : const Color(0xAA444444);
|
|
|
|
// Background.
|
|
final bgPaint = Paint()..color = bgColor;
|
|
canvas.drawRect(
|
|
Rect.fromLTWH(padLeft, padTop, plotW, plotH),
|
|
bgPaint,
|
|
);
|
|
|
|
// Compute window.
|
|
final newest = session.packets.newest;
|
|
final oldestUs = session.packets.oldest?.timestampUs.toInt() ?? 0;
|
|
final newestUs = newest?.timestampUs.toInt() ??
|
|
DateTime.now().microsecondsSinceEpoch;
|
|
final win = session.viewState.currentWindow(
|
|
nowUs: newestUs,
|
|
oldestUs: oldestUs,
|
|
newestUs: newestUs,
|
|
);
|
|
|
|
// Decimate.
|
|
final cols = session.decimator.decimate(
|
|
channel: channel,
|
|
buffer: session.packets,
|
|
startUs: win.startUs,
|
|
endUs: win.endUs,
|
|
pixelWidth: plotW.round(),
|
|
);
|
|
final gaps = session.decimator.computeGaps(
|
|
channel: channel,
|
|
buffer: session.packets,
|
|
startUs: win.startUs,
|
|
endUs: win.endUs,
|
|
pixelWidth: plotW.round(),
|
|
);
|
|
|
|
// Y range.
|
|
final cfg = layout.configFor(channel);
|
|
var yMin = cfg.yMin, yMax = cfg.yMax;
|
|
if (cfg.yMode == YMode.auto) {
|
|
double lo = double.infinity, hi = double.negativeInfinity;
|
|
for (final c in cols) {
|
|
if (!c.hasData) continue;
|
|
if (c.min < lo) lo = c.min;
|
|
if (c.max > hi) hi = c.max;
|
|
}
|
|
if (lo.isFinite && hi.isFinite && hi > lo) {
|
|
yMin = lo;
|
|
yMax = hi;
|
|
}
|
|
}
|
|
final ySpan = (yMax - yMin).abs() < 1e-12 ? 1.0 : (yMax - yMin);
|
|
|
|
double xForCol(int col) => padLeft + col + 0.5;
|
|
double yForVal(double v) =>
|
|
padTop + plotH * (1 - (v - yMin) / ySpan);
|
|
|
|
// Everything that draws into the plot area must stay within it; a
|
|
// Y-zoom can push line segments outside [padTop, padTop+plotH].
|
|
canvas.save();
|
|
canvas.clipRect(Rect.fromLTWH(padLeft, padTop, plotW, plotH));
|
|
|
|
// Hatched gap rectangles.
|
|
final hatchPaint = Paint()
|
|
..color = hatchColor
|
|
..style = PaintingStyle.fill;
|
|
for (final g in gaps.hatchedRanges) {
|
|
final x0 = xForCol(g.startPx);
|
|
final x1 = xForCol(g.endPx + 1);
|
|
canvas.drawRect(
|
|
Rect.fromLTRB(x0, padTop, x1, padTop + plotH),
|
|
hatchPaint,
|
|
);
|
|
}
|
|
|
|
// Polyline.
|
|
final linePaint = Paint()
|
|
..color = lineColor
|
|
..strokeWidth = 1.2
|
|
..style = PaintingStyle.stroke;
|
|
final path = Path();
|
|
bool started = false;
|
|
for (var i = 0; i < cols.length; i++) {
|
|
final c = cols[i];
|
|
// Real missing-data gap: break the polyline.
|
|
if (c.isGap) {
|
|
started = false;
|
|
continue;
|
|
}
|
|
// Empty column (no packet fell here — sub-packet-period zoom). Let the
|
|
// path interpolate across.
|
|
if (!c.hasData) continue;
|
|
final x = xForCol(i);
|
|
final yMinPx = yForVal(c.max); // Higher value = smaller y.
|
|
final yMaxPx = yForVal(c.min);
|
|
if (!started) {
|
|
path.moveTo(x, (yMinPx + yMaxPx) / 2);
|
|
started = true;
|
|
} else {
|
|
path.lineTo(x, (yMinPx + yMaxPx) / 2);
|
|
}
|
|
// Min/max vertical span to surface intra-column variation.
|
|
if ((yMaxPx - yMinPx).abs() > 0.5) {
|
|
canvas.drawLine(Offset(x, yMinPx), Offset(x, yMaxPx), linePaint);
|
|
}
|
|
}
|
|
canvas.drawPath(path, linePaint);
|
|
|
|
// Sub-pixel marker lines.
|
|
final markerPaint = Paint()
|
|
..color = markerColor
|
|
..strokeWidth = 1;
|
|
for (final px in gaps.markerPixels) {
|
|
final x = xForCol(px);
|
|
canvas.drawLine(
|
|
Offset(x, padTop),
|
|
Offset(x, padTop + plotH),
|
|
markerPaint,
|
|
);
|
|
}
|
|
|
|
// Cursor crosshair + data dot (still inside the plot clip).
|
|
int? hoverCol;
|
|
double? hoverY;
|
|
int? hoverTsUs;
|
|
if (hoverLocalPos != null) {
|
|
final hx = hoverLocalPos!.dx;
|
|
final hy = hoverLocalPos!.dy;
|
|
if (hx >= padLeft &&
|
|
hx <= padLeft + plotW &&
|
|
hy >= padTop &&
|
|
hy <= padTop + plotH) {
|
|
final col = (hx - padLeft).floor().clamp(0, cols.length - 1);
|
|
hoverCol = col;
|
|
hoverTsUs = win.startUs +
|
|
(((col + 0.5) / plotW) * (win.endUs - win.startUs)).round();
|
|
final cursorPaint = Paint()
|
|
..color = cursorColor
|
|
..strokeWidth = 1;
|
|
canvas.drawLine(
|
|
Offset(xForCol(col), padTop),
|
|
Offset(xForCol(col), padTop + plotH),
|
|
cursorPaint,
|
|
);
|
|
if (cols[col].hasData) {
|
|
hoverY = (cols[col].min + cols[col].max) / 2;
|
|
final yPx = yForVal(hoverY);
|
|
canvas.drawLine(
|
|
Offset(padLeft, yPx),
|
|
Offset(padLeft + plotW, yPx),
|
|
cursorPaint,
|
|
);
|
|
canvas.drawCircle(
|
|
Offset(xForCol(col), yPx),
|
|
3,
|
|
Paint()..color = lineColor,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
canvas.restore();
|
|
|
|
// Cursor readout box (outside the clip so it can overlay labels).
|
|
if (hoverCol != null) {
|
|
final tStr = hoverTsUs != null ? _fmtTime(hoverTsUs) : '—';
|
|
final yStr = hoverY != null ? hoverY.toStringAsFixed(3) : '—';
|
|
_drawReadout(canvas, 'x=$tStr y=$yStr',
|
|
Offset(padLeft + 4, padTop + 2));
|
|
}
|
|
|
|
// Axes labels.
|
|
_drawText(canvas, '${yMax.toStringAsFixed(2)}',
|
|
Offset(padLeft - 4, padTop), align: TextAlign.right);
|
|
_drawText(canvas, '${yMin.toStringAsFixed(2)}',
|
|
Offset(padLeft - 4, padTop + plotH - 12), align: TextAlign.right);
|
|
_drawText(canvas, _fmtTime(win.startUs),
|
|
Offset(padLeft, size.height - 14));
|
|
_drawText(canvas, _fmtTime(win.endUs),
|
|
Offset(size.width - padRight, size.height - 14),
|
|
align: TextAlign.right);
|
|
}
|
|
|
|
void _drawReadout(Canvas c, String s, Offset where) {
|
|
final textColor =
|
|
isDark ? const Color(0xFFEEEEEE) : const Color(0xFF222222);
|
|
final bgColor =
|
|
isDark ? const Color(0xCC2A2B2E) : const Color(0xCCFFFFFF);
|
|
final borderColor =
|
|
isDark ? const Color(0x66FFFFFF) : const Color(0x44000000);
|
|
final tp = TextPainter(
|
|
text: TextSpan(
|
|
text: s,
|
|
style: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 10,
|
|
color: textColor,
|
|
),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
)..layout();
|
|
final bgRect = Rect.fromLTWH(
|
|
where.dx,
|
|
where.dy,
|
|
tp.width + 8,
|
|
tp.height + 4,
|
|
);
|
|
c.drawRRect(
|
|
RRect.fromRectAndRadius(bgRect, const Radius.circular(3)),
|
|
Paint()..color = bgColor,
|
|
);
|
|
c.drawRRect(
|
|
RRect.fromRectAndRadius(bgRect, const Radius.circular(3)),
|
|
Paint()
|
|
..color = borderColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 0.5,
|
|
);
|
|
tp.paint(c, Offset(where.dx + 4, where.dy + 2));
|
|
}
|
|
|
|
void _drawText(Canvas c, String s, Offset where,
|
|
{TextAlign align = TextAlign.left}) {
|
|
final tp = TextPainter(
|
|
text: TextSpan(
|
|
text: s,
|
|
style: const TextStyle(fontSize: 9, color: Colors.grey),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
textAlign: align,
|
|
)..layout();
|
|
final dx = align == TextAlign.right ? where.dx - tp.width : where.dx;
|
|
tp.paint(c, Offset(dx, where.dy));
|
|
}
|
|
|
|
String _fmtTime(int us) {
|
|
final d = DateTime.fromMicrosecondsSinceEpoch(us);
|
|
return '${d.hour.toString().padLeft(2, '0')}:'
|
|
'${d.minute.toString().padLeft(2, '0')}:'
|
|
'${d.second.toString().padLeft(2, '0')}.'
|
|
'${d.millisecond.toString().padLeft(3, '0')}';
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(ChartPainter old) => true; // Per-frame repaint.
|
|
} |