Folds a user zoom (scroll wheel, clamped) and pan (drag) transform into GraphViewport so the painter and hit-testing move in lockstep — hover and click keep landing on what's drawn. Zoom scales about the canvas centre; a fresh graph re-fits and drops the transform. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
110 lines
3.4 KiB
Dart
110 lines
3.4 KiB
Dart
/// The interactive vault-graph view (T-323): lays a [VaultGraph] out with the
|
|
/// force solver, paints it via [GraphPainter], and wires hover (highlight the
|
|
/// neighbourhood) + click (open the note). Pan/zoom + filtering layer on later.
|
|
library;
|
|
|
|
import 'package:clide/builtin/graph/src/graph_painter.dart';
|
|
import 'package:clide/src/graph/force_layout.dart';
|
|
import 'package:clide/src/graph/vault_graph.dart';
|
|
import 'package:clide/widgets/src/clide_settings.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
class GraphView extends StatefulWidget {
|
|
const GraphView({super.key, required this.graph, this.onOpen, this.layoutSize = const Size(800, 600)});
|
|
|
|
final VaultGraph graph;
|
|
|
|
/// Called with a node's id (its vault-relative path) when it's clicked.
|
|
final void Function(String nodeId)? onOpen;
|
|
|
|
final Size layoutSize;
|
|
|
|
@override
|
|
State<GraphView> createState() => _GraphViewState();
|
|
}
|
|
|
|
class _GraphViewState extends State<GraphView> {
|
|
static const double _minZoom = 0.2, _maxZoom = 5;
|
|
|
|
late Map<String, GraphPoint> _pos;
|
|
String? _hovered;
|
|
double _zoom = 1;
|
|
Offset _pan = Offset.zero;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_relayout();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(GraphView old) {
|
|
super.didUpdateWidget(old);
|
|
if (!identical(old.graph, widget.graph)) _relayout();
|
|
}
|
|
|
|
void _relayout() {
|
|
_pos = ForceLayout.compute(
|
|
[for (final n in widget.graph.nodes) n.id],
|
|
widget.graph.edgePairs,
|
|
width: widget.layoutSize.width,
|
|
height: widget.layoutSize.height,
|
|
);
|
|
_hovered = null;
|
|
// A fresh graph re-fits; drop any user pan/zoom.
|
|
_zoom = 1;
|
|
_pan = Offset.zero;
|
|
}
|
|
|
|
void _onScroll(PointerScrollEvent e) {
|
|
final factor = e.scrollDelta.dy < 0 ? 1.1 : 0.9;
|
|
final next = (_zoom * factor).clamp(_minZoom, _maxZoom);
|
|
if (next != _zoom) setState(() => _zoom = next);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = ClideSettings.theme.of(context).surface;
|
|
return LayoutBuilder(
|
|
builder: (ctx, constraints) {
|
|
final size = constraints.biggest;
|
|
String? hit(Offset local) => hitTestNode(widget.graph, _pos, local, size, layoutSize: widget.layoutSize, zoom: _zoom, pan: _pan);
|
|
return Listener(
|
|
onPointerSignal: (s) {
|
|
if (s is PointerScrollEvent) _onScroll(s);
|
|
},
|
|
child: MouseRegion(
|
|
onHover: (e) {
|
|
final h = hit(e.localPosition);
|
|
if (h != _hovered) setState(() => _hovered = h);
|
|
},
|
|
onExit: (_) {
|
|
if (_hovered != null) setState(() => _hovered = null);
|
|
},
|
|
child: GestureDetector(
|
|
onTapUp: (d) {
|
|
final h = hit(d.localPosition);
|
|
if (h != null) widget.onOpen?.call(h);
|
|
},
|
|
onPanUpdate: (d) => setState(() => _pan += d.delta),
|
|
child: CustomPaint(
|
|
size: size,
|
|
painter: GraphPainter(
|
|
graph: widget.graph,
|
|
positions: _pos,
|
|
tokens: tokens,
|
|
highlight: _hovered == null ? null : widget.graph.neighborhood(_hovered!),
|
|
layoutSize: widget.layoutSize,
|
|
zoom: _zoom,
|
|
pan: _pan,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|