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. }