use std::fmt::Write as _;
use crate::diagram::layout::{Edge, EdgeStyle, Layout, Node, NodeKind, Shape};
const HOOK: f64 = 26.0;
const LABEL_LIFT: f64 = 6.0;
#[must_use]
pub fn render(layout: &Layout) -> String {
let mut out = String::new();
let _ = writeln!(
out,
r#"<svg class="routemap" viewBox="0 0 {:.0} {:.0}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Factory route map">"#,
layout.width, layout.height
);
out.push_str(ARROWHEADS);
for edge in &layout.edges {
render_edge(&mut out, layout, edge);
}
for node in &layout.nodes {
render_node(&mut out, node);
}
out.push_str("</svg>\n");
out
}
const ARROWHEADS: &str = r#" <defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="arrowhead"/>
</marker>
<marker id="arrow-bypass" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="arrowhead bypass"/>
</marker>
</defs>
"#;
fn render_node(out: &mut String, node: &Node) {
let kind = match node.kind {
NodeKind::Pipeline => "pipeline",
NodeKind::Agent => "agent",
};
let state = node
.activity
.map_or(String::new(), |activity| format!(" {}", activity.class()));
let _ = writeln!(
out,
r#" <g class="node {kind}{state}" id="{}" tabindex="0">"#,
escape(&node.id)
);
match node.shape {
Shape::Box => {
let _ = writeln!(
out,
r#" <rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="10"/>"#,
node.x, node.y, node.w, node.h
);
}
Shape::Gate => {
let notch = 14.0;
let (x, y, w, h) = (node.x, node.y, node.w, node.h);
let _ = writeln!(
out,
r#" <polygon points="{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}"/>"#,
x + notch,
y,
x + w - notch,
y,
x + w,
y + h / 2.0,
x + w - notch,
y + h,
x + notch,
y + h,
x,
y + h / 2.0
);
}
}
let (cx, cy) = node.centre();
let baseline = if node.subtitle.is_some() {
cy - 4.0
} else {
cy + 5.0
};
let _ = writeln!(
out,
r#" <text class="label" x="{cx:.1}" y="{baseline:.1}" text-anchor="middle">{}</text>"#,
escape(&node.label)
);
if let Some(subtitle) = &node.subtitle {
let _ = writeln!(
out,
r#" <text class="sub" x="{cx:.1}" y="{:.1}" text-anchor="middle">{}</text>"#,
cy + 13.0,
escape(subtitle)
);
}
out.push_str(" </g>\n");
}
fn render_edge(out: &mut String, layout: &Layout, edge: &Edge) {
let (Some(from), Some(to)) = (layout.node(&edge.from), layout.node(&edge.to)) else {
return;
};
let class = match edge.style {
EdgeStyle::Plain => "edge",
EdgeStyle::Entry => "edge entry",
EdgeStyle::Joined => "edge joined",
EdgeStyle::Bypass => "edge bypass",
EdgeStyle::Spawn => "edge spawn",
};
let marker = if matches!(edge.style, EdgeStyle::Bypass | EdgeStyle::Spawn) {
"arrow-bypass"
} else {
"arrow"
};
let (path, label_at) = if edge.back {
return_path(from, to, edge)
} else {
forward_path(from, to, edge.from_y, edge.to_y)
};
let _ = writeln!(
out,
r#" <path class="{class}" d="{path}" marker-end="url(#{marker})"/>"#
);
if let Some(label) = &edge.label {
let _ = writeln!(
out,
r#" <text class="edgelabel" x="{:.1}" y="{:.1}" text-anchor="middle">{}</text>"#,
label_at.0,
label_at.1,
escape(label)
);
}
}
fn forward_path(from: &Node, to: &Node, from_y: f64, to_y: f64) -> (String, (f64, f64)) {
let (x1, y1) = (from.exit().0, from_y);
let (x2, y2) = (to.entry().0, to_y);
let bend = ((x2 - x1) * 0.45).max(24.0);
(
format!(
"M {x1:.1} {y1:.1} C {:.1} {y1:.1} {:.1} {y2:.1} {x2:.1} {y2:.1}",
x1 + bend,
x2 - bend
),
(f64::midpoint(x1, x2), f64::midpoint(y1, y2) - 8.0),
)
}
fn return_path(from: &Node, to: &Node, edge: &Edge) -> (String, (f64, f64)) {
let (fx, fy) = (f64::midpoint(from.x, from.x + from.w), from.y + from.h);
let ty = to.y + to.h;
let floor = edge.floor.unwrap_or(fy.max(ty) + 34.0);
let gutter = edge.gutter.unwrap_or_else(|| (to.x - 44.0).max(12.0));
let corner = edge.hook_x.unwrap_or(to.x + 26.0);
(
format!(
"M {fx:.1} {fy:.1} \
C {fx:.1} {floor:.1} {gutter:.1} {floor:.1} {gutter:.1} {:.1} \
Q {gutter:.1} {ty:.1} {corner:.1} {ty:.1}",
ty + HOOK
),
(f64::midpoint(fx, gutter), floor - LABEL_LIFT),
)
}
fn escape(raw: &str) -> String {
raw.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::diagram::{Activity, Live};
fn factory() -> Config {
Config::from_toml(
r#"
[layover]
work_dir = "work"
[defaults]
runner = "claude"
[runners.claude]
command = ["claude", "-p"]
[agents.analyst]
prompt = "analyse"
access = "read-only"
[agents.developer]
prompt = "develop"
[agents.tester]
prompt = "test"
[pipelines.triage]
entry = "analyst"
[[routes]]
from = "analyst"
to = "developer"
[[routes]]
from = "developer"
to = "tester"
[[routes]]
from = "tester"
to = "developer"
join = "all"
"#,
"svg-test.toml",
)
.expect("config parses")
}
fn svg() -> String {
render(&Layout::build(&factory(), &Live::default()))
}
#[test]
fn the_fragment_is_a_sized_svg_element() {
let svg = svg();
assert!(
svg.starts_with(r#"<svg class="routemap" viewBox="0 0 "#),
"{svg}"
);
assert!(svg.trim_end().ends_with("</svg>"));
}
#[test]
fn every_node_becomes_an_addressable_element() {
let svg = svg();
for id in ["p_triage", "a_analyst", "a_developer", "a_tester"] {
assert!(svg.contains(&format!(r#"id="{id}""#)), "{id} missing");
}
}
#[test]
fn a_barrier_is_drawn_as_a_gate_and_an_ordinary_agent_as_a_box() {
let svg = svg();
assert!(
svg.contains("<polygon"),
"the joined developer needs a gate"
);
assert_eq!(svg.matches("<polygon").count(), 1);
assert!(svg.contains("<rect"));
}
#[test]
fn edges_are_drawn_before_nodes() {
let svg = svg();
let first_path = svg.find(r#"<path class="edge"#).expect("has edges");
let first_node = svg.find(r#"<g class="node"#).expect("has nodes");
assert!(first_path < first_node);
}
#[test]
fn a_return_path_is_routed_below_rather_than_through_the_columns() {
let layout = Layout::build(&factory(), &Live::default());
let tester = layout.node("a_tester").expect("tester");
let back = layout
.edges
.iter()
.find(|edge| edge.back)
.expect("the review loop exists");
let (path, _) = return_path(tester, layout.node("a_developer").expect("developer"), back);
let floor: f64 = path
.split_whitespace()
.filter_map(|token| token.parse::<f64>().ok())
.fold(0.0, f64::max);
assert!(
floor > tester.y + tester.h,
"the return path should dip below the nodes it passes"
);
}
#[test]
fn live_state_becomes_a_class_the_stylesheet_can_colour() {
let live = Live::default()
.with("developer", Activity::Running)
.with("analyst", Activity::Failed);
let svg = render(&Layout::build(&factory(), &live));
assert!(
svg.contains(r#"class="node agent running" id="a_developer""#),
"{svg}"
);
assert!(svg.contains(r#"class="node agent failed" id="a_analyst""#));
assert!(svg.contains(r#"class="node agent" id="a_tester""#));
}
#[test]
fn a_join_condition_is_written_on_the_edge() {
let svg = svg();
assert!(svg.contains(r#"class="edgelabel""#));
assert!(svg.contains(">all</text>"));
}
#[test]
fn read_only_access_is_written_under_the_name() {
let svg = svg();
assert!(svg.contains(">read-only</text>"));
}
#[test]
fn a_return_path_climbs_in_the_gutter_rather_than_through_the_column() {
let layout = Layout::build(&factory(), &Live::default());
let developer = layout.node("a_developer").expect("developer");
let tester = layout.node("a_tester").expect("tester");
let back = layout
.edges
.iter()
.find(|edge| edge.back && edge.to == "a_developer")
.expect("the review loop exists");
let (path, _) = return_path(tester, developer, back);
let gutter = back.gutter.expect("a return path is given a gutter");
assert!(
gutter < developer.x,
"the ascent should happen left of the target's column: {gutter} vs {}",
developer.x
);
assert!(path.contains(&format!("{gutter:.1}")), "{path}");
assert!(
!path.contains(&format!("{:.1} {:.1}", developer.centre().0, 400.0)),
"nothing should be routed up the column centre: {path}"
);
}
#[test]
fn markup_in_a_name_cannot_escape_into_the_document() {
assert_eq!(
escape(r#"<script>alert("x")</script>"#),
"<script>alert("x")</script>"
);
}
#[test]
fn an_edge_to_a_node_that_is_not_there_is_skipped_rather_than_drawn_wrong() {
let mut layout = Layout::build(&factory(), &Live::default());
layout.edges.push(Edge {
from: "a_analyst".to_owned(),
to: "a_ghost".to_owned(),
label: None,
style: EdgeStyle::Plain,
back: false,
from_y: 0.0,
to_y: 0.0,
gutter: None,
hook_x: None,
floor: None,
});
let svg = render(&layout);
assert!(!svg.contains("a_ghost"));
}
}