First commit
This commit is contained in:
189
lib/ui/chart_widget.dart
Normal file
189
lib/ui/chart_widget.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user