Skip to main content

somatize_core/
svg.rs

1//! Self-contained SVG rendering of a [`Graph`] — no JavaScript, no
2//! external tools.
3//!
4//! Mermaid needs a JS runtime and notebook front-ends sanitize
5//! `<script>` out of cell outputs, so diagrams that must show up
6//! *inline* (notebook reprs, offline HTML reports, GitHub previews)
7//! render through this pure data→string layer instead. Layout is a
8//! simple longest-path layering (left→right), which fits Soma's small
9//! chain/fork DAGs; styling reuses the same status palette as the
10//! mermaid/graphviz overlay classes so a run reads identically in
11//! every rendering.
12
13use 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; // ≈13px system-ui
24const SUB_CHAR_W: f32 = 6.4; // ≈11px
25
26/// (fill, stroke, label ink, stroke width) per overlay style class.
27fn 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), // flagged
34        None => ("#fcfcfb", "#c3c2b7", "#0b0b0b", 1.4),
35    }
36}
37
38fn esc(text: &str) -> String {
39    text.replace('&', "&amp;")
40        .replace('<', "&lt;")
41        .replace('>', "&gt;")
42        .replace('"', "&quot;")
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    /// Render as a self-contained SVG diagram.
58    pub fn to_svg(&self) -> String {
59        self.to_svg_with(&GraphOverlay::default())
60    }
61
62    /// Render as a self-contained SVG diagram with per-node execution
63    /// annotations (status colors + a duration/cache/flags sublabel),
64    /// same overlay semantics as [`Graph::to_mermaid_with`].
65    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        // Longest-path layering, left→right.
76        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        // Boxes: size from label/sublabel, stacked per layer, layers
94        // centered vertically.
95        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        // Edges first (under the nodes).
178        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        // Nodes.
207        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: double border, like mermaid's [[...]].
224                "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: decision notches on the vertical edges.
235                "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        // No unescaped angle brackets from labels.
302        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        // Unannotated node keeps the neutral surface.
330        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        // Escaping: a hostile label cannot break out of the SVG.
349        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("&lt;script&gt;"));
354    }
355}