231 lines
7.8 KiB
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
} |