189 lines
6.1 KiB
Dart
189 lines
6.1 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;
|
|
|
|
@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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
} |