src/viz/diagram.rs
//! Data-only diagram renderers for the viz tabs.
//!
//! Each viz tab paints a diagram with egui — on screen only. These are the
//! **pure-data counterparts**: headless functions that turn the same
//! [`Timeline`] data model into an exportable static SVG / Markdown diagram,
//! with **no egui dependency**. They are compiled wherever [`model`](super::model)
//! is (the `server` build too — see [`super`] module docs), so the docs
//! exporter, the `Viz.Timeline` RPC, and the CLI can all render the viz
//! diagrams without a display (CLI parity with the GUI).
//!
//! House style mirrors [`crate::introspect::Graph::to_svg`]: graph diagrams are
//! emitted as a self-contained static SVG (no JavaScript, no diagram engine, no
//! build step — never Mermaid), so they render verbatim in the Codeberg /
//! GitHub web UI; tabular diagrams are plain GitHub-flavoured Markdown tables.
//!
//! There is one renderer per **diagram-bearing** viz tab (the interactive /
//! tabular ops tabs — LiveRun, Warehouse, Mcp, Search — have no static diagram
//! to export). The eight:
//!
//! | renderer | viz tab | shape |
//! |----------|---------|-------|
//! | [`timeline_svg`] | Timeline | static SVG timeline (per-repo lanes) |
//! | [`lane_summary_table`] | Timeline | per-repo lane summary table |
//! | [`depgraph_svg`] | DepGraph | static SVG `graph LR`, edges labelled by `via` crates |
//! | [`snapshot_edge_list`] | DepGraph | cross-repo edge / adjacency list |
//! | [`gate_matrix_table`] | Gates | release × repo gate-verdict matrix |
//! | [`release_versions_table`] | Funnel | published crate versions per release |
//! | [`bench_history_table`] | Bench | per-repo primary-metric trend (newest first) |
//! | [`bench_compare_table`] | TimeTravel | latest bench metric across repos |
use std::collections::BTreeSet;
use std::fmt::Write as _;
use crate::warehouse::dep_graph::DepGraphSnapshot;
use super::model::{BenchHistory, Lane, Timeline};
/// XML-escape a label for safe inclusion in SVG text / attributes.
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
/// Short git sha (first 7 chars) for compact node/cell labels.
fn short_sha(sha: &str) -> &str {
&sha[..sha.len().min(7)]
}
/// Pipe-escape a cell value so a stray `|` can't break a Markdown table.
fn cell(s: &str) -> String {
s.replace('|', "\\|")
}
/// The latest published version for a lane node, if any (`crate v1.2.3` or just
/// the version when the crate name is empty).
fn node_version(versions: &[(String, String)]) -> String {
versions
.iter()
.map(|(c, v)| {
if c.is_empty() {
format!("v{v}")
} else {
format!("{c} v{v}")
}
})
.collect::<Vec<_>>()
.join(", ")
}
// ── Timeline tab ────────────────────────────────────────────────────────────
/// **Timeline tab → static SVG timeline.** One lane (row) per repo, one cell
/// per recorded release (`sha` + gate verdict + published versions), laid out
/// left-to-right in release order. Empty lanes (bench-only repos) are skipped.
/// Self-contained SVG — no JavaScript, no diagram engine (never Mermaid).
/// Returns an italic placeholder when the timeline has no releases at all.
pub fn timeline_svg(tl: &Timeline) -> String {
if !tl.has_releases() {
return "_no releases recorded yet_\n".to_string();
}
let lanes: Vec<&Lane> = tl.lanes.iter().filter(|l| !l.nodes.is_empty()).collect();
if lanes.is_empty() {
return "_no releases recorded yet_\n".to_string();
}
let label_w = 150.0f64;
let cell_w = 210.0f64;
let cell_h = 30.0f64;
let row_h = 40.0f64;
let margin = 14.0f64;
let title_h = 26.0f64;
let max_cells = lanes
.iter()
.map(|l| l.nodes.len())
.max()
.unwrap_or(1)
.max(1);
let width = margin * 2.0 + label_w + cell_w * max_cells as f64;
let height = margin * 2.0 + title_h + row_h * lanes.len() as f64;
let mut s = String::new();
let _ = write!(
s,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.0}\" height=\"{height:.0}\" \
viewBox=\"0 0 {width:.0} {height:.0}\" font-family=\"sans-serif\" font-size=\"11\">\n"
);
let _ = write!(
s,
"<text x=\"{:.0}\" y=\"{:.0}\" font-size=\"13\" font-weight=\"bold\">{} release timeline</text>\n",
margin,
margin + 14.0,
xml_escape(&tl.workspace_name),
);
for (li, lane) in lanes.iter().enumerate() {
let row_y = margin + title_h + li as f64 * row_h;
let _ = write!(
s,
"<text x=\"{:.0}\" y=\"{:.0}\" font-weight=\"bold\">{}</text>\n",
margin,
row_y + cell_h / 2.0 + 4.0,
xml_escape(&lane.repo),
);
for (ci, n) in lane.nodes.iter().enumerate() {
let x = margin + label_w + ci as f64 * cell_w;
let date = n.timestamp.format("%Y-%m-%d");
let mut label = format!("{} {} {}", date, short_sha(&n.sha), n.gate_status);
let ver = node_version(&n.published_versions);
if !ver.is_empty() {
label.push_str(" — ");
label.push_str(&ver);
}
let fill = if n.gate_status.starts_with("succeeded") {
"#e6f3ea"
} else {
"#f7e6e6"
};
let _ = write!(
s,
"<rect x=\"{x:.0}\" y=\"{row_y:.0}\" width=\"{:.0}\" height=\"{cell_h:.0}\" rx=\"3\" \
fill=\"{fill}\" stroke=\"#3c3c50\" stroke-width=\"0.7\"/>\n",
cell_w - 8.0,
);
let _ = write!(
s,
"<text x=\"{:.0}\" y=\"{:.0}\">{}</text>\n",
x + 6.0,
row_y + cell_h / 2.0 + 4.0,
xml_escape(&label),
);
}
}
s.push_str("</svg>\n");
s
}
/// **Timeline tab → summary table.** One row per repo lane: number of recorded
/// releases, the newest release's sha / gate / version. The at-a-glance
/// counterpart to the swim-lane painting.
pub fn lane_summary_table(tl: &Timeline) -> String {
if tl.lanes.is_empty() {
return "_no repos in this workspace_\n".to_string();
}
let mut s = String::from("| Repo | Releases | Latest sha | Gate | Version |\n");
s.push_str("|------|---------:|-----------|------|---------|\n");
for lane in &tl.lanes {
let (sha, gate, ver) = match lane.nodes.last() {
Some(n) => (
short_sha(&n.sha).to_string(),
n.gate_status.clone(),
node_version(&n.published_versions),
),
None => ("—".into(), "—".into(), String::new()),
};
let ver = if ver.is_empty() {
"—".to_string()
} else {
ver
};
let _ = writeln!(
s,
"| {} | {} | {} | {} | {} |",
cell(&lane.repo),
lane.nodes.len(),
cell(&sha),
cell(&gate),
cell(&ver),
);
}
s
}
// ── DepGraph tab ────────────────────────────────────────────────────────────
/// Pick the snapshot to draw: the explicitly latest one, else the
/// highest-`timestamp` snapshot in the map.
fn latest_snapshot(tl: &Timeline) -> Option<&DepGraphSnapshot> {
tl.latest_snapshot
.as_ref()
.or_else(|| tl.snapshots.values().max_by_key(|s| s.timestamp))
}
/// **DepGraph tab → static SVG `graph LR`.** The cross-repo dependency
/// snapshot: one node per repo, one left-to-right edge `from → to` labelled
/// with the crates that justify it (`via`). Mirrors the woven-threads painting.
/// Self-contained SVG — no JavaScript, no diagram engine (never Mermaid).
/// Returns an italic placeholder when no snapshot was captured.
pub fn depgraph_svg(tl: &Timeline) -> String {
let Some(snap) = latest_snapshot(tl) else {
return "_no dependency-graph snapshot captured yet_\n".to_string();
};
// Build a layered-DAG layout via the shared introspect renderer for node
// placement, but draw here so we can keep the per-edge `via` crate labels.
let mut node_set: BTreeSet<&str> = BTreeSet::new();
for e in &snap.edges {
node_set.insert(e.from.as_str());
node_set.insert(e.to.as_str());
}
let nodes: Vec<String> = node_set.iter().map(|s| s.to_string()).collect();
use std::collections::HashMap;
let idx: HashMap<&str, usize> = nodes
.iter()
.enumerate()
.map(|(i, s)| (s.as_str(), i))
.collect();
// Layered columns by longest-path depth (sources at column 0). Cycle-safe:
// a bounded relaxation pass over the edge set.
let n = nodes.len();
let mut col = vec![0usize; n];
for _ in 0..n {
let mut changed = false;
for e in &snap.edges {
if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
if f != t && col[t] <= col[f] {
col[t] = col[f] + 1;
changed = true;
}
}
}
if !changed {
break;
}
}
let cols = col.iter().copied().max().unwrap_or(0) + 1;
// Row within each column, assigned in node (sorted) order.
let mut next_row = vec![0usize; cols];
let mut row = vec![0usize; n];
for i in 0..n {
row[i] = next_row[col[i]];
next_row[col[i]] += 1;
}
let rows = next_row.iter().copied().max().unwrap_or(1).max(1);
let col_w = 200.0f64;
let row_h = 44.0f64;
let box_w = 150.0f64;
let box_h = 26.0f64;
let margin = 14.0f64;
let width = margin * 2.0 + col_w * cols as f64;
let height = margin * 2.0 + row_h * rows as f64;
let pos = |i: usize| -> (f64, f64) {
(
margin + col[i] as f64 * col_w,
margin + row[i] as f64 * row_h,
)
};
let mut s = String::new();
let _ = write!(
s,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.0}\" height=\"{height:.0}\" \
viewBox=\"0 0 {width:.0} {height:.0}\" font-family=\"sans-serif\" font-size=\"11\">\n"
);
s.push_str(
"<defs><marker id=\"arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"7\" refY=\"3\" \
orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#5a6876\"/></marker></defs>\n",
);
for e in &snap.edges {
let (Some(&fi), Some(&ti)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
continue;
};
let (fx, fy) = pos(fi);
let (tx, ty) = pos(ti);
let x1 = fx + box_w;
let y1 = fy + box_h / 2.0;
let x2 = tx;
let y2 = ty + box_h / 2.0;
let _ = write!(
s,
"<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
stroke=\"#787882\" stroke-width=\"1\" stroke-dasharray=\"4 3\" marker-end=\"url(#arrow)\"/>\n"
);
let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
if !via.is_empty() {
let _ = write!(
s,
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\" fill=\"#5a6876\" font-size=\"9\">{}</text>\n",
(x1 + x2) / 2.0,
(y1 + y2) / 2.0 - 3.0,
xml_escape(&via.join(", ")),
);
}
}
for (i, name) in nodes.iter().enumerate() {
let (x, y) = pos(i);
let _ = write!(
s,
"<rect x=\"{x:.0}\" y=\"{y:.0}\" width=\"{box_w:.0}\" height=\"{box_h:.0}\" rx=\"3\" \
fill=\"#f5f7fc\" stroke=\"#3c3c50\" stroke-width=\"0.7\"/>\n"
);
let _ = write!(
s,
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\">{}</text>\n",
x + box_w / 2.0,
y + box_h / 2.0 + 4.0,
xml_escape(name),
);
}
s.push_str("</svg>\n");
s
}
/// **DepGraph tab → edge list.** The same cross-repo snapshot as a flat,
/// plain Markdown list (`from → to (via crateA, crateB)`) for terminal output
/// or hosts that prefer text over the inline SVG. Sorted for deterministic output.
pub fn snapshot_edge_list(tl: &Timeline) -> String {
let Some(snap) = latest_snapshot(tl) else {
return "_no dependency-graph snapshot captured yet_\n".to_string();
};
if snap.edges.is_empty() {
return "_no cross-repo dependency edges in the latest snapshot_\n".to_string();
}
let mut lines: Vec<String> = snap
.edges
.iter()
.map(|e| {
let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
if via.is_empty() {
format!("- `{}` → `{}`", e.from, e.to)
} else {
format!("- `{}` → `{}` (via {})", e.from, e.to, via.join(", "))
}
})
.collect();
lines.sort();
let mut s = lines.join("\n");
s.push('\n');
s
}
// ── Gates tab ───────────────────────────────────────────────────────────────
/// **Gates tab → verdict matrix.** Rows = recorded releases (chronological),
/// columns = repos, cells = the gate verdict (`✓` succeeded, `✗` failed,
/// otherwise the raw status). The tabular view of which release passed the gate
/// for which repo.
pub fn gate_matrix_table(tl: &Timeline) -> String {
if !tl.has_releases() {
return "_no releases recorded yet_\n".to_string();
}
// Column order = lane order (already repo-sorted in the model).
let repos: Vec<&Lane> = tl.lanes.iter().filter(|l| !l.nodes.is_empty()).collect();
if repos.is_empty() {
return "_no gated releases recorded yet_\n".to_string();
}
let mut header = String::from("| Release |");
let mut divider = String::from("|---------|");
for l in &repos {
let _ = write!(header, " {} |", cell(&l.repo));
divider.push_str(":--:|");
}
let mut s = format!("{header}\n{divider}\n");
for rid in &tl.release_order {
// Find any node for this release to label the row by date.
let label = repos
.iter()
.flat_map(|l| l.nodes.iter())
.find(|n| &n.release_id == rid)
.map(|n| n.timestamp.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| rid.to_string());
let mut row = format!("| {} |", cell(&label));
for l in &repos {
let mark = l
.nodes
.iter()
.find(|n| &n.release_id == rid)
.map(|n| gate_mark(&n.gate_status))
.unwrap_or("·".to_string());
let _ = write!(row, " {mark} |");
}
let _ = writeln!(s, "{row}");
}
s
}
fn gate_mark(status: &str) -> String {
if status.starts_with("succeeded") {
"✓".to_string()
} else if status.starts_with("failed") {
"✗".to_string()
} else {
cell(status)
}
}
// ── Funnel tab ──────────────────────────────────────────────────────────────
/// **Funnel tab → published-versions table.** One row per (repo, release) that
/// published at least one crate, listing the crate@version it cut. The
/// release-output side of the funnel.
pub fn release_versions_table(tl: &Timeline) -> String {
let mut rows: Vec<(String, String, String)> = Vec::new(); // (date, repo, versions)
for lane in &tl.lanes {
for n in &lane.nodes {
if n.published_versions.is_empty() {
continue;
}
rows.push((
n.timestamp.format("%Y-%m-%d").to_string(),
lane.repo.clone(),
node_version(&n.published_versions),
));
}
}
if rows.is_empty() {
return "_no published versions recorded yet_\n".to_string();
}
rows.sort();
let mut s = String::from("| Date | Repo | Published |\n");
s.push_str("|------|------|-----------|\n");
for (date, repo, ver) in rows {
let _ = writeln!(s, "| {} | {} | {} |", date, cell(&repo), cell(&ver));
}
s
}
// ── Bench / TimeTravel tabs ─────────────────────────────────────────────────
/// **Bench tab → per-repo history table.** For each repo with bench points, the
/// N newest runs (newest first) of its primary metric — the textual sparkline.
/// `limit` caps rows per repo (`None` → all).
pub fn bench_history_table(tl: &Timeline, limit: Option<usize>) -> String {
let mut histories: Vec<&BenchHistory> = tl
.bench_history
.values()
.filter(|h| !h.points.is_empty())
.collect();
histories.sort_by(|a, b| a.repo.cmp(&b.repo));
if histories.is_empty() {
return "_no benchmark history recorded yet_\n".to_string();
}
let mut s = String::from("| Repo | Date | Version | Metric | Value | Machine |\n");
s.push_str("|------|------|---------|--------|------:|---------|\n");
for h in histories {
let mut pts: Vec<_> = h.points.iter().collect();
pts.sort_by_key(|p| std::cmp::Reverse(p.timestamp)); // newest first
if let Some(n) = limit {
pts.truncate(n);
}
for p in pts {
let _ = writeln!(
s,
"| {} | {} | {} | {} | {} | {} |",
cell(&h.repo),
p.timestamp.format("%Y-%m-%d"),
cell(&p.version),
cell(&p.primary_metric_name),
fmt_num(p.primary_metric_value),
cell(&p.machine),
);
}
}
s
}
/// **TimeTravel tab → latest-bench comparison table.** One row per repo, its
/// most recent run's primary metric — the cross-repo "where does each stand
/// now" view that the TimeTravel reel scrubs through.
pub fn bench_compare_table(tl: &Timeline) -> String {
let mut rows: Vec<(String, String, f64, String, String)> = Vec::new();
for (repo, h) in &tl.bench_history {
if let Some(p) = h.points.iter().max_by_key(|p| p.timestamp) {
rows.push((
repo.clone(),
p.primary_metric_name.clone(),
p.primary_metric_value,
p.version.clone(),
p.timestamp.format("%Y-%m-%d").to_string(),
));
}
}
if rows.is_empty() {
return "_no benchmark history recorded yet_\n".to_string();
}
rows.sort_by(|a, b| a.0.cmp(&b.0));
let mut s = String::from("| Repo | Latest metric | Value | Version | Date |\n");
s.push_str("|------|---------------|------:|---------|------|\n");
for (repo, metric, val, ver, date) in rows {
let _ = writeln!(
s,
"| {} | {} | {} | {} | {} |",
cell(&repo),
cell(&metric),
fmt_num(val),
cell(&ver),
date,
);
}
s
}
/// Compact numeric formatting: integers without a decimal point and with
/// thousands separators, fractions to 3 sig figs.
fn fmt_num(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 {
let i = v as i64;
let s = i.abs().to_string();
let mut out = String::new();
for (idx, ch) in s.chars().enumerate() {
if idx > 0 && (s.len() - idx).is_multiple_of(3) {
out.push(',');
}
out.push(ch);
}
if i < 0 { format!("-{out}") } else { out }
} else if v.abs() >= 1000.0 {
format!("{v:.0}")
} else {
format!("{v:.3}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::warehouse::dep_graph::CrossRepoEdge;
use chrono::{TimeZone, Utc};
use std::collections::BTreeMap;
use uuid::Uuid;
fn sample_timeline() -> Timeline {
let sid = Uuid::from_u128(1);
let rid = Uuid::from_u128(2);
let ts = Utc.with_ymd_and_hms(2026, 6, 10, 12, 0, 0).unwrap();
let snap = DepGraphSnapshot {
snapshot_id: sid,
workspace_name: "skade".into(),
timestamp: ts,
edges: vec![CrossRepoEdge::normal(
"skade",
"skade-katalog",
BTreeSet::from(["skade-katalog".to_string()]),
)],
};
let mut snapshots = BTreeMap::new();
snapshots.insert(sid, snap.clone());
let mut release_snapshot = BTreeMap::new();
release_snapshot.insert(rid, sid);
let node = super::super::model::LaneNode {
release_id: rid,
timestamp: ts,
sha: "deadbeefcafef00d".into(),
branch: "main".into(),
dirty: false,
gate_status: "succeeded".into(),
tests_passed: 9,
tests_failed: 0,
published_versions: vec![("skade".into(), "0.1.4".into())],
};
let mut bench_history = BTreeMap::new();
bench_history.insert(
"skade".to_string(),
BenchHistory {
repo: "skade".into(),
points: vec![super::super::model::BenchPoint {
timestamp: ts,
primary_metric_name: "skade_table_exists_ops_sec".into(),
primary_metric_value: 2_000_000.0,
metrics: vec![("skade_table_exists.ops_sec".into(), 2_000_000.0)],
version: "0.1.4".into(),
machine: "oden".into(),
}],
},
);
Timeline {
workspace_name: "skade".into(),
lanes: vec![Lane {
repo: "skade".into(),
nodes: vec![node],
}],
release_order: vec![rid],
release_snapshot,
snapshots,
latest_snapshot: Some(snap),
bench_history,
}
}
#[test]
fn timeline_svg_has_lane_and_event() {
let tl = sample_timeline();
let out = timeline_svg(&tl);
assert!(out.starts_with("<svg"), "{out}");
assert!(out.contains("release timeline"), "{out}");
assert!(out.contains(">skade<"), "lane label: {out}");
assert!(out.contains("deadbee"), "short sha: {out}");
assert!(out.contains("skade v0.1.4"), "version: {out}");
assert!(!out.contains("mermaid"), "no mermaid: {out}");
assert!(out.trim_end().ends_with("</svg>"));
}
#[test]
fn depgraph_svg_labels_edge_via() {
let out = depgraph_svg(&sample_timeline());
assert!(out.starts_with("<svg"), "{out}");
assert!(out.contains(">skade<"), "node skade: {out}");
assert!(out.contains(">skade-katalog<"), "node skade-katalog: {out}");
assert!(
out.contains(">skade-katalog</text>"),
"edge via label: {out}"
);
assert!(out.contains("<line "), "edge line: {out}");
assert!(!out.contains("mermaid"), "no mermaid: {out}");
}
#[test]
fn snapshot_edge_list_is_sorted_markdown() {
let out = snapshot_edge_list(&sample_timeline());
assert!(
out.contains("- `skade` → `skade-katalog` (via skade-katalog)"),
"{out}"
);
}
#[test]
fn gate_matrix_marks_success() {
let out = gate_matrix_table(&sample_timeline());
assert!(out.contains("| Release |"), "{out}");
assert!(out.contains("skade"), "{out}");
assert!(out.contains('✓'), "succeeded → check: {out}");
}
#[test]
fn release_versions_lists_published() {
let out = release_versions_table(&sample_timeline());
assert!(out.contains("skade v0.1.4"), "{out}");
assert!(out.contains("2026-06-10"), "{out}");
}
#[test]
fn bench_history_table_newest_first_with_thousands() {
let out = bench_history_table(&sample_timeline(), Some(20));
assert!(out.contains("skade_table_exists_ops_sec"), "{out}");
assert!(out.contains("2,000,000"), "thousands sep: {out}");
}
#[test]
fn bench_compare_table_one_row_per_repo() {
let out = bench_compare_table(&sample_timeline());
assert!(out.contains("| skade |"), "{out}");
assert!(out.contains("2,000,000"), "{out}");
}
#[test]
fn lane_summary_shows_latest() {
let out = lane_summary_table(&sample_timeline());
assert!(out.contains("| skade |"), "{out}");
assert!(out.contains("deadbee"), "{out}");
assert!(out.contains("succeeded"), "{out}");
}
#[test]
fn empty_timeline_placeholders() {
let tl = Timeline {
workspace_name: "empty".into(),
lanes: vec![],
release_order: vec![],
release_snapshot: BTreeMap::new(),
snapshots: BTreeMap::new(),
latest_snapshot: None,
bench_history: BTreeMap::new(),
};
assert!(timeline_svg(&tl).contains("no releases"));
assert!(depgraph_svg(&tl).contains("no dependency-graph snapshot"));
assert!(bench_history_table(&tl, None).contains("no benchmark history"));
assert!(gate_matrix_table(&tl).contains("no releases"));
}
}