Files
TelemetryMonitor/lib/ui/chart_widget.dart
2026-04-21 14:40:09 -03:00

189 lines
6.1 KiB
Dart

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../layout/chart_config.dart';
import '../session/view_state.dart';
import 'app_scope.dart';
import 'chart_painter.dart';
/// One chart cell: header + painted plot area. Listens to frameTick for
/// repaints, and translates pointer events into view-state and y-zoom edits.
class ChartWidget extends StatefulWidget {
const ChartWidget({super.key, required this.channel});
final int channel;
@override
State<ChartWidget> createState() => _ChartWidgetState();
}
class _ChartWidgetState extends State<ChartWidget> {
Offset? _dragStart;
int? _dragStartUs;
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return Container(
decoration: BoxDecoration(
border:
Border.all(color: Theme.of(context).colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(channel: widget.channel),
Expanded(
child: LayoutBuilder(builder: (context, constraints) {
return Listener(
onPointerSignal: (ev) =>
_onPointerSignal(ev, constraints),
child: GestureDetector(
onDoubleTap: () => _resetY(),
onHorizontalDragStart: (d) => _dragStartHandler(d),
onHorizontalDragUpdate: (d) =>
_dragUpdate(d, constraints),
onHorizontalDragEnd: (_) => _dragStart = null,
child: ValueListenableBuilder<int>(
valueListenable: scope.session.frameTick,
builder: (_, __, ___) {
return CustomPaint(
painter: ChartPainter(
channel: widget.channel,
session: scope.session,
layout: scope.layout,
),
size: Size.infinite,
);
},
),
),
);
}),
),
],
),
);
}
void _onPointerSignal(PointerSignalEvent ev, BoxConstraints c) {
if (ev is! PointerScrollEvent) return;
final scope = AppScope.of(context);
final ctrl = HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed;
final factor = ev.scrollDelta.dy > 0 ? 1.1 : 1 / 1.1;
if (ctrl) {
// Y-zoom for this chart only.
final cfg = scope.layout.configFor(widget.channel);
final localY = ev.localPosition.dy;
// Map local Y back to data value (top = yMax, bottom = yMin).
final h = c.maxHeight;
final v =
cfg.yMax - (localY / h) * (cfg.yMax - cfg.yMin);
scope.layout.zoomY(
channel: widget.channel,
pivotValue: v,
factor: factor,
);
} else {
// Shared X-zoom.
final newest = scope.session.packets.newest;
final oldestUs =
scope.session.packets.oldest?.timestampUs.toInt() ?? 0;
final newestUs = newest?.timestampUs.toInt() ??
DateTime.now().microsecondsSinceEpoch;
final win = scope.session.viewState.currentWindow(
nowUs: newestUs,
oldestUs: oldestUs,
newestUs: newestUs,
);
final localX = ev.localPosition.dx;
final w = c.maxWidth;
final pivotUs = win.startUs +
((localX / w) * (win.endUs - win.startUs)).round();
final maxWindow = Duration(
microseconds: (newestUs - oldestUs).clamp(
const Duration(milliseconds: 10).inMicroseconds,
1 << 62,
),
);
scope.session.viewState.zoomAt(
pivotUs: pivotUs,
factor: factor,
maxWindow: maxWindow,
oldestUs: oldestUs,
newestUs: newestUs,
);
}
}
void _dragStartHandler(DragStartDetails d) {
final scope = AppScope.of(context);
_dragStart = d.localPosition;
final newest = scope.session.packets.newest;
final newestUs = newest?.timestampUs.toInt() ??
DateTime.now().microsecondsSinceEpoch;
_dragStartUs = newestUs;
}
void _dragUpdate(DragUpdateDetails d, BoxConstraints c) {
final scope = AppScope.of(context);
if (_dragStart == null || _dragStartUs == null) return;
final dxPx = d.localPosition.dx - _dragStart!.dx;
final widthUs = scope.session.viewState.windowWidth.inMicroseconds;
final usPerPx = widthUs / c.maxWidth;
final shiftUs = (-dxPx * usPerPx).round();
final newest = scope.session.packets.newest;
final oldestUs =
scope.session.packets.oldest?.timestampUs.toInt() ?? 0;
final newestUs = newest?.timestampUs.toInt() ?? _dragStartUs!;
scope.session.viewState.panBy(
Duration(microseconds: shiftUs),
oldestUs: oldestUs,
newestUs: newestUs,
);
_dragStart = d.localPosition;
}
void _resetY() {
final scope = AppScope.of(context);
scope.layout.setYMode(widget.channel, YMode.auto);
}
}
class _Header extends StatelessWidget {
const _Header({required this.channel});
final int channel;
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return ListenableBuilder(
listenable: scope.layout,
builder: (_, __) {
final cfg = scope.layout.configFor(channel);
final modeTag = switch (cfg.yMode) {
YMode.auto => 'auto',
YMode.fixed => 'fixed',
YMode.userZoomed => 'user',
};
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Row(
children: [
Text('CH$channel · ${cfg.name}',
style: const TextStyle(
fontWeight: FontWeight.w500, fontSize: 12)),
const Spacer(),
Text(
'$modeTag · y: ${cfg.yMin.toStringAsFixed(2)}'
'${cfg.yMax.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
],
),
);
},
);
}
}