Skip to main content

cognee_visualization/
lib.rs

1//! Interactive HTML knowledge-graph visualization for Cognee-Rust.
2//!
3//! This crate ports the Python `cognee_network_visualization` module to Rust.
4//! It renders all nodes + edges of a `GraphDBTrait` into a single self-contained
5//! HTML file that uses d3.js v7 for force-directed layout and Canvas rendering.
6//!
7//! # Quick start
8//!
9//! ```no_run
10//! use cognee_graph::GraphDBTrait;
11//! use cognee_visualization::visualize;
12//! use std::path::Path;
13//!
14//! # async fn example(graph_db: &dyn GraphDBTrait) -> Result<(), Box<dyn std::error::Error>> {
15//! // Write the visualization to a caller-specified file.
16//! let _path = visualize(graph_db, Some(Path::new("/tmp/graph.html"))).await?;
17//!
18//! // Or write it to `~/graph_visualization.html` (matches Python behavior).
19//! let _path = visualize(graph_db, None).await?;
20//! # Ok(()) }
21//! ```
22
23mod colors;
24mod error;
25mod html;
26mod paths;
27mod serialize;
28
29pub use error::VisualizationError;
30
31use std::path::{Path, PathBuf};
32
33use cognee_graph::GraphDBTrait;
34use tokio::fs;
35use tokio::io::AsyncWriteExt;
36use tracing::info;
37
38/// Generate an interactive HTML knowledge-graph visualization of the supplied
39/// graph database.
40///
41/// * `graph_db` — graph database from which all nodes + edges are fetched via
42///   `get_graph_data()`.
43/// * `output_path` — optional destination path. When `None`, the file is
44///   written to `~/graph_visualization.html` (or `%USERPROFILE%` on Windows),
45///   matching Python's `visualize_graph()`.
46///
47/// Returns the absolute path the file was written to.
48pub async fn visualize(
49    graph_db: &dyn GraphDBTrait,
50    output_path: Option<&Path>,
51) -> Result<PathBuf, VisualizationError> {
52    let html = render(graph_db).await?;
53
54    let dest: PathBuf = match output_path {
55        Some(p) => p.to_path_buf(),
56        None => paths::default_output_path()?,
57    };
58
59    if let Some(parent) = dest.parent()
60        && !parent.as_os_str().is_empty()
61    {
62        fs::create_dir_all(parent).await?;
63    }
64    let mut file = fs::File::create(&dest).await?;
65    file.write_all(html.as_bytes()).await?;
66    file.flush().await?;
67
68    info!(path = %dest.display(), "Graph visualization saved");
69    Ok(dest)
70}
71
72/// Render the HTML visualization string for the supplied graph database,
73/// without writing it anywhere.
74///
75/// Useful when the caller wants to stream the HTML over HTTP, embed it into a
76/// larger page, or post-process it before persisting.
77pub async fn render(graph_db: &dyn GraphDBTrait) -> Result<String, VisualizationError> {
78    let (nodes, edges) = graph_db.get_graph_data().await?;
79    let serialized = serialize::serialize_graph(nodes, edges);
80    html::build_html(&serialized, None)
81}
82
83/// Render a combined HTML visualization aggregating multiple `(user_label, graph_db)`
84/// pairs into one output.
85///
86/// Each pair's nodes are tagged with a `source_user` attribute carrying the
87/// supplied human-readable label so the d3 template can color-code by user.
88/// Mirrors Python's `aggregate_multi_user_graphs()` in
89/// [`cognee/modules/visualization/cognee_network_visualization.py:115-157`](https://github.com/topoteretes/cognee/blob/main/cognee/modules/visualization/cognee_network_visualization.py#L115-L157):
90///
91/// - Nodes are deduplicated by `str(node_id)` with **first-write-wins**
92///   semantics so iteration order across the supplied pairs determines the
93///   surviving node entry.
94/// - Edges are deduplicated by the `(source, target, relation)` tuple.
95/// - The `source_user` field is only populated when the inbound node does not
96///   already carry one (Python's `if not node_info.get("source_user")`).
97///
98/// An empty input produces a valid-but-empty HTML document.
99///
100/// `pairs` is a slice of `(user_label, graph_db)` tuples; the label is taken
101/// as an arbitrary `&str` to keep this crate decoupled from
102/// `cognee_models::User`. Callers should resolve the underlying user record
103/// and pass `user.email` (or the stringified id as a fallback) so the
104/// `userColors` palette key matches Python.
105pub async fn render_multi_user(
106    pairs: &[(String, std::sync::Arc<dyn GraphDBTrait>)],
107) -> Result<String, VisualizationError> {
108    use std::borrow::Cow;
109    use std::collections::{HashMap, HashSet};
110
111    // First-write-wins by stringified node id (mirror Python L142).
112    let mut all_nodes: HashMap<String, cognee_graph::GraphNode> = HashMap::new();
113    let mut node_order: Vec<String> = Vec::new();
114
115    // Edge dedupe by (source, target, relation) (mirror Python L150-155).
116    let mut all_edges: Vec<cognee_graph::EdgeData> = Vec::new();
117    let mut seen_edges: HashSet<(String, String, String)> = HashSet::new();
118
119    for (user_label, gdb) in pairs {
120        let (nodes, edges) = gdb.get_graph_data().await?;
121        for (node_id, mut node_info) in nodes {
122            let key = node_id.to_string();
123            if all_nodes.contains_key(&key) {
124                continue;
125            }
126            // Mirror Python's `if not node_info.get("source_user"): node_info["source_user"] = user_label`
127            // — preserve any pre-existing `source_user` on the node so the
128            // owning user's value (if already tagged) survives.
129            let needs_label = match node_info.get("source_user") {
130                Some(serde_json::Value::String(s)) if !s.is_empty() => false,
131                Some(serde_json::Value::Null) | None => true,
132                _ => false,
133            };
134            if needs_label {
135                node_info.insert(
136                    Cow::Borrowed("source_user"),
137                    serde_json::Value::String(user_label.clone()),
138                );
139            }
140            node_order.push(key.clone());
141            all_nodes.insert(key, (node_id, node_info));
142        }
143        for edge in edges {
144            let edge_key = (edge.0.to_string(), edge.1.to_string(), edge.2.clone());
145            if seen_edges.insert(edge_key) {
146                all_edges.push(edge);
147            }
148        }
149    }
150
151    let ordered_nodes: Vec<cognee_graph::GraphNode> = node_order
152        .into_iter()
153        .filter_map(|k| all_nodes.remove(&k))
154        .collect();
155
156    let serialized = serialize::serialize_graph(ordered_nodes, all_edges);
157    html::build_html(&serialized, None)
158}