Files
clide/lib/src/graph/vault_graph.dart
T
jpmschweitzerandClaude Opus 4.8 f859173b98 feat(graph): filter model — local-graph BFS, subgraph, GraphFilter (T-323)
Adds the pure filtering primitives the graph pane composes: VaultGraph
nodesWithin (depth-bounded BFS over undirected edges = the local graph
around a note) and subgraph (retain a node set + the edges between them),
plus GraphFilter, which combines depth-from-active with tag include/exclude
over a caller-supplied tag map. Flutter-free; runs under dart test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:26:59 +02:00

112 lines
3.7 KiB
Dart

/// The vault link graph (T-323): notes are nodes, wikilinks are edges.
///
/// Built from pql's per-file outlinks (`pql outlinks <file>`); 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<GraphNode> nodes;
final List<GraphEdge> 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<String, List<String>> 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 = <GraphEdge>[];
final seen = <String>{};
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<String> 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<String> nodesWithin(String root, int maxDepth) {
if (maxDepth < 0 || !nodes.any((n) => n.id == root)) return const {};
final adj = <String, Set<String>>{};
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 = <String>{};
for (final id in frontier) {
for (final nb in adj[id] ?? const <String>{}) {
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<String> 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;
}