use super::constants::*;
use super::parser::{ArchDiagram, ArchEdge, ArchGroup, ArchJunction, ArchService, Direction};
#[allow(unused_imports)]
use super::templates::{
self, edge_arrow, edge_label, edge_path, empty_svg, esc, fmt, group_icon_and_label,
group_plain_label, group_rect, service_group, service_icon_svg, service_label, svg_root,
};
use crate::theme::Theme;
use std::collections::HashMap;
#[derive(Clone, Debug)]
struct Pos {
x: f64,
y: f64,
}
struct Layout {
pos: HashMap<String, Pos>,
}
impl Layout {
fn new(services: &[ArchService], junctions: &[ArchJunction], edges: &[ArchEdge]) -> Self {
let mut ids: Vec<String> = services.iter().map(|s| s.id.clone()).collect();
ids.extend(junctions.iter().map(|j| j.id.clone()));
let node_group: HashMap<&str, Option<&str>> = services
.iter()
.map(|s| (s.id.as_str(), s.in_group.as_deref()))
.chain(
junctions
.iter()
.map(|j| (j.id.as_str(), j.in_group.as_deref())),
)
.collect();
let mut pos: HashMap<String, Pos> = HashMap::new();
if let Some(first) = ids.first() {
pos.insert(first.clone(), Pos { x: 0.0, y: 0.0 });
}
let mut changed = true;
let mut iterations = 0;
while changed && iterations < 200 {
changed = false;
iterations += 1;
for edge in edges {
let lhs_placed = pos.contains_key(&edge.lhs_id);
let rhs_placed = pos.contains_key(&edge.rhs_id);
if !lhs_placed && !rhs_placed {
if pos.is_empty() {
pos.insert(edge.lhs_id.clone(), Pos { x: 0.0, y: 0.0 });
changed = true;
}
continue;
}
let lhs_grp = node_group.get(edge.lhs_id.as_str()).copied().flatten();
let rhs_grp = node_group.get(edge.rhs_id.as_str()).copied().flatten();
let cross_group = lhs_grp != rhs_grp;
let h_step = if cross_group {
H_STEP + H_STEP_CROSS_EXTRA
} else {
H_STEP
};
let v_step = V_STEP;
if lhs_placed && !rhs_placed {
let lp = pos[&edge.lhs_id].clone();
let rp = find_free_pos(
offset_dir(&lp, &edge.lhs_dir, h_step, v_step),
&edge.lhs_dir,
h_step,
v_step,
&pos,
);
pos.insert(edge.rhs_id.clone(), rp);
changed = true;
} else if rhs_placed && !lhs_placed {
let rp = pos[&edge.rhs_id].clone();
let lp = find_free_pos(
offset_dir(&rp, &edge.rhs_dir, h_step, v_step),
&edge.rhs_dir,
h_step,
v_step,
&pos,
);
pos.insert(edge.lhs_id.clone(), lp);
changed = true;
}
}
}
let max_y = pos.values().map(|p| p.y).fold(f64::MIN, f64::max);
let start_y = if max_y == f64::MIN {
0.0
} else {
max_y + V_STEP * 2.0
};
let mut x_off = 0.0;
for id in &ids {
if !pos.contains_key(id) {
pos.insert(
id.clone(),
Pos {
x: x_off,
y: start_y,
},
);
x_off += H_STEP;
}
}
Layout { pos }
}
fn node_centre(&self, id: &str) -> Option<Pos> {
self.pos.get(id).cloned()
}
}
fn find_free_pos(
base: Pos,
dir: &Direction,
h_step: f64,
v_step: f64,
pos: &HashMap<String, Pos>,
) -> Pos {
let threshold = h_step.min(v_step) * 0.5;
let occupied = |p: &Pos| {
pos.values()
.any(|q| (q.x - p.x).abs() < threshold && (q.y - p.y).abs() < threshold)
};
if !occupied(&base) {
return base;
}
let perp: &[(f64, f64)] = match dir {
Direction::L | Direction::R => &[
(0.0, -v_step),
(0.0, v_step),
(0.0, -2.0 * v_step),
(0.0, 2.0 * v_step),
],
Direction::T | Direction::B => &[
(-h_step, 0.0),
(h_step, 0.0),
(-2.0 * h_step, 0.0),
(2.0 * h_step, 0.0),
],
};
for &(dx, dy) in perp {
let p = Pos {
x: base.x + dx,
y: base.y + dy,
};
if !occupied(&p) {
return p;
}
}
let mut p = offset_dir(&base, dir, h_step, v_step);
for _ in 0..10 {
if !occupied(&p) {
return p;
}
p = offset_dir(&p, dir, h_step, v_step);
}
p
}
fn offset_dir(p: &Pos, dir: &Direction, h_step: f64, v_step: f64) -> Pos {
match dir {
Direction::L => Pos {
x: p.x - h_step,
y: p.y,
},
Direction::R => Pos {
x: p.x + h_step,
y: p.y,
},
Direction::T => Pos {
x: p.x,
y: p.y - v_step,
},
Direction::B => Pos {
x: p.x,
y: p.y + v_step,
},
}
}
fn anchor(centre: &Pos, dir: &Direction) -> (f64, f64) {
let h = ICON_SIZE / 2.0;
match dir {
Direction::L => (centre.x - h, centre.y),
Direction::R => (centre.x + h, centre.y),
Direction::T => (centre.x, centre.y - h),
Direction::B => (centre.x, centre.y + h),
}
}
struct BBox {
x: f64,
y: f64,
w: f64,
h: f64,
}
fn group_bbox(
group_id: &str,
layout: &Layout,
services: &[ArchService],
junctions: &[ArchJunction],
groups: &[ArchGroup],
) -> Option<BBox> {
let mut min_x = f64::MAX;
let mut min_y = f64::MAX;
let mut max_x = f64::MIN;
let mut max_y = f64::MIN;
let mut any = false;
for svc in services {
if svc.in_group.as_deref() == Some(group_id) {
if let Some(c) = layout.node_centre(&svc.id) {
min_x = min_x.min(c.x - ICON_SIZE / 2.0);
min_y = min_y.min(c.y - ICON_SIZE / 2.0);
max_x = max_x.max(c.x + ICON_SIZE / 2.0);
max_y = max_y.max(c.y + ICON_SIZE / 2.0 + LABEL_SPACE_IN_GROUP);
any = true;
}
}
}
for jct in junctions {
if jct.in_group.as_deref() == Some(group_id) {
if let Some(c) = layout.node_centre(&jct.id) {
min_x = min_x.min(c.x - 8.0);
min_y = min_y.min(c.y - 8.0);
max_x = max_x.max(c.x + 8.0);
max_y = max_y.max(c.y + 8.0);
any = true;
}
}
}
for child in groups {
if child.in_group.as_deref() == Some(group_id) {
if let Some(cb) = group_bbox(&child.id, layout, services, junctions, groups) {
min_x = min_x.min(cb.x);
min_y = min_y.min(cb.y);
max_x = max_x.max(cb.x + cb.w);
max_y = max_y.max(cb.y + cb.h);
any = true;
}
}
}
if !any {
return None;
}
Some(BBox {
x: min_x - GROUP_PAD,
y: min_y - GROUP_PAD,
w: (max_x - min_x) + GROUP_PAD * 2.0,
h: (max_y - min_y) + GROUP_PAD * 2.0,
})
}
fn icon_inner(name: &str) -> &'static str {
match name.to_lowercase().as_str() {
"internet" => concat!(
"<rect width=\"80\" height=\"80\" style=\"fill: #087ebf; stroke-width: 0px;\"></rect>",
"<circle cx=\"40\" cy=\"40\" r=\"22.5\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></circle>",
"<line x1=\"40\" y1=\"17.5\" x2=\"40\" y2=\"62.5\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></line>",
"<line x1=\"17.5\" y1=\"40\" x2=\"62.5\" y2=\"40\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></line>",
"<path d=\"m39.99,17.51c-15.28,11.1-15.28,33.88,0,44.98\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></path>",
"<path d=\"m40.01,17.51c15.28,11.1,15.28,33.88,0,44.98\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></path>",
"<line x1=\"19.75\" y1=\"30.1\" x2=\"60.25\" y2=\"30.1\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></line>",
"<line x1=\"19.75\" y1=\"49.9\" x2=\"60.25\" y2=\"49.9\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></line>",
),
"database" => concat!(
"<rect width=\"80\" height=\"80\" style=\"fill: #087ebf; stroke-width: 0px;\"></rect>",
"<path id=\"IconifyId19e371fabfe869c2d0\" data-name=\"4\" d=\"m20,57.86c0,3.94,8.95,7.14,20,7.14s20-3.2,20-7.14\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></path>",
"<path id=\"IconifyId19e371fabfe869c2d1\" data-name=\"3\" d=\"m20,45.95c0,3.94,8.95,7.14,20,7.14s20-3.2,20-7.14\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></path>",
"<path id=\"IconifyId19e371fabfe869c2d2\" data-name=\"2\" d=\"m20,34.05c0,3.94,8.95,7.14,20,7.14s20-3.2,20-7.14\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></path>",
"<ellipse id=\"IconifyId19e371fabfe869c2d3\" data-name=\"1\" cx=\"40\" cy=\"22.14\" rx=\"20\" ry=\"7.14\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></ellipse>",
"<line x1=\"20\" y1=\"57.86\" x2=\"20\" y2=\"22.14\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></line>",
"<line x1=\"60\" y1=\"57.86\" x2=\"60\" y2=\"22.14\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></line>",
),
"server" => concat!(
"<rect width=\"80\" height=\"80\" style=\"fill: #087ebf; stroke-width: 0px;\"></rect>",
"<rect x=\"17.5\" y=\"17.5\" width=\"45\" height=\"45\" rx=\"2\" ry=\"2\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></rect>",
"<line x1=\"17.5\" y1=\"32.5\" x2=\"62.5\" y2=\"32.5\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></line>",
"<line x1=\"17.5\" y1=\"47.5\" x2=\"62.5\" y2=\"47.5\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></line>",
"<g><path d=\"m56.25,25c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z\" style=\"fill: #fff; stroke-width: 0px;\"></path><path d=\"m56.25,25c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10;\"></path></g>",
"<g><path d=\"m56.25,40c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z\" style=\"fill: #fff; stroke-width: 0px;\"></path><path d=\"m56.25,40c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10;\"></path></g>",
"<g><path d=\"m56.25,55c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z\" style=\"fill: #fff; stroke-width: 0px;\"></path><path d=\"m56.25,55c0,.27-.45.5-1,.5h-10.5c-.55,0-1-.23-1-.5s.45-.5,1-.5h10.5c.55,0,1,.23,1,.5Z\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10;\"></path></g>",
"<g><circle cx=\"32.5\" cy=\"25\" r=\".75\" style=\"fill: #fff; stroke: #fff; stroke-miterlimit: 10;\"></circle><circle cx=\"27.5\" cy=\"25\" r=\".75\" style=\"fill: #fff; stroke: #fff; stroke-miterlimit: 10;\"></circle><circle cx=\"22.5\" cy=\"25\" r=\".75\" style=\"fill: #fff; stroke: #fff; stroke-miterlimit: 10;\"></circle></g>",
"<g><circle cx=\"32.5\" cy=\"40\" r=\".75\" style=\"fill: #fff; stroke: #fff; stroke-miterlimit: 10;\"></circle><circle cx=\"27.5\" cy=\"40\" r=\".75\" style=\"fill: #fff; stroke: #fff; stroke-miterlimit: 10;\"></circle><circle cx=\"22.5\" cy=\"40\" r=\".75\" style=\"fill: #fff; stroke: #fff; stroke-miterlimit: 10;\"></circle></g>",
"<g><circle cx=\"32.5\" cy=\"55\" r=\".75\" style=\"fill: #fff; stroke: #fff; stroke-miterlimit: 10;\"></circle><circle cx=\"27.5\" cy=\"55\" r=\".75\" style=\"fill: #fff; stroke: #fff; stroke-miterlimit: 10;\"></circle><circle cx=\"22.5\" cy=\"55\" r=\".75\" style=\"fill: #fff; stroke: #fff; stroke-miterlimit: 10;\"></circle></g>",
),
"disk" => concat!(
"<rect width=\"80\" height=\"80\" style=\"fill: #087ebf; stroke-width: 0px;\"></rect>",
"<rect x=\"20\" y=\"15\" width=\"40\" height=\"50\" rx=\"1\" ry=\"1\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></rect>",
"<ellipse cx=\"24\" cy=\"19.17\" rx=\".8\" ry=\".83\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></ellipse>",
"<ellipse cx=\"56\" cy=\"19.17\" rx=\".8\" ry=\".83\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></ellipse>",
"<ellipse cx=\"24\" cy=\"60.83\" rx=\".8\" ry=\".83\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></ellipse>",
"<ellipse cx=\"56\" cy=\"60.83\" rx=\".8\" ry=\".83\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></ellipse>",
"<ellipse cx=\"40\" cy=\"33.75\" rx=\"14\" ry=\"14.58\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></ellipse>",
"<ellipse cx=\"40\" cy=\"33.75\" rx=\"4\" ry=\"4.17\" style=\"fill: #fff; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></ellipse>",
"<path d=\"m37.51,42.52l-4.83,13.22c-.26.71-1.1,1.02-1.76.64l-4.18-2.42c-.66-.38-.81-1.26-.33-1.84l9.01-10.8c.88-1.05,2.56-.08,2.09,1.2Z\" style=\"fill: #fff; stroke-width: 0px;\"></path>",
),
"cloud" => concat!(
"<rect width=\"80\" height=\"80\" style=\"fill: #087ebf; stroke-width: 0px;\"></rect>",
"<path d=\"m65,47.5c0,2.76-2.24,5-5,5H20c-2.76,0-5-2.24-5-5,0-1.87,1.03-3.51,2.56-4.36-.04-.21-.06-.42-.06-.64,0-2.6,2.48-4.74,5.65-4.97,1.65-4.51,6.34-7.76,11.85-7.76.86,0,1.69.08,2.5.23,2.09-1.57,4.69-2.5,7.5-2.5,6.1,0,11.19,4.38,12.28,10.17,2.14.56,3.72,2.51,3.72,4.83,0,.03,0,.07-.01.1,2.29.46,4.01,2.48,4.01,4.9Z\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></path>",
),
"lock" => concat!(
"<rect width=\"80\" height=\"80\" style=\"fill: #087ebf; stroke-width: 0px;\"></rect>",
"<rect x=\"22\" y=\"38\" width=\"36\" height=\"26\" rx=\"3\" ry=\"3\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></rect>",
"<path d=\"m56,38v-8c0-8.84-7.16-16-16-16s-16,7.16-16,16v8\" style=\"fill: none; stroke: #fff; stroke-miterlimit: 10; stroke-width: 2px;\"></path>",
"<circle cx=\"40\" cy=\"51\" r=\"4\" style=\"fill: #fff;\"></circle>",
"<line x1=\"40\" y1=\"55\" x2=\"40\" y2=\"60\" style=\"stroke: #fff; stroke-width: 2px;\"></line>",
),
_ => "<rect width=\"80\" height=\"80\" style=\"fill: #087ebf; stroke-width: 0px;\"></rect>",
}
}
pub fn render(diag: &ArchDiagram, theme: Theme) -> String {
let vars = theme.resolve();
let line_color = vars.line_color;
let layout = Layout::new(&diag.services, &diag.junctions, &diag.edges);
let node_ids: Vec<&str> = diag
.services
.iter()
.map(|s| s.id.as_str())
.chain(diag.junctions.iter().map(|j| j.id.as_str()))
.collect();
if node_ids.is_empty() {
return empty_svg(vars.font_family);
}
let mut node_min_x = f64::MAX;
let mut node_min_y = f64::MAX;
let mut node_max_x = f64::MIN;
let mut node_max_y = f64::MIN;
for id in &node_ids {
if let Some(c) = layout.node_centre(id) {
node_min_x = node_min_x.min(c.x - ICON_SIZE / 2.0);
node_min_y = node_min_y.min(c.y - ICON_SIZE / 2.0);
node_max_x = node_max_x.max(c.x + ICON_SIZE / 2.0);
node_max_y = node_max_y.max(c.y + ICON_SIZE / 2.0 + LABEL_SPACE);
}
}
let mut grp_min_x = node_min_x;
let mut grp_min_y = node_min_y;
let mut grp_max_x = node_max_x;
let mut grp_max_y = node_max_y;
for grp in &diag.groups {
if grp.in_group.is_none() {
if let Some(bb) = group_bbox(
&grp.id,
&layout,
&diag.services,
&diag.junctions,
&diag.groups,
) {
grp_min_x = grp_min_x.min(bb.x);
grp_min_y = grp_min_y.min(bb.y);
grp_max_x = grp_max_x.max(bb.x + bb.w);
grp_max_y = grp_max_y.max(bb.y + bb.h);
}
}
}
let vb_x = grp_min_x - OUTER_MARGIN;
let vb_y = grp_min_y - OUTER_MARGIN;
let vb_w = (grp_max_x - grp_min_x) + OUTER_MARGIN * 2.0;
let vb_h = (grp_max_y - grp_min_y) + OUTER_MARGIN * 2.0;
let mut out = String::new();
out.push_str(&svg_root(vb_w, vb_x, vb_y, vb_w, vb_h, vars.font_family));
out.push_str("<g></g>");
out.push_str("<g class=\"architecture-edges\">");
for edge in &diag.edges {
render_edge(edge, &layout, line_color, vars.font_family, &mut out);
}
out.push_str("</g>");
out.push_str("<g class=\"architecture-services\">");
for svc in &diag.services {
render_service(svc, &layout, vars.text_color, &mut out);
}
out.push_str("</g>");
out.push_str("<g class=\"architecture-groups\">");
for grp in &diag.groups {
if grp.in_group.is_none() {
render_group_recursive(
grp,
&layout,
&diag.services,
&diag.junctions,
&diag.groups,
vars.text_color,
&mut out,
);
}
}
out.push_str("</g>");
out.push_str("</svg>");
out
}
fn render_group_recursive(
grp: &ArchGroup,
layout: &Layout,
services: &[ArchService],
junctions: &[ArchJunction],
groups: &[ArchGroup],
text_color: &str,
out: &mut String,
) {
let bb = match group_bbox(&grp.id, layout, services, junctions, groups) {
Some(b) => b,
None => return,
};
out.push_str(&group_rect(&esc(&grp.id), bb.x, bb.y, bb.w, bb.h));
if let Some(icon) = &grp.icon {
let icon_x = bb.x + 1.0;
let icon_y = bb.y + 1.0;
let label = grp.title.as_deref().unwrap_or(&grp.id);
let label_tx = icon_x + 33.0; let label_ty = icon_y + 6.0; out.push_str(&group_icon_and_label(
icon_x,
icon_y,
icon_inner(icon),
label_tx,
label_ty,
&esc(label),
text_color,
));
} else {
let label = grp.title.as_deref().unwrap_or(&grp.id);
out.push_str(&group_plain_label(
bb.x + 10.0,
bb.y + 18.0,
&esc(label),
text_color,
));
}
for child in groups {
if child.in_group.as_deref() == Some(&grp.id) {
render_group_recursive(child, layout, services, junctions, groups, text_color, out);
}
}
}
fn render_service(svc: &ArchService, layout: &Layout, text_color: &str, out: &mut String) {
let centre = match layout.node_centre(&svc.id) {
Some(c) => c,
None => return,
};
let icon_left = centre.x - ICON_SIZE / 2.0;
let icon_top = centre.y - ICON_SIZE / 2.0;
let label = svc.title.as_deref().unwrap_or(&svc.id);
out.push_str(&service_group(&esc(&svc.id), icon_left, icon_top));
out.push_str(&service_label(&esc(label), text_color));
out.push_str("<g><g>");
let inner = svc.icon.as_deref().map(icon_inner).unwrap_or(
"<rect width=\"80\" height=\"80\" style=\"fill: #087ebf; stroke-width: 0px;\"></rect>",
);
out.push_str(&service_icon_svg(inner));
out.push_str("</g></g>");
out.push_str("</g>");
}
fn render_edge(edge: &ArchEdge, layout: &Layout, line_color: &str, ff: &str, out: &mut String) {
let lc = match layout.node_centre(&edge.lhs_id) {
Some(c) => c,
None => return,
};
let rc = match layout.node_centre(&edge.rhs_id) {
Some(c) => c,
None => return,
};
let (sx, sy) = anchor(&lc, &edge.lhs_dir);
let (tx, ty) = anchor(&rc, &edge.rhs_dir);
let mid_x = (sx + tx) / 2.0;
let mid_y = (sy + ty) / 2.0;
let path = format!(
"M {},{} L {},{} L{},{} ",
fmt(sx),
fmt(sy),
fmt(mid_x),
fmt(mid_y),
fmt(tx),
fmt(ty)
);
out.push_str("<g>");
out.push_str(&edge_path(
&path,
&esc(&edge.lhs_id),
&esc(&edge.rhs_id),
line_color,
));
if edge.rhs_into {
let (pts, t) = arrow_angled(tx, ty, mid_x, mid_y);
out.push_str(&edge_arrow(&pts, &t, line_color));
}
if edge.lhs_into {
let (pts, t) = arrow_angled(sx, sy, mid_x, mid_y);
out.push_str(&edge_arrow(&pts, &t, line_color));
}
if let Some(label) = &edge.title {
let lx = (sx + tx) / 2.0;
let ly = (sy + ty) / 2.0 - 6.0;
out.push_str(&edge_label(lx, ly, &esc(label), line_color, ff));
}
out.push_str("</g>");
}
fn arrow_angled(tip_x: f64, tip_y: f64, from_x: f64, from_y: f64) -> (String, String) {
let a = 40.0_f64 / 3.0;
let ha = a / 2.0;
let angle_deg = (tip_y - from_y).atan2(tip_x - from_x).to_degrees();
let pts = format!("0,0 {},{} {},{}", fmt(-a), fmt(-ha), fmt(-a), fmt(ha));
let t = format!(
"translate({},{}) rotate({})",
fmt(tip_x),
fmt(tip_y),
fmt(angle_deg)
);
(pts, t)
}
#[allow(dead_code)]
fn arrow_at(centre: &Pos, dir: &Direction, _anchor_x: f64, _anchor_y: f64) -> (String, String) {
let a = 40.0_f64 / 3.0; let ha = a / 2.0; let (cx, cy) = (centre.x, centre.y);
let h = ICON_SIZE / 2.0;
match dir {
Direction::L => {
let left = cx - h;
let pts = format!("{},{} 0,{} 0,0", fmt(a), fmt(ha), fmt(a));
let t = format!("translate({},{})", fmt(left - (a - 2.0)), fmt(cy - ha));
(pts, t)
}
Direction::R => {
let right = cx + h;
let pts = format!("0,{} {},{} {},0", fmt(ha), fmt(a), fmt(a), fmt(a));
let t = format!("translate({},{})", fmt(right - 2.0), fmt(cy - ha));
(pts, t)
}
Direction::T => {
let top = cy - h;
let pts = format!("0,0 {},0 {},{}", fmt(a), fmt(ha), fmt(a));
let t = format!("translate({},{})", fmt(cx - ha), fmt(top - (a - 2.0)));
(pts, t)
}
Direction::B => {
let bottom = cy + h;
let pts = format!("0,{} {},{} {},0", fmt(a), fmt(a), fmt(a), fmt(ha));
let t = format!("translate({},{})", fmt(cx - ha), fmt(bottom - 2.0));
(pts, t)
}
}
}
#[cfg(test)]
mod tests {
use super::super::parser;
use super::*;
const ARCH_BASIC: &str = "architecture-beta\n service api(internet)[API]\n service db(database)[Database]\n api:R --> L:db";
#[test]
fn basic_render_produces_svg() {
let diag = parser::parse(ARCH_BASIC).diagram;
let svg = render(&diag, Theme::Default);
assert!(svg.contains("<svg"), "missing <svg tag");
assert!(svg.contains("API"), "missing service label");
}
#[test]
fn dark_theme() {
let diag = parser::parse(ARCH_BASIC).diagram;
let svg = render(&diag, Theme::Dark);
assert!(svg.contains("<svg"), "missing <svg tag");
}
#[test]
fn snapshot_default_theme() {
let diag = parser::parse(ARCH_BASIC).diagram;
let svg = render(&diag, crate::theme::Theme::Default);
insta::assert_snapshot!(crate::svg::normalize_floats(&svg));
}
}