/// The vault link graph (T-323): notes are nodes, wikilinks are edges. /// /// Built from pql's per-file outlinks (`pql outlinks `); the force solver /// ([ForceLayout]) positions it and the graph pane renders it. Pure data — no /// Flutter, no pql shell-out here (the pane feeds in the already-queried links), /// so it runs under `dart test`. library; class GraphNode { const GraphNode({required this.id, required this.label}); /// Vault-relative path — the stable identity + what a click opens. final String id; /// Display name (basename without extension). final String label; } class GraphEdge { const GraphEdge(this.from, this.to); final String from; final String to; } class VaultGraph { const VaultGraph(this.nodes, this.edges); final List nodes; final List edges; bool get isEmpty => nodes.isEmpty; /// Build from a file→outlinks map. Every key is a node; each outlink to a /// KNOWN file becomes one edge. Self-links and dangling links (to files not in /// the map) are dropped, and parallel edges are de-duplicated. factory VaultGraph.fromOutlinks(Map> outlinks) { final ids = outlinks.keys.toList(); final known = ids.toSet(); final nodes = [for (final id in ids) GraphNode(id: id, label: _label(id))]; final edges = []; final seen = {}; for (final entry in outlinks.entries) { for (final target in entry.value) { if (target == entry.key || !known.contains(target)) continue; if (seen.add('${entry.key}$target')) edges.add(GraphEdge(entry.key, target)); } } return VaultGraph(nodes, edges); } /// Node ids directly connected to [id] (including [id]) — the set a hover /// highlights and everything else dims against. Set neighborhood(String id) { final out = {id}; for (final e in edges) { if (e.from == id) out.add(e.to); if (e.to == id) out.add(e.from); } return out; } /// Node ids reachable from [root] within [maxDepth] hops over undirected edges /// (both link directions), including [root] at depth 0 — the "local graph" /// around a note. Empty when [root] isn't a node: the local graph of a /// non-node is nothing. Set nodesWithin(String root, int maxDepth) { if (maxDepth < 0 || !nodes.any((n) => n.id == root)) return const {}; final adj = >{}; for (final e in edges) { (adj[e.from] ??= {}).add(e.to); (adj[e.to] ??= {}).add(e.from); } final seen = {root}; var frontier = {root}; for (var d = 0; d < maxDepth && frontier.isNotEmpty; d++) { final next = {}; for (final id in frontier) { for (final nb in adj[id] ?? const {}) { if (seen.add(nb)) next.add(nb); } } frontier = next; } return seen; } /// A new graph of just the nodes whose id is in [keep], plus the edges between /// two kept nodes. Node labels are preserved. VaultGraph subgraph(Set keep) { final keptNodes = [ for (final n in nodes) if (keep.contains(n.id)) n, ]; final keptIds = {for (final n in keptNodes) n.id}; final keptEdges = [ for (final e in edges) if (keptIds.contains(e.from) && keptIds.contains(e.to)) e, ]; return VaultGraph(keptNodes, keptEdges); } /// The edge pairs as `(from, to)` id tuples — the shape [ForceLayout] consumes. List<(String, String)> get edgePairs => [for (final e in edges) (e.from, e.to)]; } String _label(String path) { final slash = path.lastIndexOf('/'); final base = slash >= 0 ? path.substring(slash + 1) : path; final dot = base.lastIndexOf('.'); return dot > 0 ? base.substring(0, dot) : base; }