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=\"{}\" 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 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 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 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 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 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('&', "&")
160 .replace('<', "<")
161 .replace('>', ">")
162 .replace('"', """)
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}