use std::collections::HashMap;
pub const NODE_WIDTH: f64 = 180.0;
pub const NODE_HEIGHT: f64 = 50.0;
pub const HORIZONTAL_GAP: f64 = 60.0;
pub const VERTICAL_GAP: f64 = 80.0;
pub const MARGIN: f64 = 40.0;
#[derive(Debug, Clone)]
pub struct SvgNode {
pub id: String,
pub label: String,
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
pub fill: String,
pub stroke: String,
pub text_color: String,
pub detail: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SvgEdge {
pub id: String,
pub source: String,
pub target: String,
pub label: Option<String>,
pub color: String,
}
pub struct LayoutEngine {
pub nodes: Vec<SvgNode>,
pub edges: Vec<SvgEdge>,
}
impl LayoutEngine {
pub fn new() -> Self {
Self {
nodes: Vec::new(),
edges: Vec::new(),
}
}
pub fn layout(&mut self) {
if self.nodes.is_empty() {
return;
}
let mut outgoing: HashMap<String, Vec<String>> = HashMap::new();
let mut incoming: HashMap<String, Vec<String>> = HashMap::new();
for edge in &self.edges {
outgoing
.entry(edge.source.clone())
.or_default()
.push(edge.target.clone());
incoming
.entry(edge.target.clone())
.or_default()
.push(edge.source.clone());
}
let mut levels: HashMap<String, usize> = HashMap::new();
let mut queue: Vec<String> = Vec::new();
for node in &self.nodes {
let has_incoming = incoming.contains_key(&node.id) && !incoming[&node.id].is_empty();
if !has_incoming {
levels.insert(node.id.clone(), 0);
queue.push(node.id.clone());
}
}
if queue.is_empty() {
for node in &self.nodes {
levels.insert(node.id.clone(), 0);
queue.push(node.id.clone());
}
}
let mut processed = 0;
while processed < queue.len() {
let current = queue[processed].clone();
processed += 1;
let current_level = *levels.get(¤t).unwrap_or(&0);
if let Some(neighbors) = outgoing.get(¤t) {
for neighbor in neighbors {
let entry = levels.entry(neighbor.clone()).or_insert(0);
*entry = (*entry).max(current_level + 1);
if !queue.contains(neighbor) {
queue.push(neighbor.clone());
}
}
}
}
for node in &self.nodes {
levels.entry(node.id.clone()).or_insert(0);
}
let max_level = levels.values().copied().max().unwrap_or(0);
let mut level_groups: Vec<Vec<String>> = vec![Vec::new(); max_level + 1];
for (id, level) in &levels {
level_groups[*level].push(id.clone());
}
let node_positions: HashMap<String, (f64, f64)> = self
.nodes
.iter()
.map(|n| {
let level = *levels.get(&n.id).unwrap_or(&0);
let nodes_in_level = &level_groups[level];
let index = nodes_in_level
.iter()
.position(|x| x == &n.id)
.unwrap_or(0);
let total_width = nodes_in_level.len() as f64 * (NODE_WIDTH + HORIZONTAL_GAP)
- HORIZONTAL_GAP;
let start_x = MARGIN + (index as f64) * (NODE_WIDTH + HORIZONTAL_GAP);
let x = start_x;
let y = MARGIN + (level as f64) * (NODE_HEIGHT + VERTICAL_GAP);
(n.id.clone(), (x, y))
})
.collect();
for node in &mut self.nodes {
if let Some(&(x, y)) = node_positions.get(&node.id) {
node.x = x;
node.y = y;
}
}
}
pub fn to_svg(&self) -> String {
if self.nodes.is_empty() {
return r###"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200" viewBox="0 0 400 200">
<rect width="100%" height="100%" fill="#0f172a"/>
<text x="50%" y="50%" text-anchor="middle" fill="#94a3b8" font-family="sans-serif" font-size="14">Empty graph</text>
</svg>"###.to_string();
}
let max_x = self
.nodes
.iter()
.map(|n| n.x + n.width)
.fold(0.0, f64::max)
+ MARGIN;
let max_y = self
.nodes
.iter()
.map(|n| n.y + n.height)
.fold(0.0, f64::max)
+ MARGIN;
let mut svg = String::new();
svg.push_str(&format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">
"##,
max_x as i32,
max_y as i32,
max_x as i32,
max_y as i32
));
svg.push_str(r##" <rect width="100%" height="100%" fill="#0f172a"/>
"##);
svg.push_str(r##" <defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b"/>
</marker>
</defs>
"##);
let node_map: HashMap<String, &SvgNode> = self.nodes.iter().map(|n| (n.id.clone(), n)).collect();
for edge in &self.edges {
if let (Some(src), Some(dst)) = (node_map.get(&edge.source), node_map.get(&edge.target))
{
let x1 = src.x + src.width / 2.0;
let y1 = src.y + src.height;
let x2 = dst.x + dst.width / 2.0;
let y2 = dst.y;
let mid_y = (y1 + y2) / 2.0;
svg.push_str(&format!(
r##" <path d="M {:.1} {:.1} C {:.1} {:.1}, {:.1} {:.1}, {:.1} {:.1}" fill="none" stroke="{}" stroke-width="1.5" marker-end="url(#arrowhead)"/>
"##,
x1, y1, x1, mid_y, x2, mid_y, x2, y2 - 2.0, edge.color
));
if let Some(ref label) = edge.label {
let lx = (x1 + x2) / 2.0;
let ly = mid_y;
svg.push_str(&format!(
r##" <rect x="{:.1}" y="{:.1}" width="{}" height="16" rx="3" fill="#0f172a" stroke="none"/>
<text x="{:.1}" y="{:.1}" text-anchor="middle" fill="#94a3b8" font-family="sans-serif" font-size="10">{}</text>
"##,
lx - label.len() as f64 * 3.0,
ly - 8.0,
label.len() * 6 + 8,
lx,
ly + 3.0,
escape_xml(label)
));
}
}
}
for node in &self.nodes {
let rx = 6.0;
svg.push_str(&format!(
r##" <rect x="{:.1}" y="{:.1}" width="{}" height="{}" rx="{:.1}" fill="#000000" opacity="0.3"/>
"##,
node.x + 2.0,
node.y + 2.0,
node.width as i32,
node.height as i32,
rx
));
svg.push_str(&format!(
r##" <rect x="{:.1}" y="{:.1}" width="{}" height="{}" rx="{:.1}" fill="{}" stroke="{}" stroke-width="2"/>
"##,
node.x,
node.y,
node.width as i32,
node.height as i32,
rx,
node.fill,
node.stroke
));
let max_chars = ((node.width - 20.0) / 7.0) as usize;
let display_label = if node.label.len() > max_chars {
format!("{}...", &node.label[..max_chars.saturating_sub(3)])
} else {
node.label.clone()
};
svg.push_str(&format!(
r##" <text x="{:.1}" y="{:.1}" text-anchor="middle" fill="{}" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" font-size="12" font-weight="600">{}</text>
"##,
node.x + node.width / 2.0,
node.y + node.height / 2.0 + 4.0,
node.text_color,
escape_xml(&display_label)
));
if let Some(ref detail) = node.detail {
let tooltip = format!("{}\n{}", node.label, detail);
svg.push_str(&format!(
r##" <title>{}</title>
"##,
escape_xml(&tooltip)
));
} else {
svg.push_str(&format!(
r##" <title>{}</title>
"##,
escape_xml(&node.label)
));
}
}
svg.push_str("</svg>\n");
svg
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_svg() {
let engine = LayoutEngine::new();
let svg = engine.to_svg();
assert!(svg.contains("Empty graph"));
}
#[test]
fn test_layout_and_svg() {
let mut engine = LayoutEngine::new();
engine.nodes.push(SvgNode {
id: "a".to_string(),
label: "main".to_string(),
x: 0.0,
y: 0.0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
fill: "#1e3a5f".to_string(),
stroke: "#3b82f6".to_string(),
text_color: "#e2e8f0".to_string(),
detail: None,
});
engine.nodes.push(SvgNode {
id: "b".to_string(),
label: "helper".to_string(),
x: 0.0,
y: 0.0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
fill: "#1e3a5f".to_string(),
stroke: "#3b82f6".to_string(),
text_color: "#e2e8f0".to_string(),
detail: None,
});
engine.edges.push(SvgEdge {
id: "e1".to_string(),
source: "a".to_string(),
target: "b".to_string(),
label: Some("calls".to_string()),
color: "#64748b".to_string(),
});
engine.layout();
let svg = engine.to_svg();
assert!(svg.contains("main"));
assert!(svg.contains("helper"));
assert!(svg.contains("calls"));
assert!(svg.contains("<svg"));
}
}