Skip to main content

scirs2_graph/
visualization.rs

1//! Graph visualization with layout algorithms and SVG export
2//!
3//! This module provides algorithms for computing node positions for graph
4//! visualization and exporting graphs to SVG and DOT formats.
5//!
6//! # Layout Algorithms
7//! - **ForceDirected**: Fruchterman-Reingold force-directed placement
8//! - **Hierarchical**: Layer-based layout for DAGs and trees
9//! - **Circular**: Nodes evenly distributed around a circle
10//! - **Spectral**: Eigenvector-based layout using graph Laplacian
11//!
12//! # Export Formats
13//! - SVG with fully customizable node/edge styling
14//! - DOT format with layout hints
15
16use std::collections::HashMap;
17use std::f64::consts::PI;
18use std::fmt::Write as FmtWrite;
19use std::hash::Hash;
20
21use scirs2_core::random::{Rng, RngExt};
22
23use crate::base::{EdgeWeight, Graph, Node};
24use crate::error::{GraphError, Result};
25
26// ============================================================================
27// Core types
28// ============================================================================
29
30/// 2D node position in layout space
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct LayoutPosition {
33    /// X coordinate
34    pub x: f64,
35    /// Y coordinate
36    pub y: f64,
37}
38
39impl LayoutPosition {
40    /// Create a new position
41    pub fn new(x: f64, y: f64) -> Self {
42        LayoutPosition { x, y }
43    }
44
45    /// Compute Euclidean distance to another position
46    pub fn distance(&self, other: &LayoutPosition) -> f64 {
47        let dx = self.x - other.x;
48        let dy = self.y - other.y;
49        (dx * dx + dy * dy).sqrt()
50    }
51
52    /// Clamp coordinates to the given bounding box
53    pub fn clamp(&self, x_min: f64, x_max: f64, y_min: f64, y_max: f64) -> LayoutPosition {
54        LayoutPosition {
55            x: self.x.max(x_min).min(x_max),
56            y: self.y.max(y_min).min(y_max),
57        }
58    }
59}
60
61/// Computed graph layout: maps each node to its 2D position
62#[derive(Debug, Clone)]
63pub struct GraphLayout<N: Node> {
64    /// Node → position mapping
65    pub positions: HashMap<N, LayoutPosition>,
66    /// Width of the layout canvas
67    pub width: f64,
68    /// Height of the layout canvas
69    pub height: f64,
70}
71
72impl<N: Node + Clone> GraphLayout<N> {
73    /// Create a new empty layout
74    pub fn new(width: f64, height: f64) -> Self {
75        GraphLayout {
76            positions: HashMap::new(),
77            width,
78            height,
79        }
80    }
81
82    /// Number of nodes in the layout
83    pub fn node_count(&self) -> usize {
84        self.positions.len()
85    }
86
87    /// Get position for a node
88    pub fn position(&self, node: &N) -> Option<&LayoutPosition> {
89        self.positions.get(node)
90    }
91
92    /// Set position for a node
93    pub fn set_position(&mut self, node: N, pos: LayoutPosition) {
94        self.positions.insert(node, pos);
95    }
96
97    /// Normalize positions to [0, 1] × [0, 1]
98    pub fn normalize(&mut self) {
99        if self.positions.is_empty() {
100            return;
101        }
102
103        let positions: Vec<LayoutPosition> = self.positions.values().cloned().collect();
104        let x_min = positions.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
105        let x_max = positions
106            .iter()
107            .map(|p| p.x)
108            .fold(f64::NEG_INFINITY, f64::max);
109        let y_min = positions.iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
110        let y_max = positions
111            .iter()
112            .map(|p| p.y)
113            .fold(f64::NEG_INFINITY, f64::max);
114
115        let x_range = (x_max - x_min).max(1e-10);
116        let y_range = (y_max - y_min).max(1e-10);
117
118        for pos in self.positions.values_mut() {
119            pos.x = (pos.x - x_min) / x_range;
120            pos.y = (pos.y - y_min) / y_range;
121        }
122    }
123}
124
125// ============================================================================
126// Layout algorithm enum
127// ============================================================================
128
129/// Available graph layout algorithms
130#[derive(Debug, Clone, PartialEq)]
131pub enum LayoutAlgorithm {
132    /// Fruchterman-Reingold force-directed layout
133    ForceDirected {
134        /// Number of iterations to run
135        iterations: usize,
136        /// Ideal spring length (None → auto-compute from area)
137        spring_length: Option<f64>,
138        /// Cooling coefficient: temperature decreases each iteration
139        cooling: f64,
140    },
141    /// Layer-based hierarchical layout
142    Hierarchical {
143        /// Vertical spacing between layers
144        layer_spacing: f64,
145        /// Horizontal spacing between nodes in the same layer
146        node_spacing: f64,
147    },
148    /// Circular layout: nodes arranged around a circle
149    Circular {
150        /// Radius of the circle
151        radius: f64,
152    },
153    /// Spectral layout using graph Laplacian eigenvectors
154    Spectral {
155        /// Number of eigenvector iterations (power method)
156        iterations: usize,
157    },
158}
159
160impl Default for LayoutAlgorithm {
161    fn default() -> Self {
162        LayoutAlgorithm::ForceDirected {
163            iterations: 100,
164            spring_length: None,
165            cooling: 0.95,
166        }
167    }
168}
169
170// ============================================================================
171// Layout computation
172// ============================================================================
173
174/// Compute a graph layout using the specified algorithm
175///
176/// # Arguments
177/// * `graph` - The graph to lay out
178/// * `algorithm` - Which layout algorithm to use
179/// * `width` - Canvas width in pixels
180/// * `height` - Canvas height in pixels
181///
182/// # Returns
183/// A `GraphLayout` with computed node positions
184pub fn compute_layout<N, E, Ix>(
185    graph: &Graph<N, E, Ix>,
186    algorithm: &LayoutAlgorithm,
187    width: f64,
188    height: f64,
189) -> Result<GraphLayout<N>>
190where
191    N: Node + Clone + std::fmt::Debug,
192    E: EdgeWeight + Into<f64>,
193    Ix: petgraph::graph::IndexType,
194{
195    match algorithm {
196        LayoutAlgorithm::ForceDirected {
197            iterations,
198            spring_length,
199            cooling,
200        } => force_directed_layout(graph, *iterations, *spring_length, *cooling, width, height),
201        LayoutAlgorithm::Hierarchical {
202            layer_spacing,
203            node_spacing,
204        } => hierarchical_layout(graph, *layer_spacing, *node_spacing, width, height),
205        LayoutAlgorithm::Circular { radius } => {
206            circular_layout(graph, *radius, width / 2.0, height / 2.0)
207        }
208        LayoutAlgorithm::Spectral { iterations } => {
209            spectral_layout(graph, *iterations, width, height)
210        }
211    }
212}
213
214/// Fruchterman-Reingold force-directed layout
215///
216/// Simulates attractive forces along edges and repulsive forces between all
217/// node pairs, with a linearly decreasing temperature (simulated annealing).
218fn force_directed_layout<N, E, Ix>(
219    graph: &Graph<N, E, Ix>,
220    iterations: usize,
221    spring_length: Option<f64>,
222    cooling: f64,
223    width: f64,
224    height: f64,
225) -> Result<GraphLayout<N>>
226where
227    N: Node + Clone + std::fmt::Debug,
228    E: EdgeWeight + Into<f64>,
229    Ix: petgraph::graph::IndexType,
230{
231    let nodes: Vec<N> = graph.nodes().into_iter().cloned().collect();
232    let n = nodes.len();
233
234    if n == 0 {
235        return Ok(GraphLayout::new(width, height));
236    }
237
238    let node_to_idx: HashMap<N, usize> = nodes
239        .iter()
240        .enumerate()
241        .map(|(i, n)| (n.clone(), i))
242        .collect();
243
244    // Initialize random positions
245    let mut positions: Vec<LayoutPosition> = Vec::with_capacity(n);
246    {
247        let mut rng = scirs2_core::random::rng();
248        for _ in 0..n {
249            positions.push(LayoutPosition::new(
250                rng.random::<f64>() * width,
251                rng.random::<f64>() * height,
252            ));
253        }
254    }
255
256    // Ideal edge length
257    let area = width * height;
258    let k = spring_length.unwrap_or_else(|| (area / n as f64).sqrt());
259    let k_sq = k * k;
260
261    // Build adjacency list (by index)
262    let edge_list: Vec<(usize, usize)> = graph
263        .edges()
264        .iter()
265        .filter_map(|e| {
266            let si = node_to_idx.get(&e.source)?;
267            let ti = node_to_idx.get(&e.target)?;
268            Some((*si, *ti))
269        })
270        .collect();
271
272    let mut temperature = width / 10.0;
273
274    for _ in 0..iterations {
275        let mut disp: Vec<(f64, f64)> = vec![(0.0, 0.0); n];
276
277        // Repulsive forces between all pairs
278        for i in 0..n {
279            for j in 0..n {
280                if i == j {
281                    continue;
282                }
283                let dx = positions[i].x - positions[j].x;
284                let dy = positions[i].y - positions[j].y;
285                let dist = (dx * dx + dy * dy).sqrt().max(1e-10);
286                let force = k_sq / dist;
287                disp[i].0 += (dx / dist) * force;
288                disp[i].1 += (dy / dist) * force;
289            }
290        }
291
292        // Attractive forces along edges
293        for &(si, ti) in &edge_list {
294            let dx = positions[si].x - positions[ti].x;
295            let dy = positions[si].y - positions[ti].y;
296            let dist = (dx * dx + dy * dy).sqrt().max(1e-10);
297            let force = dist * dist / k;
298            let fx = (dx / dist) * force;
299            let fy = (dy / dist) * force;
300            disp[si].0 -= fx;
301            disp[si].1 -= fy;
302            disp[ti].0 += fx;
303            disp[ti].1 += fy;
304        }
305
306        // Apply displacement capped at temperature
307        for i in 0..n {
308            let dx = disp[i].0;
309            let dy = disp[i].1;
310            let len = (dx * dx + dy * dy).sqrt().max(1e-10);
311            let capped = len.min(temperature);
312            positions[i].x += (dx / len) * capped;
313            positions[i].y += (dy / len) * capped;
314
315            // Keep within bounds
316            positions[i].x = positions[i].x.max(0.0).min(width);
317            positions[i].y = positions[i].y.max(0.0).min(height);
318        }
319
320        temperature *= cooling;
321    }
322
323    let mut layout = GraphLayout::new(width, height);
324    for (i, node) in nodes.into_iter().enumerate() {
325        layout.set_position(node, positions[i]);
326    }
327    Ok(layout)
328}
329
330/// Layer-based hierarchical layout
331///
332/// Assigns nodes to layers based on longest path from source nodes, then
333/// spreads nodes horizontally within each layer.
334fn hierarchical_layout<N, E, Ix>(
335    graph: &Graph<N, E, Ix>,
336    layer_spacing: f64,
337    node_spacing: f64,
338    width: f64,
339    height: f64,
340) -> Result<GraphLayout<N>>
341where
342    N: Node + Clone + std::fmt::Debug,
343    E: EdgeWeight + Into<f64>,
344    Ix: petgraph::graph::IndexType,
345{
346    let nodes: Vec<N> = graph.nodes().into_iter().cloned().collect();
347    let n = nodes.len();
348
349    if n == 0 {
350        return Ok(GraphLayout::new(width, height));
351    }
352
353    // Assign layers by degree-based heuristic (sorted by degree)
354    let mut degrees: Vec<(usize, usize)> = nodes
355        .iter()
356        .enumerate()
357        .map(|(i, node)| (i, graph.degree(node)))
358        .collect();
359    degrees.sort_by_key(|b| std::cmp::Reverse(b.1));
360
361    // Divide nodes into layers of similar degree
362    let num_layers = (n as f64).sqrt().ceil() as usize + 1;
363    let layer_size = n.div_ceil(num_layers);
364
365    let mut layers: Vec<Vec<usize>> = vec![Vec::new(); num_layers];
366    for (rank, (node_idx, _)) in degrees.iter().enumerate() {
367        layers[rank / layer_size.max(1)].push(*node_idx);
368    }
369    layers.retain(|l| !l.is_empty());
370
371    let actual_layers = layers.len();
372    let mut layout = GraphLayout::new(width, height);
373
374    for (layer_idx, layer) in layers.iter().enumerate() {
375        let y = if actual_layers > 1 {
376            layer_spacing
377                + (layer_idx as f64) * (height - 2.0 * layer_spacing) / (actual_layers - 1) as f64
378        } else {
379            height / 2.0
380        };
381
382        let layer_count = layer.len();
383        for (slot, &node_idx) in layer.iter().enumerate() {
384            let x = if layer_count > 1 {
385                node_spacing
386                    + (slot as f64) * (width - 2.0 * node_spacing) / (layer_count - 1) as f64
387            } else {
388                width / 2.0
389            };
390            layout.set_position(nodes[node_idx].clone(), LayoutPosition::new(x, y));
391        }
392    }
393
394    Ok(layout)
395}
396
397/// Circular layout
398///
399/// Places all nodes evenly around a circle centered at (cx, cy).
400fn circular_layout<N, E, Ix>(
401    graph: &Graph<N, E, Ix>,
402    radius: f64,
403    cx: f64,
404    cy: f64,
405) -> Result<GraphLayout<N>>
406where
407    N: Node + Clone + std::fmt::Debug,
408    E: EdgeWeight,
409    Ix: petgraph::graph::IndexType,
410{
411    let nodes: Vec<N> = graph.nodes().into_iter().cloned().collect();
412    let n = nodes.len();
413    let width = cx * 2.0;
414    let height = cy * 2.0;
415
416    if n == 0 {
417        return Ok(GraphLayout::new(width, height));
418    }
419
420    let angle_step = 2.0 * PI / n as f64;
421    let mut layout = GraphLayout::new(width, height);
422
423    for (i, node) in nodes.into_iter().enumerate() {
424        let angle = i as f64 * angle_step - PI / 2.0; // Start from top
425        let x = cx + radius * angle.cos();
426        let y = cy + radius * angle.sin();
427        layout.set_position(node, LayoutPosition::new(x, y));
428    }
429
430    Ok(layout)
431}
432
433/// Spectral layout using graph Laplacian eigenvectors
434///
435/// Computes the second and third smallest eigenvectors of the normalized
436/// Laplacian (via power iteration) and uses them as x/y coordinates.
437fn spectral_layout<N, E, Ix>(
438    graph: &Graph<N, E, Ix>,
439    iterations: usize,
440    width: f64,
441    height: f64,
442) -> Result<GraphLayout<N>>
443where
444    N: Node + Clone + std::fmt::Debug,
445    E: EdgeWeight + Into<f64>,
446    Ix: petgraph::graph::IndexType,
447{
448    let nodes: Vec<N> = graph.nodes().into_iter().cloned().collect();
449    let n = nodes.len();
450
451    if n == 0 {
452        return Ok(GraphLayout::new(width, height));
453    }
454    if n == 1 {
455        let mut layout = GraphLayout::new(width, height);
456        layout.set_position(
457            nodes[0].clone(),
458            LayoutPosition::new(width / 2.0, height / 2.0),
459        );
460        return Ok(layout);
461    }
462
463    let node_to_idx: HashMap<N, usize> = nodes
464        .iter()
465        .enumerate()
466        .map(|(i, n)| (n.clone(), i))
467        .collect();
468
469    // Build adjacency matrix
470    let mut adj = vec![vec![0.0f64; n]; n];
471    let mut degree = vec![0.0f64; n];
472
473    for e in graph.edges() {
474        if let (Some(&si), Some(&ti)) = (node_to_idx.get(&e.source), node_to_idx.get(&e.target)) {
475            let w: f64 = e.weight.clone().into();
476            let w = w.abs().max(1e-10);
477            adj[si][ti] += w;
478            adj[ti][si] += w;
479            degree[si] += w;
480            degree[ti] += w;
481        }
482    }
483
484    // Normalized Laplacian: L_norm = I - D^{-1/2} A D^{-1/2}
485    let d_inv_sqrt: Vec<f64> = degree
486        .iter()
487        .map(|&d| if d > 1e-10 { 1.0 / d.sqrt() } else { 1.0 })
488        .collect();
489
490    // Compute two Fiedler-like vectors via deflated power iteration
491    let compute_eigvec = |shift: f64, deflate: Option<&Vec<f64>>| -> Vec<f64> {
492        let mut v: Vec<f64> = (0..n).map(|i| (i + 1) as f64).collect();
493        // Normalize
494        let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt().max(1e-10);
495        for x in &mut v {
496            *x /= norm;
497        }
498
499        for _ in 0..iterations {
500            // w = (I + shift*I - L_norm) * v  ≡  D^{-1/2} A D^{-1/2} v + shift*v
501            let mut w = vec![0.0f64; n];
502            for i in 0..n {
503                let mut sum = 0.0;
504                for j in 0..n {
505                    sum += d_inv_sqrt[i] * adj[i][j] * d_inv_sqrt[j] * v[j];
506                }
507                w[i] = sum + shift * v[i];
508            }
509
510            // Deflate against constant vector and optionally first vector
511            let ones_norm = (n as f64).sqrt();
512            let dot_ones = w.iter().sum::<f64>() / ones_norm;
513            for x in &mut w {
514                *x -= dot_ones / ones_norm;
515            }
516            if let Some(first) = deflate {
517                let dot_first: f64 = w.iter().zip(first.iter()).map(|(a, b)| a * b).sum();
518                for (x, &f) in w.iter_mut().zip(first.iter()) {
519                    *x -= dot_first * f;
520                }
521            }
522
523            let norm: f64 = w.iter().map(|x| x * x).sum::<f64>().sqrt().max(1e-10);
524            for x in &mut w {
525                *x /= norm;
526            }
527            v = w;
528        }
529        v
530    };
531
532    let v1 = compute_eigvec(1.0, None);
533    let v2 = compute_eigvec(0.8, Some(&v1));
534
535    // Scale to canvas
536    let v1_min = v1.iter().cloned().fold(f64::INFINITY, f64::min);
537    let v1_max = v1.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
538    let v2_min = v2.iter().cloned().fold(f64::INFINITY, f64::min);
539    let v2_max = v2.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
540
541    let v1_range = (v1_max - v1_min).max(1e-10);
542    let v2_range = (v2_max - v2_min).max(1e-10);
543
544    let margin = 40.0;
545    let mut layout = GraphLayout::new(width, height);
546
547    for (i, node) in nodes.into_iter().enumerate() {
548        let x = margin + (v1[i] - v1_min) / v1_range * (width - 2.0 * margin);
549        let y = margin + (v2[i] - v2_min) / v2_range * (height - 2.0 * margin);
550        layout.set_position(node, LayoutPosition::new(x, y));
551    }
552
553    Ok(layout)
554}
555
556// ============================================================================
557// SVG export
558// ============================================================================
559
560/// Configuration for SVG graph rendering
561#[derive(Debug, Clone)]
562pub struct SvgConfig {
563    /// Fill color for nodes (CSS color string)
564    pub node_color: String,
565    /// Stroke color for node outlines
566    pub node_stroke_color: String,
567    /// Color for edges
568    pub edge_color: String,
569    /// Radius of nodes in pixels
570    pub node_radius: f64,
571    /// Stroke width for edges in pixels
572    pub edge_width: f64,
573    /// Font size for node labels in pixels
574    pub font_size: f64,
575    /// Font color for labels
576    pub font_color: String,
577    /// Background color of the SVG canvas
578    pub background_color: String,
579    /// Whether to show node labels
580    pub show_labels: bool,
581    /// Whether to show edge weights
582    pub show_edge_weights: bool,
583    /// Arrow size for directed edges (0 = undirected)
584    pub arrow_size: f64,
585}
586
587impl Default for SvgConfig {
588    fn default() -> Self {
589        SvgConfig {
590            node_color: "#4CAF50".to_string(),
591            node_stroke_color: "#2E7D32".to_string(),
592            edge_color: "#9E9E9E".to_string(),
593            node_radius: 12.0,
594            edge_width: 1.5,
595            font_size: 11.0,
596            font_color: "#FFFFFF".to_string(),
597            background_color: "#FAFAFA".to_string(),
598            show_labels: true,
599            show_edge_weights: false,
600            arrow_size: 0.0,
601        }
602    }
603}
604
605impl SvgConfig {
606    /// Create a dark-theme config
607    pub fn dark_theme() -> Self {
608        SvgConfig {
609            node_color: "#1565C0".to_string(),
610            node_stroke_color: "#90CAF9".to_string(),
611            edge_color: "#546E7A".to_string(),
612            node_radius: 12.0,
613            edge_width: 1.5,
614            font_size: 11.0,
615            font_color: "#FFFFFF".to_string(),
616            background_color: "#1A1A2E".to_string(),
617            show_labels: true,
618            show_edge_weights: false,
619            arrow_size: 0.0,
620        }
621    }
622}
623
624/// Render a graph to SVG using the provided layout and configuration
625///
626/// # Arguments
627/// * `graph` - The graph to render
628/// * `layout` - Pre-computed node positions
629/// * `config` - Visual styling configuration
630///
631/// # Returns
632/// A `String` containing valid SVG markup
633///
634/// # Example
635/// ```rust
636/// use scirs2_graph::visualization::{compute_layout, LayoutAlgorithm, SvgConfig, render_svg};
637/// use scirs2_graph::Graph;
638///
639/// let mut g: Graph<&str, f64> = Graph::new();
640/// g.add_node("A");
641/// g.add_node("B");
642/// let _ = g.add_edge("A", "B", 1.0);
643///
644/// let layout = compute_layout(&g, &LayoutAlgorithm::Circular { radius: 100.0 }, 400.0, 300.0).unwrap();
645/// let svg = render_svg(&g, &layout, &SvgConfig::default());
646/// assert!(svg.contains("<svg"));
647/// ```
648pub fn render_svg<N, E, Ix>(
649    graph: &Graph<N, E, Ix>,
650    layout: &GraphLayout<N>,
651    config: &SvgConfig,
652) -> String
653where
654    N: Node + Clone + std::fmt::Debug + std::fmt::Display,
655    E: EdgeWeight + Clone + Into<f64>,
656    Ix: petgraph::graph::IndexType,
657{
658    let width = layout.width;
659    let height = layout.height;
660
661    let mut svg = String::new();
662
663    // SVG header
664    let _ = writeln!(
665        svg,
666        r#"<?xml version="1.0" encoding="UTF-8"?>
667<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">
668  <rect width="100%" height="100%" fill="{}"/>"#,
669        width, height, width, height, config.background_color
670    );
671
672    // Define arrowhead marker if directed
673    if config.arrow_size > 0.0 {
674        let _ = writeln!(
675            svg,
676            r#"  <defs>
677    <marker id="arrow" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
678      <polygon points="0 0, 10 3.5, 0 7" fill="{}"/>
679    </marker>
680  </defs>"#,
681            config.edge_color
682        );
683    }
684
685    // Draw edges first (behind nodes)
686    for edge in graph.edges() {
687        if let (Some(src_pos), Some(tgt_pos)) =
688            (layout.position(&edge.source), layout.position(&edge.target))
689        {
690            let marker_attr = if config.arrow_size > 0.0 {
691                r#" marker-end="url(#arrow)""#
692            } else {
693                ""
694            };
695
696            let _ = writeln!(
697                svg,
698                r#"  <line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="{:.2}"{}/>  "#,
699                src_pos.x,
700                src_pos.y,
701                tgt_pos.x,
702                tgt_pos.y,
703                config.edge_color,
704                config.edge_width,
705                marker_attr
706            );
707
708            // Edge weight label
709            if config.show_edge_weights {
710                let mid_x = (src_pos.x + tgt_pos.x) / 2.0;
711                let mid_y = (src_pos.y + tgt_pos.y) / 2.0;
712                let w: f64 = edge.weight.clone().into();
713                let _ = writeln!(
714                    svg,
715                    r#"  <text x="{:.2}" y="{:.2}" font-size="{:.1}" fill="{}" text-anchor="middle">{:.2}</text>"#,
716                    mid_x,
717                    mid_y - 4.0,
718                    config.font_size * 0.85,
719                    config.edge_color,
720                    w
721                );
722            }
723        }
724    }
725
726    // Draw nodes
727    for node in graph.nodes() {
728        if let Some(pos) = layout.position(node) {
729            let _ = writeln!(
730                svg,
731                r#"  <circle cx="{:.2}" cy="{:.2}" r="{:.2}" fill="{}" stroke="{}" stroke-width="1.5"/>"#,
732                pos.x, pos.y, config.node_radius, config.node_color, config.node_stroke_color
733            );
734
735            // Node label
736            if config.show_labels {
737                let _ = writeln!(
738                    svg,
739                    r#"  <text x="{:.2}" y="{:.2}" font-size="{:.1}" font-family="sans-serif" fill="{}" text-anchor="middle" dominant-baseline="middle">{}</text>"#,
740                    pos.x, pos.y, config.font_size, config.font_color, node
741                );
742            }
743        }
744    }
745
746    let _ = writeln!(svg, "</svg>");
747    svg
748}
749
750// ============================================================================
751// DOT format export with layout hints
752// ============================================================================
753
754/// Configuration for DOT format export
755#[derive(Debug, Clone, Default)]
756pub struct DotConfig {
757    /// Whether to include position hints in DOT output
758    pub include_positions: bool,
759    /// Whether the graph is directed (uses `digraph` instead of `graph`)
760    pub directed: bool,
761    /// Graph name in the DOT file
762    pub graph_name: String,
763    /// Default node attributes
764    pub node_attributes: Vec<(String, String)>,
765    /// Default edge attributes
766    pub edge_attributes: Vec<(String, String)>,
767}
768
769impl DotConfig {
770    /// Create a new DotConfig with the given graph name
771    pub fn new(graph_name: &str) -> Self {
772        DotConfig {
773            include_positions: false,
774            directed: false,
775            graph_name: graph_name.to_string(),
776            node_attributes: Vec::new(),
777            edge_attributes: Vec::new(),
778        }
779    }
780
781    /// Include layout positions in output
782    pub fn with_positions(mut self) -> Self {
783        self.include_positions = true;
784        self
785    }
786
787    /// Set as directed graph
788    pub fn directed(mut self) -> Self {
789        self.directed = true;
790        self
791    }
792
793    /// Add a default node attribute
794    pub fn node_attr(mut self, key: &str, value: &str) -> Self {
795        self.node_attributes
796            .push((key.to_string(), value.to_string()));
797        self
798    }
799}
800
801/// Export a graph to DOT format with optional layout hints
802///
803/// # Arguments
804/// * `graph` - The graph to export
805/// * `config` - DOT export configuration
806/// * `layout` - Optional layout for position hints
807///
808/// # Returns
809/// A `String` containing valid DOT format
810pub fn export_dot<N, E, Ix>(
811    graph: &Graph<N, E, Ix>,
812    config: &DotConfig,
813    layout: Option<&GraphLayout<N>>,
814) -> Result<String>
815where
816    N: Node + Clone + std::fmt::Debug + std::fmt::Display + Hash + Eq,
817    E: EdgeWeight + Clone + Into<f64>,
818    Ix: petgraph::graph::IndexType,
819{
820    let graph_type = if config.directed { "digraph" } else { "graph" };
821    let edge_op = if config.directed { "->" } else { "--" };
822    let name = if config.graph_name.is_empty() {
823        "G"
824    } else {
825        &config.graph_name
826    };
827
828    let mut dot = String::new();
829    let _ = writeln!(dot, "{} {} {{", graph_type, name);
830
831    // Default node attributes
832    if !config.node_attributes.is_empty() {
833        let attrs: Vec<String> = config
834            .node_attributes
835            .iter()
836            .map(|(k, v)| format!("{}=\"{}\"", k, v))
837            .collect();
838        let _ = writeln!(dot, "  node [{}];", attrs.join(", "));
839    }
840
841    // Default edge attributes
842    if !config.edge_attributes.is_empty() {
843        let attrs: Vec<String> = config
844            .edge_attributes
845            .iter()
846            .map(|(k, v)| format!("{}=\"{}\"", k, v))
847            .collect();
848        let _ = writeln!(dot, "  edge [{}];", attrs.join(", "));
849    }
850
851    // Nodes
852    for node in graph.nodes() {
853        if config.include_positions {
854            if let Some(layout) = layout {
855                if let Some(pos) = layout.position(node) {
856                    let _ = writeln!(dot, "  \"{}\" [pos=\"{:.2},{:.2}!\"];", node, pos.x, pos.y);
857                    continue;
858                }
859            }
860        }
861        let _ = writeln!(dot, "  \"{}\";", node);
862    }
863
864    // Edges
865    for edge in graph.edges() {
866        let w: f64 = edge.weight.clone().into();
867        let _ = writeln!(
868            dot,
869            "  \"{}\" {} \"{}\" [weight={:.4}];",
870            edge.source, edge_op, edge.target, w
871        );
872    }
873
874    let _ = writeln!(dot, "}}");
875    Ok(dot)
876}
877
878// ============================================================================
879// Tests
880// ============================================================================
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885    use crate::base::Graph;
886
887    fn make_triangle() -> Graph<&'static str, f64> {
888        let mut g = Graph::new();
889        let _ = g.add_edge("A", "B", 1.0);
890        let _ = g.add_edge("B", "C", 1.0);
891        let _ = g.add_edge("A", "C", 1.0);
892        g
893    }
894
895    fn make_path(n: usize) -> Graph<usize, f64> {
896        let mut g = Graph::new();
897        for i in 0..n - 1 {
898            let _ = g.add_edge(i, i + 1, 1.0);
899        }
900        g
901    }
902
903    #[test]
904    fn test_circular_layout_positions() {
905        let g = make_triangle();
906        let layout = compute_layout(
907            &g,
908            &LayoutAlgorithm::Circular { radius: 100.0 },
909            400.0,
910            300.0,
911        )
912        .expect("Layout failed");
913        assert_eq!(layout.node_count(), 3);
914        // All nodes should be at approximately radius distance from center
915        for pos in layout.positions.values() {
916            let dx = pos.x - 200.0;
917            let dy = pos.y - 150.0;
918            let dist = (dx * dx + dy * dy).sqrt();
919            assert!((dist - 100.0).abs() < 1.0, "Distance from center: {}", dist);
920        }
921    }
922
923    #[test]
924    fn test_force_directed_layout() {
925        let g = make_path(6);
926        let algo = LayoutAlgorithm::ForceDirected {
927            iterations: 50,
928            spring_length: None,
929            cooling: 0.95,
930        };
931        let layout = compute_layout(&g, &algo, 500.0, 400.0).expect("Layout failed");
932        assert_eq!(layout.node_count(), 6);
933        // All positions should be within canvas bounds
934        for pos in layout.positions.values() {
935            assert!(pos.x >= 0.0 && pos.x <= 500.0, "x out of bounds: {}", pos.x);
936            assert!(pos.y >= 0.0 && pos.y <= 400.0, "y out of bounds: {}", pos.y);
937        }
938    }
939
940    #[test]
941    fn test_hierarchical_layout() {
942        let g = make_path(5);
943        let algo = LayoutAlgorithm::Hierarchical {
944            layer_spacing: 50.0,
945            node_spacing: 50.0,
946        };
947        let layout = compute_layout(&g, &algo, 500.0, 400.0).expect("Layout failed");
948        assert_eq!(layout.node_count(), 5);
949    }
950
951    #[test]
952    fn test_spectral_layout() {
953        let g = make_triangle();
954        let algo = LayoutAlgorithm::Spectral { iterations: 30 };
955        let layout = compute_layout(&g, &algo, 400.0, 300.0).expect("Layout failed");
956        assert_eq!(layout.node_count(), 3);
957    }
958
959    #[test]
960    fn test_svg_render_contains_elements() {
961        let g = make_triangle();
962        let layout = compute_layout(
963            &g,
964            &LayoutAlgorithm::Circular { radius: 100.0 },
965            400.0,
966            300.0,
967        )
968        .expect("Layout");
969        let svg = render_svg(&g, &layout, &SvgConfig::default());
970        assert!(svg.contains("<svg"), "Missing SVG root element");
971        assert!(svg.contains("<circle"), "Missing node circles");
972        assert!(svg.contains("<line"), "Missing edges");
973        assert!(svg.contains("</svg>"), "Missing closing SVG tag");
974    }
975
976    #[test]
977    fn test_svg_render_node_labels() {
978        let g = make_triangle();
979        let layout = compute_layout(
980            &g,
981            &LayoutAlgorithm::Circular { radius: 100.0 },
982            400.0,
983            300.0,
984        )
985        .expect("Layout");
986        let mut config = SvgConfig::default();
987        config.show_labels = true;
988        let svg = render_svg(&g, &layout, &config);
989        // Node labels "A", "B", "C" should appear
990        assert!(
991            svg.contains(">A<") || svg.contains(">A "),
992            "Label A not found"
993        );
994    }
995
996    #[test]
997    fn test_dot_export_basic() {
998        let g = make_triangle();
999        let config = DotConfig::new("TestGraph");
1000        let dot = export_dot(&g, &config, None).expect("DOT export failed");
1001        assert!(dot.contains("graph TestGraph"), "Missing graph header");
1002        assert!(dot.contains("--"), "Missing undirected edge operator");
1003        assert!(
1004            dot.contains("\"A\"") || dot.contains("\"B\""),
1005            "Missing node"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_dot_export_with_positions() {
1011        let g = make_triangle();
1012        let layout = compute_layout(
1013            &g,
1014            &LayoutAlgorithm::Circular { radius: 100.0 },
1015            400.0,
1016            300.0,
1017        )
1018        .expect("Layout");
1019        let config = DotConfig::new("PosGraph").with_positions();
1020        let dot = export_dot(&g, &config, Some(&layout)).expect("DOT export with positions failed");
1021        assert!(dot.contains("pos="), "Missing position hints");
1022    }
1023
1024    #[test]
1025    fn test_empty_graph_layout() {
1026        let g: Graph<usize, f64> = Graph::new();
1027        let layout = compute_layout(
1028            &g,
1029            &LayoutAlgorithm::Circular { radius: 100.0 },
1030            400.0,
1031            300.0,
1032        )
1033        .expect("Empty layout");
1034        assert_eq!(layout.node_count(), 0);
1035    }
1036
1037    #[test]
1038    fn test_layout_position_distance() {
1039        let p1 = LayoutPosition::new(0.0, 0.0);
1040        let p2 = LayoutPosition::new(3.0, 4.0);
1041        assert!((p1.distance(&p2) - 5.0).abs() < 1e-10);
1042    }
1043
1044    #[test]
1045    fn test_dark_theme_config() {
1046        let g = make_triangle();
1047        let layout = compute_layout(
1048            &g,
1049            &LayoutAlgorithm::Circular { radius: 100.0 },
1050            400.0,
1051            300.0,
1052        )
1053        .expect("Layout");
1054        let svg = render_svg(&g, &layout, &SvgConfig::dark_theme());
1055        assert!(svg.contains("#1A1A2E"), "Dark theme background not found");
1056    }
1057}