From e6556401f36314701747d7557e707ac4ea206e0b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 2 Jul 2026 15:55:59 +0200 Subject: [PATCH] feat(graph): pan + zoom on the vault graph view (T-323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 3 +- lib/builtin/graph/src/graph_painter.dart | 40 +++++++++++--- lib/builtin/graph/src/graph_view.dart | 62 +++++++++++++++------- test/builtin/graph/graph_painter_test.dart | 37 +++++++++++++ test/builtin/graph/graph_view_test.dart | 44 +++++++++++++++ 5 files changed, 158 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a1e338..d9a08c54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. - **Vault graph view.** A force-directed link graph of the whole vault in the context panel — notes are nodes, wikilinks edges. Hover highlights a note's - neighbourhood; click opens it in the editor. (T-323) + neighbourhood; click opens it in the editor. Scroll to zoom, drag to pan. + (T-323) ### Changed diff --git a/lib/builtin/graph/src/graph_painter.dart b/lib/builtin/graph/src/graph_painter.dart index 1fb0d04c..5bfdc65a 100644 --- a/lib/builtin/graph/src/graph_painter.dart +++ b/lib/builtin/graph/src/graph_painter.dart @@ -22,9 +22,16 @@ class GraphViewport { GraphViewport(this.scale, this.dx, this.dy); final double scale, dx, dy; - factory GraphViewport.fit(Size canvas, Size layout) { - final scale = math.min(canvas.width / layout.width, canvas.height / layout.height); - return GraphViewport(scale, (canvas.width - layout.width * scale) / 2, (canvas.height - layout.height * scale) / 2); + /// Fits the solver's [layout] space into [canvas] (aspect-preserving), then + /// applies the user's [zoom] (about the canvas centre) and [pan]. At + /// `zoom: 1, pan: zero` this is the plain centered fit. + factory GraphViewport.fit(Size canvas, Size layout, {double zoom = 1, Offset pan = Offset.zero}) { + final scale = math.min(canvas.width / layout.width, canvas.height / layout.height) * zoom; + // Pin the layout centre to the canvas centre so zoom scales about it, then + // translate by the pan. + final dx = canvas.width / 2 + pan.dx - scale * layout.width / 2; + final dy = canvas.height / 2 + pan.dy - scale * layout.height / 2; + return GraphViewport(scale, dx, dy); } Offset toPixel(GraphPoint p) => Offset(dx + p.x * scale, dy + p.y * scale); @@ -39,9 +46,11 @@ String? hitTestNode( Size size, { Size layoutSize = const Size(800, 600), double hitRadius = 12, + double zoom = 1, + Offset pan = Offset.zero, }) { if (positions.isEmpty) return null; - final vp = GraphViewport.fit(size, layoutSize); + final vp = GraphViewport.fit(size, layoutSize, zoom: zoom, pan: pan); String? best; var bestD = hitRadius; for (final n in graph.nodes) { @@ -57,7 +66,15 @@ String? hitTestNode( } class GraphPainter extends CustomPainter { - GraphPainter({required this.graph, required this.positions, required this.tokens, this.highlight, this.layoutSize = const Size(800, 600)}); + GraphPainter({ + required this.graph, + required this.positions, + required this.tokens, + this.highlight, + this.layoutSize = const Size(800, 600), + this.zoom = 1, + this.pan = Offset.zero, + }); final VaultGraph graph; final Map positions; @@ -69,12 +86,16 @@ class GraphPainter extends CustomPainter { final Size layoutSize; + /// User pan/zoom over the base fit — kept in lockstep with [hitTestNode]. + final double zoom; + final Offset pan; + static const double nodeRadius = 5; @override void paint(ui.Canvas canvas, Size size) { if (graph.isEmpty || positions.isEmpty) return; - final vp = GraphViewport.fit(size, layoutSize); + final vp = GraphViewport.fit(size, layoutSize, zoom: zoom, pan: pan); Offset at(GraphPoint p) => vp.toPixel(p); bool lit(String id) => highlight == null || highlight!.contains(id); @@ -115,5 +136,10 @@ class GraphPainter extends CustomPainter { @override bool shouldRepaint(GraphPainter old) => - !identical(old.graph, graph) || !identical(old.positions, positions) || old.highlight != highlight || old.tokens != tokens; + !identical(old.graph, graph) || + !identical(old.positions, positions) || + old.highlight != highlight || + old.tokens != tokens || + old.zoom != zoom || + old.pan != pan; } diff --git a/lib/builtin/graph/src/graph_view.dart b/lib/builtin/graph/src/graph_view.dart index 505f6004..bd72c5db 100644 --- a/lib/builtin/graph/src/graph_view.dart +++ b/lib/builtin/graph/src/graph_view.dart @@ -7,6 +7,7 @@ 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 { @@ -24,8 +25,12 @@ class GraphView extends StatefulWidget { } class _GraphViewState extends State { + static const double _minZoom = 0.2, _maxZoom = 5; + late Map _pos; String? _hovered; + double _zoom = 1; + Offset _pan = Offset.zero; @override void initState() { @@ -47,6 +52,15 @@ class _GraphViewState extends State { 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 @@ -55,28 +69,36 @@ class _GraphViewState extends State { return LayoutBuilder( builder: (ctx, constraints) { final size = constraints.biggest; - String? hit(Offset local) => hitTestNode(widget.graph, _pos, local, size, layoutSize: widget.layoutSize); - return MouseRegion( - onHover: (e) { - final h = hit(e.localPosition); - if (h != _hovered) setState(() => _hovered = h); + 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); }, - onExit: (_) { - if (_hovered != null) setState(() => _hovered = null); - }, - child: GestureDetector( - onTapUp: (d) { - final h = hit(d.localPosition); - if (h != null) widget.onOpen?.call(h); + child: MouseRegion( + onHover: (e) { + final h = hit(e.localPosition); + if (h != _hovered) setState(() => _hovered = h); }, - child: CustomPaint( - size: size, - painter: GraphPainter( - graph: widget.graph, - positions: _pos, - tokens: tokens, - highlight: _hovered == null ? null : widget.graph.neighborhood(_hovered!), - layoutSize: widget.layoutSize, + 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, + ), ), ), ), diff --git a/test/builtin/graph/graph_painter_test.dart b/test/builtin/graph/graph_painter_test.dart index 507b6f68..58504936 100644 --- a/test/builtin/graph/graph_painter_test.dart +++ b/test/builtin/graph/graph_painter_test.dart @@ -69,6 +69,23 @@ void main() { expect(await tester.runAsync(() => hasInk(GraphPainter(graph: const VaultGraph([], []), positions: const {}, tokens: tokens))), isFalse); }); + testWidgets('paints under a user zoom + pan', (tester) async { + final tokens = await tokensFrom(tester); + final g = twoNodes(); + final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs); + expect(await tester.runAsync(() => hasInk(GraphPainter(graph: g, positions: pos, tokens: tokens, zoom: 2, pan: const Offset(15, -10)))), isTrue); + }); + + testWidgets('repaints when zoom or pan changes, not when identical', (tester) async { + final tokens = await tokensFrom(tester); + final g = twoNodes(); + final pos = ForceLayout.compute(['a.md', 'b.md'], g.edgePairs); + GraphPainter p({double zoom = 1, Offset pan = Offset.zero}) => GraphPainter(graph: g, positions: pos, tokens: tokens, zoom: zoom, pan: pan); + expect(p().shouldRepaint(p()), isFalse); + expect(p(zoom: 2).shouldRepaint(p()), isTrue); + expect(p(pan: const Offset(1, 0)).shouldRepaint(p()), isTrue); + }); + group('hitTestNode', () { test('finds the node under the point, null when far or empty', () { final g = VaultGraph.fromOutlinks({'a.md': const []}); @@ -79,5 +96,25 @@ void main() { expect(hitTestNode(g, pos, const Offset(20, 20), const Size(800, 600)), isNull); expect(hitTestNode(g, const {}, Offset.zero, const Size(800, 600)), isNull); }); + + test('pan shifts the hit location; zoom scales about the centre', () { + final g = VaultGraph.fromOutlinks({'a.md': const []}); + final pos = ForceLayout.compute(['a.md'], const []); // single node at (400,300) + // A pan moves the node by the same pixels — hit follows it, misses the old spot. + expect(hitTestNode(g, pos, const Offset(500, 300), const Size(800, 600), pan: const Offset(100, 0)), 'a.md'); + expect(hitTestNode(g, pos, const Offset(400, 300), const Size(800, 600), pan: const Offset(100, 0)), isNull); + // Zoom scales about the canvas centre, so a centre node stays under it. + expect(hitTestNode(g, pos, const Offset(400, 300), const Size(800, 600), zoom: 3), 'a.md'); + }); + }); + + group('GraphViewport.fit', () { + test('a centre point is pan-translated and zoom-invariant', () { + const canvas = Size(800, 600), layout = Size(800, 600); + final centre = (x: 400.0, y: 300.0); + expect(GraphViewport.fit(canvas, layout).toPixel(centre), const Offset(400, 300)); + expect(GraphViewport.fit(canvas, layout, zoom: 4).toPixel(centre), const Offset(400, 300)); + expect(GraphViewport.fit(canvas, layout, pan: const Offset(10, 20)).toPixel(centre), const Offset(410, 320)); + }); }); } diff --git a/test/builtin/graph/graph_view_test.dart b/test/builtin/graph/graph_view_test.dart index b6c0df58..d14d9809 100644 --- a/test/builtin/graph/graph_view_test.dart +++ b/test/builtin/graph/graph_view_test.dart @@ -1,5 +1,6 @@ import 'package:clide/builtin/graph/src/graph_view.dart'; import 'package:clide/src/graph/vault_graph.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -36,4 +37,47 @@ void main() { expect(find.byType(GraphView), findsOneWidget); await tester.tap(find.byType(GraphView)); // no node, no onOpen — must not throw }); + + testWidgets('dragging pans the graph; a reverse pan restores it', (tester) async { + String? opened; + final g = VaultGraph.fromOutlinks({'only.md': const []}); + await tester.pumpWidget( + anchoredHarness( + f, + SizedBox( + width: 400, + height: 400, + child: GraphView(graph: g, onOpen: (id) => opened = id), + ), + ), + ); + await tester.pump(); + final centre = tester.getCenter(find.byType(GraphView)); + // Pan right — the node leaves the centre, so a centre tap misses. (An equal, + // opposite drag cancels the same gesture slop, so the net returns to zero — + // robust to the exact slop the arena consumes.) + await tester.drag(find.byType(GraphView), const Offset(120, 0)); + await tester.pump(); + await tester.tapAt(centre); + expect(opened, isNull); + await tester.drag(find.byType(GraphView), const Offset(-120, 0)); + await tester.pump(); + await tester.tapAt(centre); + expect(opened, 'only.md'); + }); + + testWidgets('a scroll signal zooms without throwing', (tester) async { + final g = VaultGraph.fromOutlinks({ + 'a.md': const ['b.md'], + 'b.md': const [], + }); + await tester.pumpWidget(anchoredHarness(f, SizedBox(width: 400, height: 400, child: GraphView(graph: g)))); + await tester.pump(); + final centre = tester.getCenter(find.byType(GraphView)); + final pointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding(pointer.hover(centre)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, -120))); // zoom in + await tester.pump(); + expect(find.byType(GraphView), findsOneWidget); + }); }