1use 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#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct LayoutPosition {
33 pub x: f64,
35 pub y: f64,
37}
38
39impl LayoutPosition {
40 pub fn new(x: f64, y: f64) -> Self {
42 LayoutPosition { x, y }
43 }
44
45 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 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#[derive(Debug, Clone)]
63pub struct GraphLayout<N: Node> {
64 pub positions: HashMap<N, LayoutPosition>,
66 pub width: f64,
68 pub height: f64,
70}
71
72impl<N: Node + Clone> GraphLayout<N> {
73 pub fn new(width: f64, height: f64) -> Self {
75 GraphLayout {
76 positions: HashMap::new(),
77 width,
78 height,
79 }
80 }
81
82 pub fn node_count(&self) -> usize {
84 self.positions.len()
85 }
86
87 pub fn position(&self, node: &N) -> Option<&LayoutPosition> {
89 self.positions.get(node)
90 }
91
92 pub fn set_position(&mut self, node: N, pos: LayoutPosition) {
94 self.positions.insert(node, pos);
95 }
96
97 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#[derive(Debug, Clone, PartialEq)]
131pub enum LayoutAlgorithm {
132 ForceDirected {
134 iterations: usize,
136 spring_length: Option<f64>,
138 cooling: f64,
140 },
141 Hierarchical {
143 layer_spacing: f64,
145 node_spacing: f64,
147 },
148 Circular {
150 radius: f64,
152 },
153 Spectral {
155 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
170pub 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
214fn 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 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 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 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 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 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 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 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
330fn 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 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 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
397fn 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; 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
433fn 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
562pub struct SvgConfig {
563 pub node_color: String,
565 pub node_stroke_color: String,
567 pub edge_color: String,
569 pub node_radius: f64,
571 pub edge_width: f64,
573 pub font_size: f64,
575 pub font_color: String,
577 pub background_color: String,
579 pub show_labels: bool,
581 pub show_edge_weights: bool,
583 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 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
624pub 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 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 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 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 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 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 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#[derive(Debug, Clone, Default)]
756pub struct DotConfig {
757 pub include_positions: bool,
759 pub directed: bool,
761 pub graph_name: String,
763 pub node_attributes: Vec<(String, String)>,
765 pub edge_attributes: Vec<(String, String)>,
767}
768
769impl DotConfig {
770 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 pub fn with_positions(mut self) -> Self {
783 self.include_positions = true;
784 self
785 }
786
787 pub fn directed(mut self) -> Self {
789 self.directed = true;
790 self
791 }
792
793 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
801pub 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 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 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 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 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#[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 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 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 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}