From c529703b9fbc9455cbef50f7d49af1b2059d280e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 2 Jul 2026 08:34:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(graph):=20interactive=20GraphView=20?= =?UTF-8?q?=E2=80=94=20hover-highlight=20+=20click-to-open=20(T-323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphView lays a VaultGraph out with the force solver, paints it, and wires hover (light the hovered node's neighbourhood, dim the rest) + click (onOpen with the node's vault path). A shared GraphViewport keeps hit-testing aligned with paint. Solver + model + painter + interactive view now stand; the pql link-data wiring, filter, and MultitabPane/slot registration are the remaining "integrate into the app" work. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/builtin/graph/src/graph_painter.dart | 47 +++++++++++- lib/builtin/graph/src/graph_view.dart | 87 ++++++++++++++++++++++ test/builtin/graph/graph_painter_test.dart | 12 +++ test/builtin/graph/graph_view_test.dart | 39 ++++++++++ 4 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 lib/builtin/graph/src/graph_view.dart create mode 100644 test/builtin/graph/graph_view_test.dart diff --git a/lib/builtin/graph/src/graph_painter.dart b/lib/builtin/graph/src/graph_painter.dart index 932e22d0..1fb0d04c 100644 --- a/lib/builtin/graph/src/graph_painter.dart +++ b/lib/builtin/graph/src/graph_painter.dart @@ -15,6 +15,47 @@ import 'package:clide/src/graph/force_layout.dart'; import 'package:clide/src/graph/vault_graph.dart'; import 'package:flutter/widgets.dart'; +/// The aspect-preserving, centered fit of the solver's [layoutSize] space into a +/// canvas — shared by the painter and hit-testing so hover/click land exactly on +/// what's drawn. +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); + } + + Offset toPixel(GraphPoint p) => Offset(dx + p.x * scale, dy + p.y * scale); +} + +/// The node id nearest [local] within [hitRadius] px, or null — the inverse of +/// [GraphViewport], so it matches what [GraphPainter] drew. +String? hitTestNode( + VaultGraph graph, + Map positions, + Offset local, + Size size, { + Size layoutSize = const Size(800, 600), + double hitRadius = 12, +}) { + if (positions.isEmpty) return null; + final vp = GraphViewport.fit(size, layoutSize); + String? best; + var bestD = hitRadius; + for (final n in graph.nodes) { + final p = positions[n.id]; + if (p == null) continue; + final d = (vp.toPixel(p) - local).distance; + if (d <= bestD) { + bestD = d; + best = n.id; + } + } + return best; +} + class GraphPainter extends CustomPainter { GraphPainter({required this.graph, required this.positions, required this.tokens, this.highlight, this.layoutSize = const Size(800, 600)}); @@ -33,10 +74,8 @@ class GraphPainter extends CustomPainter { @override void paint(ui.Canvas canvas, Size size) { if (graph.isEmpty || positions.isEmpty) return; - final scale = math.min(size.width / layoutSize.width, size.height / layoutSize.height); - final dx = (size.width - layoutSize.width * scale) / 2; - final dy = (size.height - layoutSize.height * scale) / 2; - Offset at(GraphPoint p) => Offset(dx + p.x * scale, dy + p.y * scale); + final vp = GraphViewport.fit(size, layoutSize); + Offset at(GraphPoint p) => vp.toPixel(p); bool lit(String id) => highlight == null || highlight!.contains(id); final edge = Paint() diff --git a/lib/builtin/graph/src/graph_view.dart b/lib/builtin/graph/src/graph_view.dart new file mode 100644 index 00000000..505f6004 --- /dev/null +++ b/lib/builtin/graph/src/graph_view.dart @@ -0,0 +1,87 @@ +/// 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/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 createState() => _GraphViewState(); +} + +class _GraphViewState extends State { + late Map _pos; + String? _hovered; + + @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; + } + + @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); + return 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); + }, + child: CustomPaint( + size: size, + painter: GraphPainter( + graph: widget.graph, + positions: _pos, + tokens: tokens, + highlight: _hovered == null ? null : widget.graph.neighborhood(_hovered!), + layoutSize: widget.layoutSize, + ), + ), + ), + ); + }, + ); + } +} diff --git a/test/builtin/graph/graph_painter_test.dart b/test/builtin/graph/graph_painter_test.dart index e69bd056..507b6f68 100644 --- a/test/builtin/graph/graph_painter_test.dart +++ b/test/builtin/graph/graph_painter_test.dart @@ -68,4 +68,16 @@ void main() { final tokens = await tokensFrom(tester); expect(await tester.runAsync(() => hasInk(GraphPainter(graph: const VaultGraph([], []), positions: const {}, tokens: tokens))), isFalse); }); + + group('hitTestNode', () { + test('finds the node under the point, null when far or empty', () { + final g = VaultGraph.fromOutlinks({'a.md': const []}); + // A single node lays out at the layout centre (400,300); an 800×600 canvas + // over an 800×600 layout is scale 1, no offset → the node sits at (400,300). + final pos = ForceLayout.compute(['a.md'], const []); + expect(hitTestNode(g, pos, const Offset(400, 300), const Size(800, 600)), 'a.md'); + expect(hitTestNode(g, pos, const Offset(20, 20), const Size(800, 600)), isNull); + expect(hitTestNode(g, const {}, Offset.zero, const Size(800, 600)), isNull); + }); + }); } diff --git a/test/builtin/graph/graph_view_test.dart b/test/builtin/graph/graph_view_test.dart new file mode 100644 index 00000000..b6c0df58 --- /dev/null +++ b/test/builtin/graph/graph_view_test.dart @@ -0,0 +1,39 @@ +import 'package:clide/builtin/graph/src/graph_view.dart'; +import 'package:clide/src/graph/vault_graph.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + testWidgets('clicking a node opens it', (tester) async { + String? opened; + final g = VaultGraph.fromOutlinks({'only.md': const []}); + await tester.pumpWidget( + anchoredHarness( + f, + SizedBox( + width: 300, + height: 300, + child: GraphView(graph: g, layoutSize: const Size(300, 300), onOpen: (id) => opened = id), + ), + ), + ); + await tester.pump(); + // A single node lays out at the centre → the view's centre. + await tester.tap(find.byType(GraphView)); + expect(opened, 'only.md'); + }); + + testWidgets('renders an empty graph without error', (tester) async { + await tester.pumpWidget(anchoredHarness(f, const SizedBox(width: 200, height: 200, child: GraphView(graph: VaultGraph([], []))))); + await tester.pump(); + expect(find.byType(GraphView), findsOneWidget); + await tester.tap(find.byType(GraphView)); // no node, no onOpen — must not throw + }); +}