Files
TelemetryMonitor/lib/ui/chart_widget.dart

231 lines
7.8 KiB
Dart

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../layout/chart_config.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;
final ValueNotifier<Offset?> _hover = ValueNotifier(null);
@override
void dispose() {
_hover.dispose();
super.dispose();
}
@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 ValueListenableBuilder<bool>(
valueListenable: scope.session.cursorEnabled,
builder: (_, cursorOn, __) {
return MouseRegion(
cursor: cursorOn
? SystemMouseCursors.precise
: MouseCursor.defer,
onHover: cursorOn
? (e) => _hover.value = e.localPosition
: null,
onExit: cursorOn ? (_) => _hover.value = null : null,
child: Listener(
onPointerSignal: (ev) =>
_onPointerSignal(ev, constraints),
child: GestureDetector(
onDoubleTap: () => _resetY(),
onHorizontalDragStart: (d) => _dragStartHandler(d),
onHorizontalDragUpdate: (d) =>
_dragUpdate(d, constraints),
onHorizontalDragEnd: (_) => _dragStart = null,
child: ListenableBuilder(
listenable: Listenable.merge([
scope.session.frameTick,
_hover,
]),
builder: (_, __) {
return CustomPaint(
painter: ChartPainter(
channel: widget.channel,
session: scope.session,
layout: scope.layout,
isDark: Theme.of(context).brightness ==
Brightness.dark,
hoverLocalPos:
cursorOn ? _hover.value : null,
),
size: Size.infinite,
);
},
),
),
),
);
},
);
}),
),
],
),
);
}
// Modifier conventions:
// Ctrl/Meta + wheel → Y-zoom (per-chart).
// Shift + wheel → X-zoom (shared).
// Bare wheel → bubble to the outer GridView scroll.
void _onPointerSignal(PointerSignalEvent ev, BoxConstraints c) {
if (ev is! PointerScrollEvent) return;
final ctrl = HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed;
final shift = HardwareKeyboard.instance.isShiftPressed;
if (!ctrl && !shift) return; // Let the grid scroll handle it.
GestureBinding.instance.pointerSignalResolver.register(ev, (e) {
if (e is! PointerScrollEvent) return;
if (ctrl) {
_zoomY(e, c);
} else {
_zoomX(e, c);
}
});
}
void _zoomY(PointerScrollEvent ev, BoxConstraints c) {
final scope = AppScope.of(context);
final factor = ev.scrollDelta.dy > 0 ? 1.1 : 1 / 1.1;
final cfg = scope.layout.configFor(widget.channel);
final localY = ev.localPosition.dy;
final h = c.maxHeight;
final v = cfg.yMax - (localY / h) * (cfg.yMax - cfg.yMin);
scope.layout.zoomY(
channel: widget.channel,
pivotValue: v,
factor: factor,
);
}
void _zoomX(PointerScrollEvent ev, BoxConstraints c) {
final scope = AppScope.of(context);
final factor = ev.scrollDelta.dy > 0 ? 1.1 : 1 / 1.1;
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),
),
],
),
);
},
);
}
}