Skip to main content

oxirs_samm/graph_analytics/
visualization.rs

1//! Graph Visualization for SAMM Models
2//!
3//! This module provides visualization capabilities for SAMM model dependency graphs.
4//! It supports generating DOT format files that can be rendered using Graphviz, and
5//! optionally can render directly to SVG/PNG if the `graphviz` feature is enabled.
6//!
7//! # Features
8//!
9//! - **DOT Format Generation**: Create Graphviz DOT files for model graphs
10//! - **Customizable Styles**: Choose between compact, detailed, or hierarchical layouts
11//! - **Color Coding**: Different colors for aspects, properties, and characteristics
12//! - **Optional Rendering**: Generate SVG/PNG images with the `graphviz` feature
13//!
14//! # Examples
15//!
16//! ```rust
17//! use oxirs_samm::graph_analytics::{ModelGraph, VisualizationStyle};
18//! use oxirs_samm::metamodel::Aspect;
19//!
20//! # fn example(aspect: &Aspect) -> Result<(), Box<dyn std::error::Error>> {
21//! // Build graph
22//! let graph = ModelGraph::from_aspect(aspect)?;
23//!
24//! // Generate DOT format
25//! let dot = graph.to_dot(VisualizationStyle::Detailed)?;
26//! std::fs::write("model.dot", dot)?;
27//!
28//! // Render to SVG (requires 'graphviz' feature)
29//! #[cfg(feature = "graphviz")]
30//! graph.render_svg("model.svg", VisualizationStyle::Hierarchical)?;
31//! # Ok(())
32//! # }
33//! ```
34
35use crate::error::{Result, SammError};
36use crate::graph_analytics::ModelGraph;
37use std::fmt::Write as FmtWrite;
38
39/// Visualization style for graph rendering
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum VisualizationStyle {
42    /// Compact layout - minimal labels, optimized for overview
43    Compact,
44    /// Detailed layout - full URNs and metadata
45    #[default]
46    Detailed,
47    /// Hierarchical layout - top-down tree structure
48    Hierarchical,
49}
50
51/// Color scheme for graph elements
52#[derive(Debug, Clone)]
53pub struct ColorScheme {
54    /// Color for aspect nodes
55    pub aspect_color: String,
56    /// Color for property nodes
57    pub property_color: String,
58    /// Color for characteristic nodes
59    pub characteristic_color: String,
60    /// Color for edges
61    pub edge_color: String,
62}
63
64impl Default for ColorScheme {
65    fn default() -> Self {
66        Self {
67            aspect_color: "#E8F4F8".to_string(),         // Light blue
68            property_color: "#FFF4E6".to_string(),       // Light orange
69            characteristic_color: "#F0F0F0".to_string(), // Light gray
70            edge_color: "#666666".to_string(),           // Dark gray
71        }
72    }
73}
74
75impl ModelGraph {
76    /// Generate DOT format representation of the graph
77    ///
78    /// Creates a Graphviz DOT file that can be rendered using `dot` command or
79    /// online tools like GraphvizOnline.
80    ///
81    /// # Arguments
82    ///
83    /// * `style` - Visualization style to use
84    ///
85    /// # Returns
86    ///
87    /// DOT format string
88    ///
89    /// # Example
90    ///
91    /// ```rust
92    /// use oxirs_samm::graph_analytics::{ModelGraph, VisualizationStyle};
93    ///
94    /// # fn example(graph: &ModelGraph) -> Result<(), Box<dyn std::error::Error>> {
95    /// let dot = graph.to_dot(VisualizationStyle::Detailed)?;
96    /// std::fs::write("model.dot", dot)?;
97    /// # Ok(())
98    /// # }
99    /// ```
100    pub fn to_dot(&self, style: VisualizationStyle) -> Result<String> {
101        self.to_dot_with_colors(style, ColorScheme::default())
102    }
103
104    /// Generate DOT format with custom color scheme
105    ///
106    /// # Arguments
107    ///
108    /// * `style` - Visualization style to use
109    /// * `colors` - Custom color scheme
110    ///
111    /// # Returns
112    ///
113    /// DOT format string
114    pub fn to_dot_with_colors(
115        &self,
116        style: VisualizationStyle,
117        colors: ColorScheme,
118    ) -> Result<String> {
119        let mut dot = String::new();
120
121        // DOT file header
122        writeln!(dot, "digraph SAMM_Model {{")
123            .map_err(|e| SammError::GraphError(format!("Failed to write DOT: {}", e)))?;
124        writeln!(dot, "  // Generated by OxiRS SAMM")
125            .map_err(|e| SammError::GraphError(format!("Failed to write DOT: {}", e)))?;
126        writeln!(
127            dot,
128            "  rankdir={}; // Layout direction",
129            if style == VisualizationStyle::Hierarchical {
130                "TB"
131            } else {
132                "LR"
133            }
134        )
135        .map_err(|e| SammError::GraphError(format!("Failed to write DOT: {}", e)))?;
136        writeln!(dot, "  node [shape=box, style=filled, fontname=\"Arial\"];")
137            .map_err(|e| SammError::GraphError(format!("Failed to write DOT: {}", e)))?;
138        writeln!(dot, "  edge [color=\"{}\"];", colors.edge_color)
139            .map_err(|e| SammError::GraphError(format!("Failed to write DOT: {}", e)))?;
140        writeln!(dot).map_err(|e| SammError::GraphError(format!("Failed to write DOT: {}", e)))?;
141
142        // Add nodes with colors based on type
143        for name in self.nodes() {
144            let (color, shape, label) = self.get_node_attributes(name, style, &colors);
145            writeln!(
146                dot,
147                "  \"{}\" [fillcolor=\"{}\", shape={}, label=\"{}\"];",
148                name, color, shape, label
149            )
150            .map_err(|e| SammError::GraphError(format!("Failed to write DOT: {}", e)))?;
151        }
152
153        writeln!(dot).map_err(|e| SammError::GraphError(format!("Failed to write DOT: {}", e)))?;
154
155        // Add edges
156        for (src, tgt) in self.edges() {
157            writeln!(dot, "  \"{}\" -> \"{}\";", src, tgt)
158                .map_err(|e| SammError::GraphError(format!("Failed to write DOT: {}", e)))?;
159        }
160
161        writeln!(dot, "}}")
162            .map_err(|e| SammError::GraphError(format!("Failed to write DOT: {}", e)))?;
163
164        Ok(dot)
165    }
166
167    /// Get node attributes (color, shape, label) based on node type
168    fn get_node_attributes(
169        &self,
170        name: &str,
171        style: VisualizationStyle,
172        colors: &ColorScheme,
173    ) -> (String, &'static str, String) {
174        // Determine node type based on naming patterns
175        let (color, shape) = if name.contains("Aspect") {
176            (colors.aspect_color.clone(), "box")
177        } else if name.contains("Characteristic") || name.contains("Char") {
178            (colors.characteristic_color.clone(), "ellipse")
179        } else {
180            // Assume property
181            (colors.property_color.clone(), "rectangle")
182        };
183
184        // Generate label based on style
185        let label = match style {
186            VisualizationStyle::Compact => {
187                // Just the short name
188                name.to_string()
189            }
190            VisualizationStyle::Detailed => {
191                // Full name with type indicator
192                name.to_string()
193            }
194            VisualizationStyle::Hierarchical => {
195                // Name with indentation hints
196                name.to_string()
197            }
198        };
199
200        (color, shape, label)
201    }
202
203    /// Render graph to SVG file (requires `graphviz` feature)
204    ///
205    /// This method directly renders the graph to an SVG file using the Graphviz library.
206    ///
207    /// # Arguments
208    ///
209    /// * `output_path` - Path to output SVG file
210    /// * `style` - Visualization style to use
211    ///
212    /// # Example
213    ///
214    /// ```rust,no_run
215    /// # #[cfg(feature = "graphviz")]
216    /// # {
217    /// use oxirs_samm::graph_analytics::{ModelGraph, VisualizationStyle};
218    ///
219    /// # fn example(graph: &ModelGraph) -> Result<(), Box<dyn std::error::Error>> {
220    /// graph.render_svg("model.svg", VisualizationStyle::Hierarchical)?;
221    /// # Ok(())
222    /// # }
223    /// # }
224    /// ```
225    #[cfg(feature = "graphviz")]
226    pub fn render_svg(&self, output_path: &str, style: VisualizationStyle) -> Result<()> {
227        use graphviz_rust::cmd::{CommandArg, Format};
228        use graphviz_rust::exec;
229        use graphviz_rust::parse;
230        use graphviz_rust::printer::PrinterContext;
231
232        let dot_string = self.to_dot(style)?;
233
234        // Parse DOT string
235        let graph = parse(&dot_string)
236            .map_err(|e| SammError::GraphError(format!("Failed to parse DOT: {:?}", e)))?;
237
238        // Render to SVG
239        let svg = exec(
240            graph,
241            &mut PrinterContext::default(),
242            vec![CommandArg::Format(Format::Svg)],
243        )
244        .map_err(|e| SammError::GraphError(format!("Failed to render SVG: {:?}", e)))?;
245
246        // Write to file
247        std::fs::write(output_path, svg)
248            .map_err(|e| SammError::GraphError(format!("Failed to write SVG: {}", e)))?;
249
250        Ok(())
251    }
252
253    /// Render graph to PNG file (requires `graphviz` feature)
254    ///
255    /// This method directly renders the graph to a PNG file using the Graphviz library.
256    ///
257    /// # Arguments
258    ///
259    /// * `output_path` - Path to output PNG file
260    /// * `style` - Visualization style to use
261    ///
262    /// # Example
263    ///
264    /// ```rust,no_run
265    /// # #[cfg(feature = "graphviz")]
266    /// # {
267    /// use oxirs_samm::graph_analytics::{ModelGraph, VisualizationStyle};
268    ///
269    /// # fn example(graph: &ModelGraph) -> Result<(), Box<dyn std::error::Error>> {
270    /// graph.render_png("model.png", VisualizationStyle::Compact)?;
271    /// # Ok(())
272    /// # }
273    /// # }
274    /// ```
275    #[cfg(feature = "graphviz")]
276    pub fn render_png(&self, output_path: &str, style: VisualizationStyle) -> Result<()> {
277        use graphviz_rust::cmd::{CommandArg, Format};
278        use graphviz_rust::exec;
279        use graphviz_rust::parse;
280        use graphviz_rust::printer::PrinterContext;
281
282        let dot_string = self.to_dot(style)?;
283
284        // Parse DOT string
285        let graph = parse(&dot_string)
286            .map_err(|e| SammError::GraphError(format!("Failed to parse DOT: {:?}", e)))?;
287
288        // Render to PNG
289        let png = exec(
290            graph,
291            &mut PrinterContext::default(),
292            vec![CommandArg::Format(Format::Png)],
293        )
294        .map_err(|e| SammError::GraphError(format!("Failed to render PNG: {:?}", e)))?;
295
296        // Write to file
297        std::fs::write(output_path, png)
298            .map_err(|e| SammError::GraphError(format!("Failed to write PNG: {}", e)))?;
299
300        Ok(())
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::metamodel::{Aspect, Characteristic, CharacteristicKind, Property};
308
309    fn create_test_aspect() -> Aspect {
310        let mut aspect = Aspect::new("urn:samm:test:1.0.0#TestAspect".to_string());
311
312        // Add properties
313        for i in 1..=3 {
314            let characteristic = Characteristic {
315                metadata: crate::metamodel::ElementMetadata::new(format!(
316                    "urn:samm:test:1.0.0#Char{}",
317                    i
318                )),
319                data_type: Some("string".to_string()),
320                kind: CharacteristicKind::Trait,
321                constraints: vec![],
322            };
323
324            let property = Property::new(format!("urn:samm:test:1.0.0#Property{}", i))
325                .with_characteristic(characteristic);
326
327            aspect.add_property(property);
328        }
329
330        aspect
331    }
332
333    #[test]
334    fn test_dot_generation_compact() {
335        let aspect = create_test_aspect();
336        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
337
338        let dot = graph
339            .to_dot(VisualizationStyle::Compact)
340            .expect("operation should succeed");
341
342        // Verify DOT structure
343        assert!(dot.contains("digraph SAMM_Model"));
344        assert!(dot.contains("TestAspect"));
345        assert!(dot.contains("Property1"));
346        assert!(dot.contains("Char1"));
347    }
348
349    #[test]
350    fn test_dot_generation_detailed() {
351        let aspect = create_test_aspect();
352        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
353
354        let dot = graph
355            .to_dot(VisualizationStyle::Detailed)
356            .expect("operation should succeed");
357
358        // Verify DOT structure
359        assert!(dot.contains("digraph SAMM_Model"));
360        assert!(dot.contains("fillcolor"));
361        assert!(dot.contains("->"));
362    }
363
364    #[test]
365    fn test_dot_generation_hierarchical() {
366        let aspect = create_test_aspect();
367        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
368
369        let dot = graph
370            .to_dot(VisualizationStyle::Hierarchical)
371            .expect("operation should succeed");
372
373        // Verify hierarchical layout
374        assert!(dot.contains("rankdir=TB"));
375    }
376
377    #[test]
378    fn test_custom_colors() {
379        let aspect = create_test_aspect();
380        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
381
382        let colors = ColorScheme {
383            aspect_color: "#FF0000".to_string(),
384            property_color: "#00FF00".to_string(),
385            characteristic_color: "#0000FF".to_string(),
386            edge_color: "#000000".to_string(),
387        };
388
389        let dot = graph
390            .to_dot_with_colors(VisualizationStyle::Detailed, colors)
391            .expect("operation should succeed");
392
393        // Verify custom colors are used
394        assert!(dot.contains("#FF0000") || dot.contains("#00FF00") || dot.contains("#0000FF"));
395    }
396
397    #[test]
398    fn test_node_attributes() {
399        let aspect = create_test_aspect();
400        let graph = ModelGraph::from_aspect(&aspect).expect("conversion should succeed");
401        let colors = ColorScheme::default();
402
403        // Test aspect node
404        let (color, shape, _label) =
405            graph.get_node_attributes("TestAspect", VisualizationStyle::Detailed, &colors);
406        assert_eq!(color, colors.aspect_color);
407        assert_eq!(shape, "box");
408
409        // Test characteristic node
410        let (color, shape, _label) =
411            graph.get_node_attributes("Char1", VisualizationStyle::Detailed, &colors);
412        assert_eq!(color, colors.characteristic_color);
413        assert_eq!(shape, "ellipse");
414
415        // Test property node
416        let (color, shape, _label) =
417            graph.get_node_attributes("Property1", VisualizationStyle::Detailed, &colors);
418        assert_eq!(color, colors.property_color);
419        assert_eq!(shape, "rectangle");
420    }
421}