Skip to main content

graphify_export/
svg.rs

1//! Static SVG export.
2
3use std::collections::HashMap;
4use std::f64::consts::PI;
5use std::fmt::Write;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use graphify_core::graph::KnowledgeGraph;
10use tracing::info;
11
12const COMMUNITY_COLORS: &[&str] = &[
13    "#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F", "#EDC948", "#B07AA1", "#FF9DA7",
14    "#9C755F", "#BAB0AC",
15];
16
17const BG_COLOR: &str = "#0f0f1a";
18const EDGE_COLOR: &str = "#3a3a5a";
19const LABEL_COLOR: &str = "#ccc";
20const FALLBACK_COLOR: &str = "#888888";
21const TEXT_COLOR: &str = "#888";
22
23const SVG_WIDTH: f64 = 1200.0;
24const SVG_HEIGHT: f64 = 900.0;
25const NODE_RADIUS: f64 = 6.0;
26const MARGIN: f64 = 60.0;
27
28/// Export a simple static SVG with circular layout.
29pub fn export_svg(
30    graph: &KnowledgeGraph,
31    communities: &HashMap<usize, Vec<String>>,
32    output_dir: &Path,
33) -> anyhow::Result<PathBuf> {
34    let nodes = graph.nodes();
35    let edges = graph.edges();
36
37    fs::create_dir_all(output_dir)?;
38    let path = output_dir.join("graph.svg");
39
40    if nodes.is_empty() {
41        let mut svg = String::new();
42        write!(
43            svg,
44            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\">",
45            SVG_WIDTH, SVG_HEIGHT
46        )
47        .unwrap();
48        write!(
49            svg,
50            "<rect width=\"100%\" height=\"100%\" fill=\"{}\"/>",
51            BG_COLOR
52        )
53        .unwrap();
54        write!(
55            svg,
56            "<text x=\"50%\" y=\"50%\" fill=\"{}\" text-anchor=\"middle\" font-family=\"sans-serif\">Empty graph</text>",
57            TEXT_COLOR
58        )
59        .unwrap();
60        svg.push_str("</svg>");
61        fs::write(&path, &svg)?;
62        return Ok(path);
63    }
64
65    // Reverse map: node_id → community_id
66    let mut node_community: HashMap<&str, usize> = HashMap::new();
67    for (&cid, members) in communities {
68        for nid in members {
69            node_community.insert(nid.as_str(), cid);
70        }
71    }
72
73    // Assign positions in a circle
74    let n = nodes.len();
75    let cx = SVG_WIDTH / 2.0;
76    let cy = SVG_HEIGHT / 2.0;
77    let radius = (SVG_WIDTH / 2.0 - MARGIN).min(SVG_HEIGHT / 2.0 - MARGIN);
78
79    let mut positions: HashMap<&str, (f64, f64)> = HashMap::new();
80    for (i, node) in nodes.iter().enumerate() {
81        let angle = 2.0 * PI * i as f64 / n as f64 - PI / 2.0;
82        let x = cx + radius * angle.cos();
83        let y = cy + radius * angle.sin();
84        positions.insert(node.id.as_str(), (x, y));
85    }
86
87    let mut svg = String::with_capacity(4096);
88    writeln!(
89        svg,
90        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\">",
91        SVG_WIDTH, SVG_HEIGHT, SVG_WIDTH, SVG_HEIGHT
92    )
93    .unwrap();
94    writeln!(
95        svg,
96        "<rect width=\"100%\" height=\"100%\" fill=\"{}\"/>",
97        BG_COLOR
98    )
99    .unwrap();
100
101    // Edges
102    for edge in &edges {
103        if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
104            positions.get(edge.source.as_str()),
105            positions.get(edge.target.as_str()),
106        ) {
107            writeln!(
108                svg,
109                "<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" stroke=\"{}\" stroke-width=\"0.5\" stroke-opacity=\"0.6\"/>",
110                x1, y1, x2, y2, EDGE_COLOR
111            )
112            .unwrap();
113        }
114    }
115
116    // Nodes
117    for node in &nodes {
118        if let Some(&(x, y)) = positions.get(node.id.as_str()) {
119            let cid = node
120                .community
121                .or_else(|| node_community.get(node.id.as_str()).copied());
122            let color = cid
123                .map(|c| COMMUNITY_COLORS[c % COMMUNITY_COLORS.len()])
124                .unwrap_or(FALLBACK_COLOR);
125            writeln!(
126                svg,
127                "<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"{}\" fill=\"{}\" opacity=\"0.85\"><title>{}</title></circle>",
128                x, y, NODE_RADIUS, color, svg_escape(&node.label)
129            )
130            .unwrap();
131        }
132    }
133
134    // Labels for small graphs
135    if n <= 50 {
136        for node in &nodes {
137            if let Some(&(x, y)) = positions.get(node.id.as_str()) {
138                writeln!(
139                    svg,
140                    "<text x=\"{:.1}\" y=\"{:.1}\" fill=\"{}\" font-size=\"9\" font-family=\"sans-serif\" text-anchor=\"middle\">{}</text>",
141                    x,
142                    y - NODE_RADIUS - 3.0,
143                    LABEL_COLOR,
144                    svg_escape(&node.label)
145                )
146                .unwrap();
147            }
148        }
149    }
150
151    svg.push_str("</svg>\n");
152
153    fs::write(&path, &svg)?;
154    info!(path = %path.display(), "exported SVG");
155    Ok(path)
156}
157
158fn svg_escape(s: &str) -> String {
159    s.replace('&', "&amp;")
160        .replace('<', "&lt;")
161        .replace('>', "&gt;")
162        .replace('"', "&quot;")
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use graphify_core::confidence::Confidence;
169    use graphify_core::graph::KnowledgeGraph;
170    use graphify_core::model::{GraphEdge, GraphNode, NodeType};
171
172    fn sample_graph() -> KnowledgeGraph {
173        let mut kg = KnowledgeGraph::new();
174        kg.add_node(GraphNode {
175            id: "a".into(),
176            label: "A".into(),
177            source_file: "test.rs".into(),
178            source_location: None,
179            node_type: NodeType::Class,
180            community: Some(0),
181            extra: HashMap::new(),
182        })
183        .unwrap();
184        kg.add_node(GraphNode {
185            id: "b".into(),
186            label: "B".into(),
187            source_file: "test.rs".into(),
188            source_location: None,
189            node_type: NodeType::Function,
190            community: Some(1),
191            extra: HashMap::new(),
192        })
193        .unwrap();
194        kg.add_edge(GraphEdge {
195            source: "a".into(),
196            target: "b".into(),
197            relation: "calls".into(),
198            confidence: Confidence::Extracted,
199            confidence_score: 1.0,
200            source_file: "test.rs".into(),
201            source_location: None,
202            weight: 1.0,
203            extra: HashMap::new(),
204        })
205        .unwrap();
206        kg
207    }
208
209    #[test]
210    fn export_svg_creates_file() {
211        let dir = tempfile::tempdir().unwrap();
212        let kg = sample_graph();
213        let communities: HashMap<usize, Vec<String>> =
214            [(0, vec!["a".into()]), (1, vec!["b".into()])].into();
215
216        let path = export_svg(&kg, &communities, dir.path()).unwrap();
217        assert!(path.exists());
218
219        let content = std::fs::read_to_string(&path).unwrap();
220        assert!(content.contains("<svg"));
221        assert!(content.contains("<circle"));
222        assert!(content.contains("<line"));
223    }
224
225    #[test]
226    fn export_svg_empty_graph() {
227        let dir = tempfile::tempdir().unwrap();
228        let kg = KnowledgeGraph::new();
229        let communities = HashMap::new();
230
231        let path = export_svg(&kg, &communities, dir.path()).unwrap();
232        assert!(path.exists());
233
234        let content = std::fs::read_to_string(&path).unwrap();
235        assert!(content.contains("Empty graph"));
236    }
237}