1use crate::graph::{EdgeKind, Graph, NodeKind};
14use crate::viz::GraphOverlay;
15use std::collections::HashMap;
16
17const NODE_H: f32 = 34.0;
18const SUB_EXTRA_H: f32 = 16.0;
19const X_GAP: f32 = 56.0;
20const Y_GAP: f32 = 22.0;
21const MARGIN: f32 = 16.0;
22const PAD_X: f32 = 14.0;
23const CHAR_W: f32 = 7.6; const SUB_CHAR_W: f32 = 6.4; fn class_colors(class: Option<&str>) -> (&'static str, &'static str, &'static str, f32) {
28 match class {
29 Some("soma_completed") => ("#e8f5e9", "#2e7d32", "#1b5e20", 1.4),
30 Some("soma_cached") => ("#e3f2fd", "#1565c0", "#0d47a1", 1.4),
31 Some("soma_failed") => ("#ffebee", "#c62828", "#b71c1c", 1.4),
32 Some("soma_running") => ("#fff8e1", "#f9a825", "#f57f17", 1.4),
33 Some(_) => ("#fff3e0", "#ef6c00", "#e65100", 2.4), None => ("#fcfcfb", "#c3c2b7", "#0b0b0b", 1.4),
35 }
36}
37
38fn esc(text: &str) -> String {
39 text.replace('&', "&")
40 .replace('<', "<")
41 .replace('>', ">")
42 .replace('"', """)
43}
44
45struct NodeBox {
46 x: f32,
47 y: f32,
48 w: f32,
49 h: f32,
50 label: String,
51 sublabel: Option<String>,
52 class: Option<&'static str>,
53 kind_tag: &'static str,
54}
55
56impl Graph {
57 pub fn to_svg(&self) -> String {
59 self.to_svg_with(&GraphOverlay::default())
60 }
61
62 pub fn to_svg_with(&self, overlay: &GraphOverlay) -> String {
66 use std::fmt::Write;
67
68 let order: Vec<String> = self
69 .topological_sort()
70 .unwrap_or_else(|_| self.nodes.iter().map(|n| n.id.as_str()).collect())
71 .into_iter()
72 .map(str::to_string)
73 .collect();
74
75 let mut layer: HashMap<&str, usize> = HashMap::new();
77 for id in &order {
78 let l = self
79 .predecessors(id)
80 .iter()
81 .filter_map(|p| layer.get(*p))
82 .max()
83 .map(|l| l + 1)
84 .unwrap_or(0);
85 layer.insert(id.as_str(), l);
86 }
87 let n_layers = layer.values().max().map(|l| l + 1).unwrap_or(0);
88 let mut layers: Vec<Vec<&str>> = vec![Vec::new(); n_layers];
89 for id in &order {
90 layers[layer[id.as_str()]].push(id);
91 }
92
93 let mut boxes: HashMap<String, NodeBox> = HashMap::new();
96 let mut layer_widths = Vec::with_capacity(n_layers);
97 let mut layer_heights = Vec::with_capacity(n_layers);
98 for ids in &layers {
99 let mut width: f32 = 0.0;
100 let mut height: f32 = 0.0;
101 for (i, id) in ids.iter().enumerate() {
102 let node = self.node(id).expect("node in topo order");
103 let ov = overlay.nodes.get(*id);
104 let sublabel = ov.and_then(|o| o.sublabel_text());
105 let label = match &node.kind {
106 NodeKind::Loop {
107 max_iterations: Some(n),
108 ..
109 } => {
110 format!("{} (max {n})", node.label)
111 }
112 _ => node.label.clone(),
113 };
114 let kind_tag = match &node.kind {
115 NodeKind::Filter { .. } => "filter",
116 NodeKind::SubGraph { .. } => "subgraph",
117 NodeKind::Loop { .. } => "loop",
118 NodeKind::Branch { .. } => "branch",
119 NodeKind::Step { .. } => "step",
120 };
121 let w = (label.chars().count() as f32 * CHAR_W)
122 .max(
123 sublabel
124 .as_deref()
125 .map_or(0.0, |s| s.chars().count() as f32 * SUB_CHAR_W),
126 )
127 .max(44.0)
128 + 2.0 * PAD_X;
129 let h = NODE_H + if sublabel.is_some() { SUB_EXTRA_H } else { 0.0 };
130 if i > 0 {
131 height += Y_GAP;
132 }
133 boxes.insert(
134 (*id).to_string(),
135 NodeBox {
136 x: 0.0,
137 y: height,
138 w,
139 h,
140 label,
141 sublabel,
142 class: ov.and_then(|o| o.style_class()),
143 kind_tag,
144 },
145 );
146 height += h;
147 width = width.max(w);
148 }
149 layer_widths.push(width);
150 layer_heights.push(height);
151 }
152 let max_height = layer_heights.iter().cloned().fold(0.0, f32::max);
153 let mut x = MARGIN;
154 for (l, ids) in layers.iter().enumerate() {
155 let y0 = MARGIN + (max_height - layer_heights[l]) / 2.0;
156 for id in ids {
157 let b = boxes.get_mut(*id).expect("box exists");
158 b.x = x;
159 b.y += y0;
160 }
161 x += layer_widths[l] + X_GAP;
162 }
163 let canvas_w = x - X_GAP + MARGIN;
164 let canvas_h = max_height + 2.0 * MARGIN;
165
166 let mut out = String::new();
167 let _ = write!(
168 out,
169 r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w:.0}" height="{h:.0}" viewBox="0 0 {w:.0} {h:.0}" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">"#,
170 w = canvas_w,
171 h = canvas_h,
172 );
173 out.push_str(
174 r##"<defs><marker id="soma-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 1 L 9 5 L 0 9 z" fill="#898781"/></marker></defs>"##,
175 );
176
177 for edge in &self.edges {
179 let (Some(src), Some(dst)) = (boxes.get(&edge.source), boxes.get(&edge.target)) else {
180 continue;
181 };
182 let (x1, y1) = (src.x + src.w, src.y + src.h / 2.0);
183 let (x2, y2) = (dst.x, dst.y + dst.h / 2.0);
184 let dx = ((x2 - x1) / 2.0).max(18.0);
185 let dash = match edge.kind {
186 EdgeKind::Data => "",
187 EdgeKind::Control => r#" stroke-dasharray="5 4""#,
188 };
189 let _ = write!(
190 out,
191 r##"<path d="M {x1:.1} {y1:.1} C {c1:.1} {y1:.1}, {c2:.1} {y2:.1}, {x2:.1} {y2:.1}" fill="none" stroke="#898781" stroke-width="1.5"{dash} marker-end="url(#soma-arrow)"/>"##,
192 c1 = x1 + dx,
193 c2 = x2 - dx,
194 );
195 if let Some(label) = &edge.label {
196 let _ = write!(
197 out,
198 r##"<text x="{x:.1}" y="{y:.1}" font-size="10" fill="#898781" text-anchor="middle">{t}</text>"##,
199 x = (x1 + x2) / 2.0,
200 y = (y1 + y2) / 2.0 - 5.0,
201 t = esc(label),
202 );
203 }
204 }
205
206 for id in &order {
208 let b = &boxes[id.as_str()];
209 let (fill, stroke, ink, sw) = class_colors(b.class);
210 let rx = match b.kind_tag {
211 "loop" => b.h / 2.0,
212 _ => 6.0,
213 };
214 let _ = write!(
215 out,
216 r#"<rect x="{x:.1}" y="{y:.1}" width="{w:.1}" height="{h:.1}" rx="{rx:.1}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>"#,
217 x = b.x,
218 y = b.y,
219 w = b.w,
220 h = b.h,
221 );
222 match b.kind_tag {
223 "subgraph" => {
225 let _ = write!(
226 out,
227 r#"<rect x="{x:.1}" y="{y:.1}" width="{w:.1}" height="{h:.1}" rx="4" fill="none" stroke="{stroke}" stroke-width="1"/>"#,
228 x = b.x + 3.0,
229 y = b.y + 3.0,
230 w = b.w - 6.0,
231 h = b.h - 6.0,
232 );
233 }
234 "branch" => {
236 let _ = write!(
237 out,
238 r#"<path d="M {x1:.1} {ym:.1} l 6 -6 M {x1:.1} {ym:.1} l 6 6 M {x2:.1} {ym:.1} l -6 -6 M {x2:.1} {ym:.1} l -6 6" stroke="{stroke}" stroke-width="1.2" fill="none"/>"#,
239 x1 = b.x,
240 x2 = b.x + b.w,
241 ym = b.y + b.h / 2.0,
242 );
243 }
244 _ => {}
245 }
246 let label_y = if b.sublabel.is_some() {
247 b.y + 20.0
248 } else {
249 b.y + b.h / 2.0 + 4.5
250 };
251 let _ = write!(
252 out,
253 r#"<text x="{x:.1}" y="{y:.1}" font-size="13" fill="{ink}" text-anchor="middle">{t}</text>"#,
254 x = b.x + b.w / 2.0,
255 y = label_y,
256 t = esc(&b.label),
257 );
258 if let Some(sub) = &b.sublabel {
259 let _ = write!(
260 out,
261 r##"<text x="{x:.1}" y="{y:.1}" font-size="11" fill="#52514e" text-anchor="middle">{t}</text>"##,
262 x = b.x + b.w / 2.0,
263 y = b.y + 36.0,
264 t = esc(sub),
265 );
266 }
267 }
268 out.push_str("</svg>");
269 out
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use crate::graph::{Edge, Graph, Node};
276 use crate::viz::{GraphOverlay, NodeOverlay, NodeStatus};
277
278 fn sample() -> Graph {
279 let mut g = Graph::new();
280 g.add_node(Node::new("a", "Scaler", "Scaler"));
281 g.add_node(Node::new("b", "PCA", "PCA"));
282 g.add_node(Node::new("c", "SVM", "SVM"));
283 g.add_edge(Edge::data("e0", "a", "b"));
284 g.add_edge(Edge::data("e1", "b", "c"));
285 g
286 }
287
288 #[test]
289 fn svg_contains_nodes_edges_and_valid_envelope() {
290 let svg = sample().to_svg();
291 assert!(svg.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""));
292 assert!(svg.ends_with("</svg>"));
293 for label in ["Scaler", "PCA", "SVM"] {
294 assert!(svg.contains(&format!(">{label}</text>")), "{svg}");
295 }
296 assert_eq!(
297 svg.matches("marker-end=\"url(#soma-arrow)\"").count(),
298 2,
299 "two edges"
300 );
301 assert!(!svg.contains("<<"));
303 }
304
305 #[test]
306 fn svg_overlay_colors_and_sublabels() {
307 let g = sample();
308 let mut ov = GraphOverlay::default();
309 ov.nodes.insert(
310 "a".into(),
311 NodeOverlay {
312 status: Some(NodeStatus::Completed),
313 duration_ms: Some(1200),
314 ..Default::default()
315 },
316 );
317 ov.nodes.insert(
318 "b".into(),
319 NodeOverlay {
320 flags: vec!["LEAKAGE".into()],
321 ..Default::default()
322 },
323 );
324 let svg = g.to_svg_with(&ov);
325 assert!(svg.contains("fill=\"#e8f5e9\""), "completed fill");
326 assert!(svg.contains(">1.2s</text>"), "duration sublabel");
327 assert!(svg.contains("fill=\"#fff3e0\""), "flagged fill");
328 assert!(svg.contains("⚠ LEAKAGE"));
329 assert!(svg.contains("fill=\"#fcfcfb\""));
331 }
332
333 #[test]
334 fn svg_layers_forks_side_by_side() {
335 let mut g = Graph::new();
336 g.add_node(Node::new("src", "src", "src"));
337 g.add_node(Node::new("l", "left", "left"));
338 g.add_node(Node::new("r", "right", "right"));
339 g.add_node(Node::new("sink", "sink", "sink"));
340 g.add_edge(Edge::data("e0", "src", "l"));
341 g.add_edge(Edge::data("e1", "src", "r"));
342 g.add_edge(Edge::data("e2", "l", "sink"));
343 g.add_edge(Edge::data("e3", "r", "sink"));
344 let svg = g.to_svg();
345 assert_eq!(svg.matches("<rect").count(), 4);
346 assert_eq!(svg.matches("marker-end").count(), 4);
347
348 let mut g2 = Graph::new();
350 g2.add_node(Node::new("x", "<script>\"&\"</script>", "x"));
351 let svg2 = g2.to_svg();
352 assert!(!svg2.contains("<script>"));
353 assert!(svg2.contains("<script>"));
354 }
355}