use crate::preview::mermaid::flowchart::Stroke;
use crate::preview::mermaid::layout::Point;
use crate::preview::mermaid::text_metrics::{FONT_FAMILY, FONT_SIZE};
use super::clusters;
use super::edges;
use super::edges::Tip;
use super::labels::Label;
use super::panel::Panel;
use super::shapes::{Glyph, Outline};
use super::theme::Theme;
use super::{shapes, Diagram, PlacedCluster, PlacedEdge, PlacedNode};
pub const NODE_STROKE_WIDTH: f64 = 1.5;
pub const EDGE_STROKE_WIDTH: f64 = 2.0;
pub const THICK_STROKE_WIDTH: f64 = 3.5;
pub const DOTTED_DASH: &str = "4 4";
pub const CLUSTER_DASH: &str = "6 4";
pub const LIFELINE_STROKE_WIDTH: f64 = 1.0;
pub const LIFELINE_DASH: &str = "3 4";
pub const DESTROY_HALF: f64 = 7.0;
pub const RIBBON_OPACITY: f64 = 0.45;
pub fn wedge_path(cx: f64, cy: f64, r: f64, start: f64, sweep: f64) -> String {
let sweep = sweep.clamp(0.0, 360.0);
if sweep >= 360.0 - 1e-9 {
return format!(
"M{cx},{top} A{r},{r} 0 1 1 {cx2},{bottom} A{r},{r} 0 1 1 {cx3},{top2} Z",
cx = num(cx),
cx2 = num(cx),
cx3 = num(cx),
top = num(cy - r),
top2 = num(cy - r),
bottom = num(cy + r),
r = num(r)
);
}
let at = |deg: f64| {
let a = (deg - 90.0).to_radians();
(cx + r * a.cos(), cy + r * a.sin())
};
let (x0, y0) = at(start);
let (x1, y1) = at(start + sweep);
let large = usize::from(sweep > 180.0);
format!(
"M{},{} L{},{} A{r},{r} 0 {large} 1 {},{} Z",
num(cx),
num(cy),
num(x0),
num(y0),
num(x1),
num(y1),
r = num(r)
)
}
fn radial_polygon(cx: f64, cy: f64, r: f64, sides: usize) -> String {
(0..sides)
.map(|k| {
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * k as f64 / sides as f64;
format!("{},{}", num(cx + r * a.cos()), num(cy + r * a.sin()))
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn num(v: f64) -> String {
let r = (v * 1000.0).round() / 1000.0;
let s = format!("{r}");
if s == "-0" {
"0".to_string()
} else {
s
}
}
pub fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
out
}
pub fn emit(diagram: &Diagram, theme: &Theme) -> String {
let mut out = String::with_capacity(1024 + diagram.nodes.len() * 256);
out.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" \
viewBox=\"0 0 {w} {h}\">\n",
w = num(diagram.width),
h = num(diagram.height)
));
out.push_str(&format!(
"<rect width=\"{}\" height=\"{}\" fill=\"{}\"/>\n",
num(diagram.width),
num(diagram.height),
theme.background_paint
));
if !diagram.clusters.is_empty() {
out.push_str("<g class=\"clusters\">\n");
for c in &diagram.clusters {
emit_cluster(&mut out, c, theme);
}
out.push_str("</g>\n");
}
if !diagram.lifelines.is_empty() {
out.push_str("<g class=\"lifelines\">\n");
for l in &diagram.lifelines {
emit_lifeline(&mut out, l, theme);
}
out.push_str("</g>\n");
}
out.push_str("<g class=\"edges\">\n");
for e in diagram.edges.iter().filter(|e| !e.overlay) {
emit_edge(&mut out, e, theme);
}
out.push_str("</g>\n<g class=\"nodes\">\n");
for n in &diagram.nodes {
emit_node(&mut out, n, theme);
}
out.push_str("</g>\n");
if diagram.edges.iter().any(|e| e.overlay) {
out.push_str("<g class=\"series\">\n");
for e in diagram.edges.iter().filter(|e| e.overlay) {
emit_edge(&mut out, e, theme);
}
out.push_str("</g>\n");
}
out.push_str("<g class=\"edge-labels\">\n");
for e in &diagram.edges {
if let Some(l) = &e.label {
let resized = theme
.tokens
.map(|t| l.label.resized(t.edge_label_font_size));
let drawn = resized.as_ref().unwrap_or(&l.label);
let patch = match &resized {
Some(d) => super::Size::new(
d.width + super::LABEL_PAD_X * 2.0,
d.height + super::LABEL_PAD_Y * 2.0,
),
None => l.size,
};
if label_meets_its_line(e, l) {
out.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\"/>\n",
num(l.center.x - patch.w / 2.0),
num(l.center.y - patch.h / 2.0),
num(patch.w),
num(patch.h),
theme.background_ref
));
}
let label_color = e
.style
.as_ref()
.and_then(|s| {
s.text.as_deref().or_else(|| {
theme
.tokens
.filter(|t| t.ink_follows_line)
.and(s.stroke.as_deref())
})
})
.unwrap_or(theme.edge_label_text);
emit_text(&mut out, drawn, l.center.x, l.center.y, label_color);
}
for l in [&e.start_label, &e.end_label].into_iter().flatten() {
emit_text(
&mut out,
&l.label,
l.center.x,
l.center.y,
theme.edge_label_text,
);
}
if let Some(b) = &e.badge {
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{}\" stroke=\"{}\" \
stroke-width=\"{}\"/>\n",
num(b.center.x),
num(b.center.y),
num(b.size.w.max(b.size.h) / 2.0),
theme.node_fill,
theme.node_stroke,
num(clusters::STROKE_WIDTH)
));
emit_text(&mut out, &b.label, b.center.x, b.center.y, theme.node_text);
}
}
out.push_str("</g>\n");
if diagram
.clusters
.iter()
.any(|c| !c.title.is_blank() || !c.sections.is_empty())
{
out.push_str("<g class=\"cluster-labels\">\n");
for c in &diagram.clusters {
emit_cluster_title(&mut out, c, theme);
}
out.push_str("</g>\n");
}
out.push_str("</svg>\n");
out
}
fn label_meets_its_line(edge: &PlacedEdge, label: &super::PlacedEdgeLabel) -> bool {
let (l, t, r, b) = (
label.center.x - label.size.w / 2.0,
label.center.y - label.size.h / 2.0,
label.center.x + label.size.w / 2.0,
label.center.y + label.size.h / 2.0,
);
let inside = |p: &Point| p.x >= l && p.x <= r && p.y >= t && p.y <= b;
let points = edge.drawn_points();
for w in points.windows(2) {
let len = (w[1].x - w[0].x).hypot(w[1].y - w[0].y);
let steps = ((len / 2.0).ceil() as usize).clamp(1, 2000);
for i in 0..=steps {
let f = i as f64 / steps as f64;
if inside(&Point::new(
w[0].x + f * (w[1].x - w[0].x),
w[0].y + f * (w[1].y - w[0].y),
)) {
return true;
}
}
}
false
}
fn frame_stroke(cluster: &PlacedCluster, theme: &Theme) -> &'static str {
match theme.tokens {
Some(t) if cluster.title_strip => t.composite_stroke,
_ => theme.cluster_stroke,
}
}
fn emit_cluster(out: &mut String, cluster: &PlacedCluster, theme: &Theme) {
let (l, t, _, _) = cluster.bounds();
let dash_pattern = theme.tokens.map_or(CLUSTER_DASH, |t| t.cluster_dash);
let dashed = cluster.dashed || (theme.tokens.is_some() && !cluster.title_strip);
let dash = if dashed {
format!(" stroke-dasharray=\"{dash_pattern}\"")
} else {
String::new()
};
let filled =
cluster.filled && !cluster.title_strip && theme.tokens.is_none_or(|t| t.cluster_filled);
let radius = theme
.tokens
.map_or(clusters::CORNER_RADIUS, |t| t.frame_radius);
if cluster.title_strip && !cluster.title.is_blank() {
let (_, _, right, _) = cluster.bounds();
let strip_bottom = t + clusters::TITLE_PAD_Y * 2.0 + cluster.title.height;
let inset = clusters::STROKE_WIDTH / 2.0;
let x0 = l + inset;
let x1 = right - inset;
let y0 = t + inset;
let r = (radius - inset).max(0.0);
out.push_str(&format!(
"<path d=\"M{x0_r},{y0} H{x1_r} A{r},{r} 0 0 1 {x1},{y0_r} V{bottom} H{x0} \
V{y0_r} A{r},{r} 0 0 1 {x0_r},{y0} Z\" fill=\"{fill}\"/>\n",
x0_r = num(x0 + r),
y0 = num(y0),
x1_r = num(x1 - r),
r = num(r),
x1 = num(x1),
y0_r = num(y0 + r),
bottom = num(strip_bottom),
x0 = num(x0),
fill = theme.node_fill,
));
out.push_str(&format!(
"<line x1=\"{}\" y1=\"{y}\" x2=\"{}\" y2=\"{y}\" stroke=\"{}\" stroke-width=\"1\"/>\n",
num(l),
num(right),
theme
.tokens
.map_or(theme.cluster_stroke, |t| t.composite_rule),
y = num(strip_bottom)
));
}
out.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"{r}\" ry=\"{r}\" \
fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\"{dash}/>\n",
num(l),
num(t),
num(cluster.size.w),
num(cluster.size.h),
if filled { theme.cluster_fill } else { "none" },
frame_stroke(cluster, theme),
num(clusters::STROKE_WIDTH),
r = num(radius)
));
let (_, _, right, _) = cluster.bounds();
for section in &cluster.sections {
out.push_str(&format!(
"<line x1=\"{}\" y1=\"{y}\" x2=\"{}\" y2=\"{y}\" stroke=\"{}\" \
stroke-width=\"{}\" stroke-dasharray=\"{CLUSTER_DASH}\"/>\n",
num(l),
num(right),
theme.cluster_stroke,
num(clusters::STROKE_WIDTH),
y = num(section.y)
));
}
}
fn emit_cluster_title(out: &mut String, cluster: &PlacedCluster, theme: &Theme) {
for section in &cluster.sections {
if section.title.is_blank() {
continue;
}
emit_text(
out,
§ion.title,
cluster.center.x,
section.y + clusters::TITLE_PAD_Y + section.title.height / 2.0,
theme.cluster_text,
);
}
if cluster.title.is_blank() {
return;
}
if cluster.title_strip {
let (l, t, _, _) = cluster.bounds();
let strip_bottom = t + clusters::TITLE_PAD_Y * 2.0 + cluster.title.height;
emit_anchored_title(
out,
&cluster.title,
l + clusters::TITLE_PAD_X,
(t + strip_bottom) / 2.0,
theme
.tokens
.map_or(theme.cluster_text, |t| t.composite_text),
STRIP_FONT_SIZE,
);
return;
}
if let Some(tokens) = theme.tokens.filter(|t| t.title_left_aligned) {
let (l, t, _, _) = cluster.bounds();
emit_anchored_title(
out,
&cluster.title,
l + clusters::TITLE_PAD_X,
t + clusters::TITLE_PAD_Y + cluster.title.height / 2.0,
theme.cluster_text,
tokens.title_font_size,
);
return;
}
let c = cluster.title_center();
emit_text(out, &cluster.title, c.x, c.y, theme.cluster_text);
}
const STRIP_FONT_SIZE: f64 = 11.0;
fn emit_anchored_title(
out: &mut String,
label: &Label,
x: f64,
cy: f64,
fill: &str,
font_size: f64,
) {
if label.is_blank() {
return;
}
out.push_str(&format!(
"<text x=\"{}\" y=\"{}\" text-anchor=\"start\" font-family=\"{}\" font-size=\"{}\" \
fill=\"{}\">{}</text>\n",
num(x),
num(cy + font_size * super::labels::BASELINE_RATIO),
FONT_FAMILY,
num(font_size),
fill,
escape(&label.lines.join(" "))
));
}
fn node_radius(node: &PlacedNode, theme: &Theme, shaped: f64) -> f64 {
let ordinary_box = matches!(
node.shape,
Glyph::Flow(crate::preview::mermaid::flowchart::Shape::Rect)
| Glyph::Flow(crate::preview::mermaid::flowchart::Shape::RoundedRect)
| Glyph::TitledBox
);
match theme.tokens {
Some(t) if ordinary_box => t.node_radius,
_ => shaped,
}
}
fn emit_node(out: &mut String, node: &PlacedNode, theme: &Theme) {
let (cx, cy) = (node.center.x, node.center.y);
let (mut fill, mut stroke, mut text) = match node.shape {
Glyph::Note => (theme.note_fill, theme.note_stroke, theme.note_text),
Glyph::StateStart | Glyph::StateEnd | Glyph::Bar { .. } => {
(theme.state_marker, theme.state_marker, theme.node_text)
}
Glyph::PlotFrame | Glyph::Graticule => ("none", theme.axis, theme.node_text),
Glyph::ChartLabel => (
"none",
"none",
if node.series.is_some() {
theme.series_text
} else {
theme.node_text
},
),
Glyph::Wedge | Glyph::ChartBar | Glyph::ChartPoint | Glyph::Ribbon => (
node.series.map_or(theme.node_fill, |i| theme.series(i)),
theme.node_stroke,
theme.series_text,
),
_ if node.series.is_some() => (
node.series.map_or(theme.node_fill, |i| theme.series(i)),
theme.node_stroke,
theme.series_text,
),
_ => (theme.node_fill, theme.node_stroke, theme.node_text),
};
let mut sw = NODE_STROKE_WIDTH;
let tinted: Option<String> = match (&node.style, theme.tokens) {
(Some(s), Some(t)) if t.tint_class_fill && s.fill.is_none() => {
s.stroke.as_deref().and_then(super::style::tint)
}
_ => None,
};
if let Some(v) = &tinted {
fill = v.as_str();
}
if let Some(s) = &node.style {
if let Some(v) = s.fill.as_deref() {
fill = v;
}
if let Some(v) = s.stroke.as_deref() {
stroke = v;
}
if let Some(v) = s.text.as_deref() {
text = v;
}
if let Some(v) = s.stroke_width {
sw = v;
}
}
let sw = num(sw);
match shapes::outline(node.shape, node.size, node.mark) {
Outline::Rect { w, h, r } => {
let r = node_radius(node, theme, r);
out.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" rx=\"{}\" ry=\"{}\" \
fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
num(cx - w / 2.0),
num(cy - h / 2.0),
num(w),
num(h),
num(r),
num(r)
));
}
Outline::Circle { r } => {
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{fill}\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n",
num(cx),
num(cy),
num(r)
));
}
Outline::DoubleCircle { outer, inner } => {
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{fill}\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n",
num(cx),
num(cy),
num(outer)
));
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"none\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n",
num(cx),
num(cy),
num(inner)
));
}
Outline::Subroutine { w, h, inset } => {
out.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{fill}\" \
stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
num(cx - w / 2.0),
num(cy - h / 2.0),
num(w),
num(h)
));
for side in [-1.0_f64, 1.0] {
let x = cx + side * (w / 2.0 - inset);
out.push_str(&format!(
"<line x1=\"{x}\" y1=\"{y0}\" x2=\"{x}\" y2=\"{y1}\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n",
x = num(x),
y0 = num(cy - h / 2.0),
y1 = num(cy + h / 2.0)
));
}
}
Outline::Cylinder { w, h, ry } => {
let (l, r) = (cx - w / 2.0, cx + w / 2.0);
let (y0, y1) = (cy - h / 2.0 + ry, cy + h / 2.0 - ry);
let rx = w / 2.0;
out.push_str(&format!(
"<path d=\"M{l},{y0} A{rx},{ry} 0 0 1 {r},{y0} L{r},{y1} A{rx},{ry} 0 0 1 \
{l},{y1} Z\" fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
l = num(l),
r = num(r),
y0 = num(y0),
y1 = num(y1),
rx = num(rx),
ry = num(ry)
));
out.push_str(&format!(
"<path d=\"M{l},{y0} A{rx},{ry} 0 0 0 {r},{y0}\" fill=\"none\" \
stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
l = num(l),
r = num(r),
y0 = num(y0),
rx = num(rx),
ry = num(ry)
));
}
Outline::Polygon(points) => {
let pts = points
.iter()
.map(|p| format!("{},{}", num(cx + p.x), num(cy + p.y)))
.collect::<Vec<_>>()
.join(" ");
out.push_str(&format!(
"<polygon points=\"{pts}\" fill=\"{fill}\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n"
));
}
Outline::Disc { r } => {
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{fill}\"/>\n",
num(cx),
num(cy),
num(r)
));
}
Outline::Target { outer, inner } => {
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"none\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n",
num(cx),
num(cy),
num(outer - NODE_STROKE_WIDTH / 2.0)
));
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{fill}\"/>\n",
num(cx),
num(cy),
num(inner)
));
}
Outline::Bar { w, h } => {
out.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{fill}\"/>\n",
num(cx - w / 2.0),
num(cy - h / 2.0),
num(w),
num(h)
));
}
Outline::Note { w, h, fold } => {
let (l, r) = (cx - w / 2.0, cx + w / 2.0);
let (t, b) = (cy - h / 2.0, cy + h / 2.0);
out.push_str(&format!(
"<path d=\"M{l},{t} L{fx},{t} L{r},{fy} L{r},{b} L{l},{b} Z\" fill=\"{fill}\" \
stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
l = num(l),
t = num(t),
r = num(r),
b = num(b),
fx = num(r - fold),
fy = num(t + fold)
));
out.push_str(&format!(
"<path d=\"M{fx},{t} L{fx},{fy} L{r},{fy}\" fill=\"none\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n",
t = num(t),
r = num(r),
fx = num(r - fold),
fy = num(t + fold)
));
}
Outline::Actor { w, h, figure_h } => {
let top = cy - h / 2.0;
let r = shapes::ACTOR_HEAD_RADIUS.min(figure_h / 4.0);
let head_y = top + r + 1.0;
let shoulder = head_y + r;
let hip = top + figure_h * 0.68;
let foot = top + figure_h - 1.0;
let reach = (w / 2.0 - 2.0).min(figure_h * 0.30);
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"none\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n",
num(cx),
num(head_y),
num(r)
));
out.push_str(&format!(
"<path d=\"M{cx},{shoulder} L{cx},{hip} M{al},{arms} L{ar},{arms} \
M{cx},{hip} L{ll},{foot} M{cx},{hip} L{lr},{foot}\" fill=\"none\" \
stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
cx = num(cx),
shoulder = num(shoulder),
hip = num(hip),
arms = num(shoulder + (hip - shoulder) * 0.35),
al = num(cx - reach),
ar = num(cx + reach),
ll = num(cx - reach * 0.8),
lr = num(cx + reach * 0.8),
foot = num(foot)
));
}
Outline::Wedge { r, start, sweep } => {
out.push_str(&format!(
"<path d=\"{}\" fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\n",
wedge_path(cx, cy, r, start, sweep)
));
}
Outline::Ribbon {
w,
left_top,
left_bottom,
right_top,
right_bottom,
} => {
let (l, r) = (cx - w / 2.0, cx + w / 2.0);
let mid = (l + r) / 2.0;
out.push_str(&format!(
"<path d=\"M{l},{lt} C{mid},{lt} {mid},{rt} {r},{rt} L{r},{rb} \
C{mid},{rb} {mid},{lb} {l},{lb} Z\" fill=\"{fill}\" \
fill-opacity=\"{RIBBON_OPACITY}\" stroke=\"none\"/>\n",
l = num(l),
r = num(r),
mid = num(mid),
lt = num(cy + left_top),
lb = num(cy + left_bottom),
rt = num(cy + right_top),
rb = num(cy + right_bottom),
));
}
Outline::Graticule {
r,
rings,
spokes,
polygon,
} => {
for i in 1..=rings.max(1) {
let rr = r * i as f64 / rings.max(1) as f64;
if polygon && spokes >= 3 {
let pts = radial_polygon(cx, cy, rr, spokes);
out.push_str(&format!(
"<polygon points=\"{pts}\" fill=\"none\" stroke=\"{stroke}\" \
stroke-width=\"{}\"/>\n",
num(clusters::STROKE_WIDTH)
));
} else {
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"none\" \
stroke=\"{stroke}\" stroke-width=\"{}\"/>\n",
num(cx),
num(cy),
num(rr),
num(clusters::STROKE_WIDTH)
));
}
}
let mut d = String::new();
for k in 0..spokes {
let a =
-std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * k as f64 / spokes as f64;
d.push_str(&format!(
"M{},{} L{},{} ",
num(cx),
num(cy),
num(cx + r * a.cos()),
num(cy + r * a.sin())
));
}
if !d.is_empty() {
out.push_str(&format!(
"<path d=\"{}\" fill=\"none\" stroke=\"{stroke}\" stroke-width=\"{}\"/>\n",
d.trim_end(),
num(clusters::STROKE_WIDTH)
));
}
}
Outline::Cloud { w, h } => {
let (l, r) = (cx - w / 2.0, cx + w / 2.0);
let (t, b) = (cy - h / 2.0, cy + h / 2.0);
out.push_str(&format!(
"<path d=\"M{l},{cy} Q{l},{t} {cx},{t} Q{r},{t} {r},{cy} Q{r},{b} {cx},{b} \
Q{l},{b} {l},{cy} Z\" fill=\"{fill}\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n",
l = num(l),
r = num(r),
t = num(t),
b = num(b),
cx = num(cx),
cy = num(cy),
));
}
Outline::Underline { w, h } => {
let y = cy + h / 2.0;
out.push_str(&format!(
"<path d=\"M{},{y} L{},{y}\" stroke=\"{stroke}\" stroke-width=\"{sw}\" \
fill=\"none\"/>\n",
num(cx - w / 2.0),
num(cx + w / 2.0),
y = num(y)
));
}
Outline::Face { r, score } => {
let s = score.clamp(1.0, 5.0);
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{fill}\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n",
num(cx),
num(cy),
num(r)
));
for dx in [-r * 0.35, r * 0.35] {
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{text}\"/>\n",
num(cx + dx),
num(cy - r * 0.25),
num(r * 0.12)
));
}
let lift = (s - 3.0) / 2.0 * r * 0.9;
out.push_str(&format!(
"<path d=\"M{},{y} Q{},{qy} {},{y}\" fill=\"none\" stroke=\"{text}\" \
stroke-width=\"{sw}\"/>\n",
num(cx - r * 0.45),
num(cx),
num(cx + r * 0.45),
y = num(cy + r * 0.25),
qy = num(cy + r * 0.25 + lift)
));
}
Outline::Crossed { r } => {
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{fill}\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n",
num(cx),
num(cy),
num(r)
));
let d = r * 0.6;
out.push_str(&format!(
"<path d=\"M{},{} L{},{} M{},{} L{},{}\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\" fill=\"none\"/>\n",
num(cx - d),
num(cy - d),
num(cx + d),
num(cy + d),
num(cx - d),
num(cy + d),
num(cx + d),
num(cy - d)
));
}
Outline::Tag {
w,
h,
point,
tip,
hole,
} => {
let pts = shapes::tag_points(w, h, point, tip)
.into_iter()
.map(|p| format!("{},{}", num(cx + p.x), num(cy + p.y)))
.collect::<Vec<_>>()
.join(" ");
out.push_str(&format!(
"<polygon points=\"{pts}\" fill=\"{fill}\" stroke=\"{stroke}\" \
stroke-width=\"{sw}\"/>\n"
));
let at = shapes::tag_hole_center(w, point);
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{}\" stroke=\"{stroke}\" \
stroke-width=\"{}\"/>\n",
num(cx + at.x),
num(cy + at.y),
num(hole),
theme.background_ref,
num(NODE_STROKE_WIDTH / 2.0)
));
}
Outline::None => {}
}
if let Some(panel) = &node.panel {
emit_panel(out, panel, node, stroke, text);
return;
}
if node.shape == Glyph::TitledBox && node.label.lines.len() > 1 {
let y = cy - node.label.height / 2.0 + node.label.line_height();
out.push_str(&format!(
"<line x1=\"{}\" y1=\"{y}\" x2=\"{}\" y2=\"{y}\" stroke=\"{stroke}\" \
stroke-width=\"{}\"/>\n",
num(cx - node.size.w / 2.0),
num(cx + node.size.w / 2.0),
num(clusters::STROKE_WIDTH),
y = num(y)
));
}
emit_text(out, &node.label, cx, cy, text);
}
fn emit_lifeline(out: &mut String, lifeline: &super::PlacedLifeline, theme: &Theme) {
out.push_str(&format!(
"<line x1=\"{x}\" y1=\"{}\" x2=\"{x}\" y2=\"{}\" stroke=\"{}\" \
stroke-width=\"{}\" stroke-dasharray=\"{LIFELINE_DASH}\"/>\n",
num(lifeline.top),
num(lifeline.bottom),
theme.lifeline,
num(LIFELINE_STROKE_WIDTH),
x = num(lifeline.x)
));
for i in 0..lifeline.activations.len() {
let Some((l, t, r, b)) = lifeline.activation_bounds(i) else {
continue;
};
out.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\" \
stroke=\"{}\" stroke-width=\"{}\"/>\n",
num(l),
num(t),
num(r - l),
num(b - t),
theme.activation_fill,
theme.activation_stroke,
num(clusters::STROKE_WIDTH)
));
}
if lifeline.destroyed {
let d = DESTROY_HALF;
out.push_str(&format!(
"<path d=\"M{},{} L{},{} M{},{} L{},{}\" stroke=\"{}\" stroke-width=\"{}\" \
fill=\"none\"/>\n",
num(lifeline.x - d),
num(lifeline.bottom - d),
num(lifeline.x + d),
num(lifeline.bottom + d),
num(lifeline.x + d),
num(lifeline.bottom - d),
num(lifeline.x - d),
num(lifeline.bottom + d),
theme.line,
num(EDGE_STROKE_WIDTH)
));
}
}
fn emit_edge(out: &mut String, edge: &PlacedEdge, theme: &Theme) {
if edge.stroke == Stroke::Invisible {
return;
}
let points = edge.drawn_points();
if points.len() < 2 {
return;
}
let (start_room, end_room) = edges::terminator_lengths(edge.tip_start, edge.tip_end);
let trimmed = edges::trim_start(&edges::trim_end(&points, end_room), start_room);
let normal_width = theme.tokens.map_or(EDGE_STROKE_WIDTH, |t| t.edge_width);
let dotted_dash = theme.tokens.map_or(DOTTED_DASH, |t| t.dotted_dash);
let (mut width, mut dash) = match edge.stroke {
Stroke::Thick => (THICK_STROKE_WIDTH, None),
Stroke::Dotted => (normal_width, Some(dotted_dash)),
Stroke::Normal | Stroke::Invalid => (normal_width, None),
Stroke::Invisible => return,
};
let mut line = edge.series.map_or(theme.line, |i| theme.series(i));
if let Some(s) = &edge.style {
if let Some(v) = s.stroke.as_deref() {
line = v;
}
if let Some(v) = s.stroke_width {
width = v;
}
if let Some(v) = s.dash.as_deref() {
dash = Some(v);
}
}
let path_of = |pts: &[Point]| -> String {
if edge.series.is_some() || edge.straight {
edges::polyline_path(pts)
} else {
edge.curve.path(pts)
}
};
let pieces: Vec<Vec<Point>> = if edge.gaps.is_empty() {
vec![trimmed]
} else {
edges::split_at_gaps(&trimmed, &edge.gaps)
};
for piece in &pieces {
if piece.len() < 2 {
continue;
}
out.push_str(&format!(
"<path d=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\"{}/>\n",
path_of(piece),
line,
num(width),
dash.map_or(String::new(), |d| format!(" stroke-dasharray=\"{d}\""))
));
}
let tip_color = if edge.tip_matches_line || theme.tokens.is_some_and(|t| t.ink_follows_line) {
line
} else {
theme.arrowhead
};
let n = points.len();
let (tail_dir, tail_tip) = (&points[1], &points[0]);
let (head_dir, head_tip) = (&points[n - 2], &points[n - 1]);
emit_tip(
out,
head_dir,
head_tip,
edge.tip_end,
tip_color,
normal_width,
theme,
);
emit_tip(
out,
tail_dir,
tail_tip,
edge.tip_start,
tip_color,
normal_width,
theme,
);
}
fn emit_tip(
out: &mut String,
from: &Point,
tip: &Point,
kind: Tip,
color: &str,
width: f64,
theme: &Theme,
) {
match kind {
Tip::None => {}
Tip::Arrow => emit_arrow_head(out, from, tip, color),
Tip::Cross => emit_cross(out, from, tip, color, width),
Tip::Circle => emit_circle_end(out, from, tip, color),
Tip::Async => {
let pts = edges::async_head(from, tip);
out.push_str(&format!(
"<polygon points=\"{}\" fill=\"{}\"/>\n",
pts.iter()
.map(|p| format!("{},{}", num(p.x), num(p.y)))
.collect::<Vec<_>>()
.join(" "),
color
));
}
Tip::HollowTriangle => {
emit_polygon_tip(out, &edges::triangle(from, tip), theme, color, false)
}
Tip::FilledDiamond => emit_polygon_tip(out, &edges::diamond(from, tip), theme, color, true),
Tip::HollowDiamond => {
emit_polygon_tip(out, &edges::diamond(from, tip), theme, color, false)
}
Tip::Lollipop => {
let c = edges::back_along(from, tip, edges::LOLLIPOP_RADIUS);
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{}\" stroke=\"{}\" \
stroke-width=\"{}\"/>\n",
num(c.x),
num(c.y),
num(edges::LOLLIPOP_RADIUS),
theme.node_fill,
color,
num(NODE_STROKE_WIDTH)
));
}
Tip::ErOnlyOne => {
emit_er_bar(out, from, tip, edges::ER_NEAR, color, width);
emit_er_bar(out, from, tip, edges::ER_FAR, color, width);
}
Tip::ErZeroOrOne => {
emit_er_bar(out, from, tip, edges::ER_NEAR, color, width);
emit_er_ring(out, from, tip, edges::ER_FAR, theme, color, width);
}
Tip::ErOneOrMore => {
emit_crows_foot(out, from, tip, color, width);
emit_er_bar(out, from, tip, edges::ER_FAR, color, width);
}
Tip::ErZeroOrMore => {
emit_crows_foot(out, from, tip, color, width);
emit_er_ring(out, from, tip, edges::ER_FAR, theme, color, width);
}
}
}
fn emit_polygon_tip(out: &mut String, points: &[Point], theme: &Theme, color: &str, filled: bool) {
let pts = points
.iter()
.map(|p| format!("{},{}", num(p.x), num(p.y)))
.collect::<Vec<_>>()
.join(" ");
let fill = if filled { color } else { theme.node_fill };
out.push_str(&format!(
"<polygon points=\"{pts}\" fill=\"{fill}\" stroke=\"{}\" stroke-width=\"{}\"/>\n",
color,
num(NODE_STROKE_WIDTH)
));
}
fn emit_er_bar(
out: &mut String,
from: &Point,
tip: &Point,
distance: f64,
color: &str,
width: f64,
) {
let (a, b) = edges::cross_bar(from, tip, distance, edges::ER_BAR_HALF);
out.push_str(&format!(
"<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"{}\" \
stroke-width=\"{}\"/>\n",
num(a.x),
num(a.y),
num(b.x),
num(b.y),
color,
num(width)
));
}
fn emit_er_ring(
out: &mut String,
from: &Point,
tip: &Point,
distance: f64,
theme: &Theme,
color: &str,
width: f64,
) {
let c = edges::back_along(from, tip, distance);
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{}\" stroke=\"{}\" \
stroke-width=\"{}\"/>\n",
num(c.x),
num(c.y),
num(edges::ER_RING_RADIUS),
theme.node_fill,
color,
num(width)
));
}
fn emit_crows_foot(out: &mut String, from: &Point, tip: &Point, color: &str, width: f64) {
let mut d = String::new();
for (a, b) in edges::crows_foot(from, tip) {
d.push_str(&format!(
"M{},{} L{},{} ",
num(a.x),
num(a.y),
num(b.x),
num(b.y)
));
}
out.push_str(&format!(
"<path d=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\"/>\n",
d.trim_end(),
color,
num(width)
));
}
fn emit_panel(out: &mut String, panel: &Panel, node: &PlacedNode, stroke: &str, text: &str) {
let (left, top, right, bottom) = node.bounds();
for y in &panel.rules {
out.push_str(&format!(
"<line x1=\"{}\" y1=\"{y}\" x2=\"{}\" y2=\"{y}\" stroke=\"{stroke}\" \
stroke-width=\"{}\"/>\n",
num(left),
num(right),
num(clusters::STROKE_WIDTH),
y = num(top + y)
));
}
let column_top = panel.rules.first().map_or(top, |y| top + y);
for x in &panel.columns {
out.push_str(&format!(
"<line x1=\"{x}\" y1=\"{}\" x2=\"{x}\" y2=\"{}\" stroke=\"{stroke}\" \
stroke-width=\"{}\"/>\n",
num(column_top),
num(bottom),
num(clusters::STROKE_WIDTH),
x = num(left + x)
));
}
for row in &panel.rows {
for cell in &row.cells {
emit_line_of_text(
out,
&cell.label,
left + cell.x,
top + row.y,
text,
cell.centered,
);
}
}
}
fn emit_arrow_head(out: &mut String, from: &Point, tip: &Point, color: &str) {
let pts = edges::arrow_head(from, tip);
out.push_str(&format!(
"<polygon points=\"{}\" fill=\"{}\"/>\n",
pts.iter()
.map(|p| format!("{},{}", num(p.x), num(p.y)))
.collect::<Vec<_>>()
.join(" "),
color
));
}
fn emit_cross(out: &mut String, from: &Point, tip: &Point, color: &str, width: f64) {
let d = edges::CROSS_HALF;
let (dx, dy) = (tip.x - from.x, tip.y - from.y);
let len = dx.hypot(dy);
let (ux, uy) = if len < 1e-9 {
(1.0, 0.0)
} else {
(dx / len, dy / len)
};
let (cx, cy) = (tip.x - ux * d, tip.y - uy * d);
out.push_str(&format!(
"<path d=\"M{},{} L{},{} M{},{} L{},{}\" stroke=\"{}\" stroke-width=\"{}\" \
fill=\"none\"/>\n",
num(cx - d),
num(cy - d),
num(cx + d),
num(cy + d),
num(cx + d),
num(cy - d),
num(cx - d),
num(cy + d),
color,
num(width)
));
}
fn emit_circle_end(out: &mut String, from: &Point, tip: &Point, color: &str) {
let r = edges::CIRCLE_RADIUS;
let (dx, dy) = (tip.x - from.x, tip.y - from.y);
let len = dx.hypot(dy);
let (ux, uy) = if len < 1e-9 {
(1.0, 0.0)
} else {
(dx / len, dy / len)
};
out.push_str(&format!(
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{}\"/>\n",
num(tip.x - ux * r),
num(tip.y - uy * r),
num(r),
color
));
}
fn emit_line_of_text(out: &mut String, label: &Label, x: f64, cy: f64, fill: &str, centered: bool) {
if label.is_blank() {
return;
}
out.push_str(&format!(
"<text x=\"{}\" y=\"{}\" text-anchor=\"{}\" font-family=\"{}\" font-size=\"{}\" \
fill=\"{}\">{}</text>\n",
num(x),
num(cy + FONT_SIZE as f64 * super::labels::BASELINE_RATIO),
if centered { "middle" } else { "start" },
FONT_FAMILY,
num(FONT_SIZE as f64),
fill,
escape(&label.lines.join(" "))
));
}
fn emit_text(out: &mut String, label: &Label, cx: f64, cy: f64, fill: &str) {
if label.is_blank() {
return;
}
out.push_str(&format!(
"<text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-family=\"{}\" font-size=\"{}\" \
fill=\"{}\">",
num(cx),
num(label.baseline(cy, 0)),
FONT_FAMILY,
num(label.font_size),
fill
));
for (i, line) in label.lines.iter().enumerate() {
let dy = if i == 0 { 0.0 } else { label.line_height() };
out.push_str(&format!(
"<tspan x=\"{}\" dy=\"{}\">{}</tspan>",
num(cx),
num(dy),
escape(line)
));
}
out.push_str("</text>\n");
}