use crate::ast::{ArrowKind, NodeType};
use crate::layout::{LayoutEdge, LayoutGroup, LayoutNode, LayoutResult};
use crate::themes::Theme;
pub fn render_svg(layout: &LayoutResult, theme: &Theme) -> String {
let mut svg = String::with_capacity(8192);
let w = layout.width;
let h = layout.height;
svg.push_str(&format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}" font-family="{font}">"#,
w = w.ceil() as i32,
h = h.ceil() as i32,
font = escape_xml(&theme.font.family),
));
svg.push('\n');
svg.push_str(&render_defs(theme));
svg.push_str(&format!(
r#" <rect width="100%" height="100%" fill="{}"/>"#,
theme.background
));
svg.push('\n');
render_groups_recursive(&layout.groups, theme, &mut svg);
for edge in &layout.edges {
render_edge(edge, theme, &mut svg);
}
for node in &layout.nodes {
render_node(node, theme, &mut svg);
}
svg.push_str("</svg>\n");
svg
}
fn render_defs(theme: &Theme) -> String {
let cs = &theme.connection_style;
let s = cs.arrow_size;
format!(
r#" <defs>
<marker id="arrow" viewBox="0 0 {s} {s}" refX="{s}" refY="{hs}" markerWidth="{s}" markerHeight="{s}" orient="auto-start-reverse">
<path d="M0,0 L{s},{hs} L0,{s}" fill="{color}" />
</marker>
<marker id="arrow-dashed" viewBox="0 0 {s} {s}" refX="{s}" refY="{hs}" markerWidth="{s}" markerHeight="{s}" orient="auto-start-reverse">
<path d="M0,0 L{s},{hs} L0,{s}" fill="{dashed_color}" />
</marker>
<marker id="arrow-blocked" viewBox="0 0 {s} {s}" refX="{s}" refY="{hs}" markerWidth="{s}" markerHeight="{s}" orient="auto-start-reverse">
<path d="M0,0 L{s},{hs} L0,{s}" fill="{blocked_color}" />
</marker>
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.08" />
</filter>
</defs>
"#,
s = s as i32,
hs = (s / 2.0) as i32,
color = cs.stroke,
dashed_color = cs.dashed_stroke,
blocked_color = cs.blocked_stroke,
)
}
fn render_node(node: &LayoutNode, theme: &Theme, svg: &mut String) {
let style = theme.node_style(&node.node_type);
let x = node.x;
let y = node.y;
let w = node.width;
let h = node.height;
let r = 10.0;
svg.push_str(&format!(r#" <g class="node" data-id="{}">"#, escape_xml(&node.id)));
svg.push('\n');
match node.node_type {
NodeType::Db | NodeType::Cache => {
render_cylinder(svg, x, y, w, h, style, node.node_type == NodeType::Cache);
}
NodeType::Queue => {
render_parallelogram(svg, x, y, w, h, style);
}
NodeType::User => {
render_user_shape(svg, x, y, w, h, style);
}
NodeType::External => {
render_cloud_rect(svg, x, y, w, h, style);
}
_ => {
svg.push_str(&format!(
r#" <rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" ry="{r}" fill="{fill}" stroke="{stroke}" stroke-width="1.5" filter="url(#shadow)"/>"#,
x = x, y = y, w = w, h = h, r = r,
fill = style.fill, stroke = style.stroke,
));
svg.push('\n');
}
}
let type_label = node.node_type.as_str().to_uppercase();
let type_y = y + 20.0;
svg.push_str(&format!(
r#" <text x="{x}" y="{y}" text-anchor="middle" fill="{color}" font-size="{size}" font-weight="600" letter-spacing="0.5">{text}</text>"#,
x = x + w / 2.0,
y = type_y,
color = style.type_color,
size = theme.font.node_type_size,
text = escape_xml(&type_label),
));
svg.push('\n');
let label_y = y + 42.0;
let display = truncate_label(&node.display_label, 20);
svg.push_str(&format!(
r#" <text x="{x}" y="{y}" text-anchor="middle" fill="{color}" font-size="{size}" font-weight="600">{text}</text>"#,
x = x + w / 2.0,
y = label_y,
color = style.text_color,
size = theme.font.node_label_size,
text = escape_xml(&display),
));
svg.push('\n');
if !node.tags.is_empty() {
let tag_y = y + 60.0;
let tag_text = node.tags.join(" · ");
let display_tags = truncate_label(&tag_text, 24);
svg.push_str(&format!(
r#" <text x="{x}" y="{y}" text-anchor="middle" fill="{color}" font-size="{size}">{text}</text>"#,
x = x + w / 2.0,
y = tag_y,
color = style.type_color,
size = theme.font.tag_size,
text = escape_xml(&display_tags),
));
svg.push('\n');
}
svg.push_str(" </g>\n");
}
fn render_cylinder(svg: &mut String, x: f64, y: f64, w: f64, h: f64, style: &crate::themes::NodeStyle, dashed: bool) {
let ry = 8.0; let dash = if dashed { r#" stroke-dasharray="4,3""# } else { "" };
svg.push_str(&format!(
r#" <path d="M{x0},{y0} L{x0},{y1} Q{x0},{y2} {cx},{y2} Q{x1},{y2} {x1},{y1} L{x1},{y0}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"{dash} filter="url(#shadow)"/>"#,
x0 = x, y0 = y + ry, y1 = y + h - ry, y2 = y + h,
x1 = x + w, cx = x + w / 2.0,
fill = style.fill, stroke = style.stroke, dash = dash,
));
svg.push('\n');
svg.push_str(&format!(
r#" <ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"{dash}/>"#,
cx = x + w / 2.0, cy = y + ry, rx = w / 2.0, ry = ry,
fill = style.stroke, stroke = style.stroke, dash = dash,
));
svg.push('\n');
}
fn render_parallelogram(svg: &mut String, x: f64, y: f64, w: f64, h: f64, style: &crate::themes::NodeStyle) {
let skew = 15.0;
svg.push_str(&format!(
r#" <path d="M{x0},{y0} L{x1},{y0} L{x2},{y1} L{x3},{y1} Z" fill="{fill}" stroke="{stroke}" stroke-width="1.5" filter="url(#shadow)"/>"#,
x0 = x + skew, y0 = y, x1 = x + w, x2 = x + w - skew, y1 = y + h, x3 = x,
fill = style.fill, stroke = style.stroke,
));
svg.push('\n');
}
fn render_user_shape(svg: &mut String, x: f64, y: f64, w: f64, h: f64, style: &crate::themes::NodeStyle) {
let r = 10.0;
svg.push_str(&format!(
r#" <rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" ry="{r}" fill="{fill}" stroke="{stroke}" stroke-width="1.5" filter="url(#shadow)"/>"#,
x = x, y = y, w = w, h = h, r = r,
fill = style.fill, stroke = style.stroke,
));
svg.push('\n');
}
fn render_cloud_rect(svg: &mut String, x: f64, y: f64, w: f64, h: f64, style: &crate::themes::NodeStyle) {
let r = 10.0;
svg.push_str(&format!(
r#" <rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" ry="{r}" fill="{fill}" stroke="{stroke}" stroke-width="1.5" stroke-dasharray="6,3" filter="url(#shadow)"/>"#,
x = x, y = y, w = w, h = h, r = r,
fill = style.fill, stroke = style.stroke,
));
svg.push('\n');
}
fn render_edge(edge: &LayoutEdge, theme: &Theme, svg: &mut String) {
if edge.points.len() < 2 { return; }
let cs = &theme.connection_style;
let (stroke, marker, dash) = match edge.arrow_kind {
ArrowKind::Solid => (&cs.stroke, "url(#arrow)", "".to_string()),
ArrowKind::Dashed => (&cs.dashed_stroke, "url(#arrow-dashed)", r#" stroke-dasharray="6,4""#.to_string()),
ArrowKind::Bidirectional => (&cs.stroke, "url(#arrow)", "".to_string()),
ArrowKind::Blocked => (&cs.blocked_stroke, "url(#arrow-blocked)", "".to_string()),
};
let start = edge.points[0];
let end = edge.points[edge.points.len() - 1];
let mut path = format!("M{},{}", start.0.round(), start.1.round());
for p in &edge.points[1..] {
path.push_str(&format!(" L{},{}", p.0.round(), p.1.round()));
}
let marker_end = format!(r#" marker-end="{}""#, marker);
let marker_start = if edge.arrow_kind == ArrowKind::Bidirectional {
format!(r#" marker-start="{}""#, marker)
} else {
String::new()
};
svg.push_str(&format!(
r#" <path d="{path}" fill="none" stroke="{stroke}" stroke-width="{sw}"{dash}{me}{ms}/>"#,
path = path,
stroke = stroke,
sw = cs.stroke_width,
dash = dash,
me = marker_end,
ms = marker_start,
));
svg.push('\n');
if let Some(ref label) = edge.label {
let mid_x = (start.0 + end.0) / 2.0;
let mid_y = (start.1 + end.1) / 2.0;
let display = truncate_label(label, 24);
let text_width = display.len() as f64 * 6.5 + 12.0;
let text_height = 18.0;
svg.push_str(&format!(
r#" <rect x="{x}" y="{y}" width="{w}" height="{h}" rx="9" ry="9" fill="{bg}"/>"#,
x = mid_x - text_width / 2.0,
y = mid_y - text_height / 2.0,
w = text_width,
h = text_height,
bg = cs.text_bg,
));
svg.push('\n');
svg.push_str(&format!(
r#" <text x="{x}" y="{y}" text-anchor="middle" dominant-baseline="central" fill="{color}" font-size="{size}">{text}</text>"#,
x = mid_x,
y = mid_y,
color = cs.text_color,
size = cs.label_size,
text = escape_xml(&display),
));
svg.push('\n');
}
if !edge.tags.is_empty() {
let mid_x = (start.0 + end.0) / 2.0;
let mid_y = (start.1 + end.1) / 2.0;
let tag_y = mid_y + if edge.label.is_some() { 14.0 } else { 0.0 };
let tag_text = edge.tags.join(", ");
let display = truncate_label(&tag_text, 16);
let tw = display.len() as f64 * 5.5 + 10.0;
svg.push_str(&format!(
r#" <rect x="{x}" y="{y}" width="{w}" height="14" rx="7" ry="7" fill="{bg}"/>"#,
x = mid_x - tw / 2.0,
y = tag_y - 7.0,
w = tw,
bg = cs.tag_bg,
));
svg.push('\n');
svg.push_str(&format!(
r#" <text x="{x}" y="{y}" text-anchor="middle" dominant-baseline="central" fill="{color}" font-size="9">{text}</text>"#,
x = mid_x,
y = tag_y,
color = cs.tag_text,
text = escape_xml(&display),
));
svg.push('\n');
}
}
fn render_groups_recursive(groups: &[LayoutGroup], theme: &Theme, svg: &mut String) {
for group in groups {
let gs = &theme.group_style;
let fill = theme.group_fill(group.depth);
let stroke = theme.group_stroke(group.depth);
let dashed = group.tags.contains(&"dashed".to_string());
let dash_attr = if dashed { r#" stroke-dasharray="6,4""# } else { "" };
svg.push_str(&format!(
r#" <rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" ry="{r}" fill="{fill}" stroke="{stroke}" stroke-width="1"{dash}/>"#,
x = group.x, y = group.y, w = group.width, h = group.height,
r = gs.corner_radius,
fill = fill, stroke = stroke, dash = dash_attr,
));
svg.push('\n');
svg.push_str(&format!(
r#" <text x="{x}" y="{y}" fill="{color}" font-size="{size}" font-weight="500">{text}</text>"#,
x = group.x + 12.0,
y = group.y + 18.0,
color = gs.text_color,
size = gs.label_size,
text = escape_xml(&group.label),
));
svg.push('\n');
render_groups_recursive(&group.children, theme, svg);
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn truncate_label(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max - 3])
}
}