use crate::graph::{Edge, Node, VowlGraph};
use crate::Result;
#[cfg_attr(test, mockall::automock)]
pub trait Renderer {
fn render(&self, graph: &VowlGraph) -> Result<String>;
fn render_node(&self, node: &Node) -> Result<String>;
fn render_edge(&self, edge: &Edge, from: &Node, to: &Node) -> Result<String>;
}
pub struct SvgRenderer {
width: f64,
height: f64,
padding: f64,
}
impl SvgRenderer {
pub fn new(width: f64, height: f64) -> Self {
Self {
width,
height,
padding: 20.0,
}
}
pub fn with_padding(mut self, padding: f64) -> Self {
self.padding = padding;
self
}
fn svg_header(&self) -> String {
format!(
r#"<svg width="{}" height="{}" xmlns="http://www.w3.org/2000/svg">"#,
self.width, self.height
)
}
fn svg_footer(&self) -> &str {
"</svg>"
}
pub fn normalize_coords(&self, x: f64, y: f64, graph: &VowlGraph) -> (f64, f64) {
let nodes = graph.nodes();
if nodes.is_empty() {
return (self.width / 2.0, self.height / 2.0);
}
let min_x = nodes
.iter()
.map(|n| n.visual.x)
.fold(f64::INFINITY, f64::min);
let max_x = nodes
.iter()
.map(|n| n.visual.x)
.fold(f64::NEG_INFINITY, f64::max);
let min_y = nodes
.iter()
.map(|n| n.visual.y)
.fold(f64::INFINITY, f64::min);
let max_y = nodes
.iter()
.map(|n| n.visual.y)
.fold(f64::NEG_INFINITY, f64::max);
let scale_x = (self.width - 2.0 * self.padding) / (max_x - min_x).max(1.0);
let scale_y = (self.height - 2.0 * self.padding) / (max_y - min_y).max(1.0);
let scale = scale_x.min(scale_y);
(
(x - min_x) * scale + self.padding,
(y - min_y) * scale + self.padding,
)
}
}
impl Renderer for SvgRenderer {
fn render(&self, graph: &VowlGraph) -> Result<String> {
let mut svg = String::new();
svg.push_str(&self.svg_header());
svg.push_str("\n <g id=\"edges\">\n");
for edge in graph.edges() {
svg.push_str(" <!-- Edge: ");
svg.push_str(&edge.label);
svg.push_str(" -->\n");
}
svg.push_str(" </g>\n <g id=\"nodes\">\n");
for node in graph.nodes() {
svg.push_str(&format!(" {}\n", self.render_node(node)?));
}
svg.push_str(" </g>\n");
svg.push_str(self.svg_footer());
Ok(svg)
}
fn render_node(&self, node: &Node) -> Result<String> {
let radius = 20.0;
let color = node.visual.color.as_deref().unwrap_or("#4CAF50");
Ok(format!(
r##"<g id="{}">
<circle cx="{}" cy="{}" r="{}" fill="{}" stroke="{{0}}" stroke-width="2"/>
<text x="{}" y="{}" text-anchor="middle" dy="{{1}}" font-size="12" fill="{{0}}">{}</text>
</g>"##,
node.id,
node.visual.x,
node.visual.y,
radius,
color,
node.visual.x,
node.visual.y + radius + 15.0,
node.label
)
.replace("{0}", "#333")
.replace("{1}", ".3em"))
}
fn render_edge(&self, _edge: &Edge, from: &Node, to: &Node) -> Result<String> {
Ok(format!(
r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{{0}}" stroke-width="1.5" marker-end="url({{1}})"/>"##,
from.visual.x, from.visual.y, to.visual.x, to.visual.y
).replace("{0}", "#999").replace("{1}", "#arrow"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::node::NodeBuilder;
#[test]
fn test_svg_renderer_creation() {
let renderer = SvgRenderer::new(800.0, 600.0);
assert_eq!(renderer.width, 800.0);
assert_eq!(renderer.height, 600.0);
}
#[test]
fn test_svg_header() {
let renderer = SvgRenderer::new(800.0, 600.0);
let header = renderer.svg_header();
assert!(header.contains("800"));
assert!(header.contains("600"));
}
#[test]
fn test_render_node() {
let renderer = SvgRenderer::new(800.0, 600.0);
let node = NodeBuilder::new("test")
.label("Test Node")
.position(100.0, 100.0)
.build();
let svg = renderer.render_node(&node).unwrap();
assert!(svg.contains("Test Node"));
assert!(svg.contains("circle"));
}
#[test]
fn normalize_coords_centres_an_empty_graph() {
let renderer = SvgRenderer::new(800.0, 600.0);
let graph = VowlGraph::new();
assert_eq!(
renderer.normalize_coords(123.0, 456.0, &graph),
(400.0, 300.0)
);
}
#[test]
fn normalize_coords_stays_finite_for_a_collapsed_extent() {
let mut graph = VowlGraph::new();
graph
.add_node(NodeBuilder::new("only").position(5.0, 5.0).build())
.unwrap();
let renderer = SvgRenderer::new(800.0, 600.0);
let (x, y) = renderer.normalize_coords(5.0, 5.0, &graph);
assert!(
x.is_finite() && y.is_finite(),
"collapsed extent must not divide by zero"
);
assert_eq!((x, y), (20.0, 20.0));
}
#[test]
fn normalize_coords_preserves_aspect_ratio() {
let mut graph = VowlGraph::new();
graph
.add_node(NodeBuilder::new("a").position(0.0, 0.0).build())
.unwrap();
graph
.add_node(NodeBuilder::new("b").position(100.0, 50.0).build())
.unwrap();
let renderer = SvgRenderer::new(1020.0, 520.0);
let (x1, y1) = renderer.normalize_coords(0.0, 0.0, &graph);
let (x2, y2) = renderer.normalize_coords(100.0, 50.0, &graph);
let scale_x = (x2 - x1) / 100.0;
let scale_y = (y2 - y1) / 50.0;
assert!((scale_x - scale_y).abs() < 1e-9, "{scale_x} vs {scale_y}");
}
#[test]
fn test_render_empty_graph() {
let renderer = SvgRenderer::new(800.0, 600.0);
let graph = VowlGraph::new();
let result = renderer.render(&graph);
assert!(result.is_ok());
let svg = result.unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
}