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=\"{SVG_WIDTH}\" height=\"{SVG_HEIGHT}\">"
45        )?;
46        write!(
47            svg,
48            "<rect width=\"100%\" height=\"100%\" fill=\"{BG_COLOR}\"/>"
49        )?;
50        write!(
51            svg,
52            "<text x=\"50%\" y=\"50%\" fill=\"{TEXT_COLOR}\" text-anchor=\"middle\" font-family=\"sans-serif\">Empty graph</text>"
53        )?;
54        svg.push_str("</svg>");
55        fs::write(&path, &svg)?;
56        return Ok(path);
57    }
58
59    let mut node_community: HashMap<&str, usize> = HashMap::new();
60    for (&cid, members) in communities {
61        for nid in members {
62            node_community.insert(nid.as_str(), cid);
63        }
64    }
65
66    let n = nodes.len();
67    let cx = SVG_WIDTH / 2.0;
68    let cy = SVG_HEIGHT / 2.0;
69    let radius = (SVG_WIDTH / 2.0 - MARGIN).min(SVG_HEIGHT / 2.0 - MARGIN);
70
71    let mut positions: HashMap<&str, (f64, f64)> = HashMap::new();
72    for (i, node) in nodes.iter().enumerate() {
73        let angle = 2.0 * PI * i as f64 / n as f64 - PI / 2.0;
74        let x = cx + radius * angle.cos();
75        let y = cy + radius * angle.sin();
76        positions.insert(node.id.as_str(), (x, y));
77    }
78
79    let mut svg = String::with_capacity(4096);
80    writeln!(
81        svg,
82        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{SVG_WIDTH}\" height=\"{SVG_HEIGHT}\" viewBox=\"0 0 {SVG_WIDTH} {SVG_HEIGHT}\">"
83    )?;
84    writeln!(
85        svg,
86        "<rect width=\"100%\" height=\"100%\" fill=\"{BG_COLOR}\"/>"
87    )?;
88
89    for edge in &edges {
90        if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
91            positions.get(edge.source.as_str()),
92            positions.get(edge.target.as_str()),
93        ) {
94            writeln!(
95                svg,
96                "<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" stroke=\"{EDGE_COLOR}\" stroke-width=\"0.5\" stroke-opacity=\"0.6\"/>"
97            )?;
98        }
99    }
100
101    for node in &nodes {
102        if let Some(&(x, y)) = positions.get(node.id.as_str()) {
103            let cid = node
104                .community
105                .or_else(|| node_community.get(node.id.as_str()).copied());
106            let color = cid.map_or(FALLBACK_COLOR, |c| {
107                COMMUNITY_COLORS[c % COMMUNITY_COLORS.len()]
108            });
109            writeln!(
110                svg,
111                "<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"{}\" fill=\"{}\" opacity=\"0.85\"><title>{}</title></circle>",
112                x,
113                y,
114                NODE_RADIUS,
115                color,
116                svg_escape(&node.label)
117            )?;
118        }
119    }
120
121    if n <= 50 {
122        for node in &nodes {
123            if let Some(&(x, y)) = positions.get(node.id.as_str()) {
124                writeln!(
125                    svg,
126                    "<text x=\"{:.1}\" y=\"{:.1}\" fill=\"{}\" font-size=\"9\" font-family=\"sans-serif\" text-anchor=\"middle\">{}</text>",
127                    x,
128                    y - NODE_RADIUS - 3.0,
129                    LABEL_COLOR,
130                    svg_escape(&node.label)
131                )?;
132            }
133        }
134    }
135
136    svg.push_str("</svg>\n");
137
138    fs::write(&path, &svg)?;
139    info!(path = %path.display(), "exported SVG");
140    Ok(path)
141}
142
143fn svg_escape(s: &str) -> String {
144    s.replace('&', "&amp;")
145        .replace('<', "&lt;")
146        .replace('>', "&gt;")
147        .replace('"', "&quot;")
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use graphify_core::confidence::Confidence;
154    use graphify_core::graph::KnowledgeGraph;
155    use graphify_core::model::{GraphEdge, GraphNode, NodeType};
156
157    fn sample_graph() -> KnowledgeGraph {
158        let mut kg = KnowledgeGraph::new();
159        kg.add_node(GraphNode {
160            id: "a".into(),
161            label: "A".into(),
162            source_file: "test.rs".into(),
163            source_location: None,
164            node_type: NodeType::Class,
165            community: Some(0),
166            extra: HashMap::new(),
167        })
168        .unwrap();
169        kg.add_node(GraphNode {
170            id: "b".into(),
171            label: "B".into(),
172            source_file: "test.rs".into(),
173            source_location: None,
174            node_type: NodeType::Function,
175            community: Some(1),
176            extra: HashMap::new(),
177        })
178        .unwrap();
179        kg.add_edge(GraphEdge {
180            source: "a".into(),
181            target: "b".into(),
182            relation: "calls".into(),
183            confidence: Confidence::Extracted,
184            confidence_score: 1.0,
185            source_file: "test.rs".into(),
186            source_location: None,
187            weight: 1.0,
188            extra: HashMap::new(),
189        })
190        .unwrap();
191        kg
192    }
193
194    #[test]
195    fn export_svg_creates_file() {
196        let dir = tempfile::tempdir().unwrap();
197        let kg = sample_graph();
198        let communities: HashMap<usize, Vec<String>> =
199            [(0, vec!["a".into()]), (1, vec!["b".into()])].into();
200
201        let path = export_svg(&kg, &communities, dir.path()).unwrap();
202        assert!(path.exists());
203
204        let content = std::fs::read_to_string(&path).unwrap();
205        assert!(content.contains("<svg"));
206        assert!(content.contains("<circle"));
207        assert!(content.contains("<line"));
208    }
209
210    #[test]
211    fn export_svg_empty_graph() {
212        let dir = tempfile::tempdir().unwrap();
213        let kg = KnowledgeGraph::new();
214        let communities = HashMap::new();
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("Empty graph"));
221    }
222}