1use 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
28pub 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('&', "&")
145 .replace('<', "<")
146 .replace('>', ">")
147 .replace('"', """)
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}