use std::collections::{HashMap, HashSet};
use anyhow::{anyhow, Context, Result};
use typst_as_lib::typst_kit_options::TypstKitFontOptions;
use typst_as_lib::TypstEngine;
use crate::introspect::{Edge, EdgeKind, Graph};
pub fn render_graph_svg(g: &Graph) -> Result<String> {
let layers = layered(&g.nodes, &g.edges);
let typst_src = emit_typst(g, &layers);
let engine = TypstEngine::builder()
.main_file(typst_src)
.search_fonts_with(
TypstKitFontOptions::default()
.include_system_fonts(false)
.include_embedded_fonts(true),
)
.build();
let result = engine.compile();
let doc = result
.output
.map_err(|err| anyhow!("typst compile failed for graph svg: {:?}", err))?;
let svg = typst_svg::svg_merged(&doc, typst::layout::Abs::pt(8.0));
Ok(svg)
}
fn layered(nodes: &[String], edges: &[Edge]) -> Vec<Vec<usize>> {
let n = nodes.len();
let idx: HashMap<&str, usize> =
nodes.iter().enumerate().map(|(i, s)| (s.as_str(), i)).collect();
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut indeg: Vec<usize> = vec![0; n];
for e in edges {
if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
if f != t {
adj[f].push(t);
indeg[t] += 1;
}
}
}
let mut layer_of = vec![0usize; n];
let mut remaining: HashSet<usize> = (0..n).collect();
let mut level = 0usize;
while !remaining.is_empty() {
let ready: Vec<usize> = remaining
.iter()
.copied()
.filter(|&i| indeg[i] == 0)
.collect();
if ready.is_empty() {
for &i in &remaining {
layer_of[i] = level;
}
break;
}
for &i in &ready {
layer_of[i] = level;
remaining.remove(&i);
}
for &i in &ready {
for &j in &adj[i] {
if indeg[j] > 0 {
indeg[j] -= 1;
}
}
}
level += 1;
}
let max_level = *layer_of.iter().max().unwrap_or(&0);
let mut layers: Vec<Vec<usize>> = vec![Vec::new(); max_level + 1];
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| nodes[a].cmp(&nodes[b]));
for i in order {
layers[layer_of[i]].push(i);
}
layers
}
fn emit_typst(g: &Graph, layers: &[Vec<usize>]) -> String {
let col_w_pt: f64 = 160.0;
let row_h_pt: f64 = 36.0;
let box_w_pt: f64 = 130.0;
let box_h_pt: f64 = 22.0;
let margin_pt: f64 = 12.0;
let cols = layers.len().max(1);
let rows = layers.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
let page_w = margin_pt * 2.0 + col_w_pt * cols as f64;
let page_h = margin_pt * 2.0 + row_h_pt * rows as f64 + box_h_pt;
let mut pos: HashMap<usize, (f64, f64)> = HashMap::new();
for (ci, layer) in layers.iter().enumerate() {
let n = layer.len() as f64;
let total_h = n * row_h_pt;
let start_y = margin_pt + (page_h - margin_pt * 2.0 - total_h) / 2.0;
for (ri, &node_i) in layer.iter().enumerate() {
let x = margin_pt + ci as f64 * col_w_pt + 8.0;
let y = start_y + ri as f64 * row_h_pt;
pos.insert(node_i, (x, y));
}
}
let name_to_idx: HashMap<&str, usize> = g
.nodes
.iter()
.enumerate()
.map(|(i, s)| (s.as_str(), i))
.collect();
let mut s = String::new();
s.push_str(&format!(
"#set page(width: {page_w:.1}pt, height: {page_h:.1}pt, margin: 0pt)\n"
));
s.push_str("#set text(font: (\"DejaVu Sans\", \"Liberation Sans\"), size: 9pt)\n\n");
for e in &g.edges {
let (Some(&fi), Some(&ti)) =
(name_to_idx.get(e.from.as_str()), name_to_idx.get(e.to.as_str()))
else {
continue;
};
let Some(&(fx, fy)) = pos.get(&fi) else { continue };
let Some(&(tx, ty)) = pos.get(&ti) else { continue };
let x1 = fx + box_w_pt;
let y1 = fy + box_h_pt / 2.0;
let x2 = tx;
let y2 = ty + box_h_pt / 2.0;
let style = match e.kind {
EdgeKind::DependsOn => "(paint: rgb(120, 120, 130), thickness: 0.6pt, dash: \"dashed\")",
EdgeKind::Calls => "(paint: rgb(40, 80, 160), thickness: 0.7pt)",
EdgeKind::Reexports => "(paint: rgb(40, 130, 60), thickness: 0.8pt)",
EdgeKind::Implements => "(paint: rgb(160, 80, 40), thickness: 0.7pt)",
};
s.push_str(&format!(
"#place(top + left, dx: 0pt, dy: 0pt, \
line(start: ({x1:.1}pt, {y1:.1}pt), end: ({x2:.1}pt, {y2:.1}pt), \
stroke: {style}))\n"
));
}
for (i, name) in g.nodes.iter().enumerate() {
let Some(&(x, y)) = pos.get(&i) else { continue };
let label = escape_typst(name);
s.push_str(&format!(
"#place(top + left, dx: {x:.1}pt, dy: {y:.1}pt, \
box(width: {box_w_pt}pt, height: {box_h_pt}pt, \
stroke: 0.6pt + rgb(60,60,80), \
fill: rgb(245,247,252), \
inset: 4pt, \
radius: 3pt)[#align(center + horizon)[{label}]])\n"
));
}
s
}
fn escape_typst(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('#', "\\#")
.replace('$', "\\$")
.replace('@', "\\@")
.replace('*', "\\*")
.replace('_', "\\_")
}
pub fn render_to_file(g: &Graph, out_path: &std::path::Path) -> Result<()> {
let svg = render_graph_svg(g)?;
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
std::fs::write(out_path, svg)
.with_context(|| format!("write {}", out_path.display()))?;
Ok(())
}
pub struct Bar {
pub label: String,
pub value: f64,
pub best: bool,
}
pub struct BarChart {
pub title: String,
pub unit: String,
pub bars: Vec<Bar>,
pub log: bool,
}
fn fmt_bar_value(v: f64, unit: &str, scale: Option<(f64, &'static str, usize)>) -> String {
let num = crate::docs::sections::fmt_metric_scaled(v, scale);
if unit.is_empty() { num } else { format!("{num} {unit}") }
}
pub fn render_bars_svg(chart: &BarChart) -> Result<String> {
let typst_src = emit_bars_typst(chart);
let engine = TypstEngine::builder()
.main_file(typst_src)
.search_fonts_with(
TypstKitFontOptions::default()
.include_system_fonts(false)
.include_embedded_fonts(true),
)
.build();
let doc = engine
.compile()
.output
.map_err(|err| anyhow!("typst compile failed for bar chart: {:?}", err))?;
Ok(typst_svg::svg_merged(&doc, typst::layout::Abs::pt(8.0)))
}
fn emit_bars_typst(chart: &BarChart) -> String {
let label_w_pt: f64 = 240.0;
let bar_max_pt: f64 = 360.0;
let value_w_pt: f64 = 130.0;
let row_h_pt: f64 = 28.0;
let bar_h_pt: f64 = 16.0;
let margin_pt: f64 = 14.0;
let title_h_pt: f64 = if chart.title.is_empty() { 0.0 } else { 30.0 };
let n = chart.bars.len().max(1);
let page_w = margin_pt * 2.0 + label_w_pt + bar_max_pt + value_w_pt;
let page_h = margin_pt * 2.0 + title_h_pt + row_h_pt * n as f64;
let max_v = chart.bars.iter().map(|b| b.value).fold(f64::MIN, f64::max);
let value_scale =
crate::docs::sections::column_format(&chart.bars.iter().map(|b| b.value).collect::<Vec<_>>());
let min_pos = chart
.bars
.iter()
.map(|b| b.value)
.filter(|v| *v > 0.0)
.fold(f64::MAX, f64::min);
let width_of = |v: f64| -> f64 {
if !(v > 0.0) || !(max_v > 0.0) {
return 0.0;
}
if chart.log && min_pos.is_finite() && max_v > min_pos {
let lo = min_pos.log10();
let hi = max_v.log10();
let frac = (v.log10() - lo) / (hi - lo);
bar_max_pt * (0.12 + 0.88 * frac.clamp(0.0, 1.0))
} else {
bar_max_pt * (v / max_v).clamp(0.0, 1.0)
}
};
let mut s = String::new();
s.push_str(&format!(
"#set page(width: {page_w:.1}pt, height: {page_h:.1}pt, margin: 0pt)\n"
));
s.push_str("#set text(font: (\"DejaVu Sans\", \"Liberation Sans\"), size: 9pt)\n\n");
if !chart.title.is_empty() {
s.push_str(&format!(
"#place(top + left, dx: {margin_pt:.1}pt, dy: {margin_pt:.1}pt, \
text(size: 11pt, weight: \"bold\")[{}])\n",
escape_typst(&chart.title)
));
let unit_note = if chart.unit.is_empty() { String::new() } else { format!(" ({})", chart.unit) };
let scale_note = if chart.log { ", log scale" } else { "" };
if !unit_note.is_empty() || !scale_note.is_empty() {
s.push_str(&format!(
"#place(top + right, dx: -{margin_pt:.1}pt, dy: {:.1}pt, \
text(size: 8pt, fill: rgb(120,120,130))[{}{}])\n",
margin_pt + 2.0,
escape_typst(unit_note.trim_start()),
scale_note,
));
}
}
let bar_x = margin_pt + label_w_pt;
for (i, b) in chart.bars.iter().enumerate() {
let row_y = margin_pt + title_h_pt + i as f64 * row_h_pt;
let bar_y = row_y + (row_h_pt - bar_h_pt) / 2.0;
s.push_str(&format!(
"#place(top + left, dx: {margin_pt:.1}pt, dy: {:.1}pt, \
box(width: {:.1}pt, height: {row_h_pt:.1}pt, inset: (right: 8pt))[#align(right + horizon)[{}]])\n",
row_y,
label_w_pt,
escape_typst(&b.label),
));
let w = width_of(b.value);
s.push_str(&format!(
"#place(top + left, dx: {bar_x:.1}pt, dy: {bar_y:.1}pt, \
rect(width: {bar_max_pt:.1}pt, height: {bar_h_pt:.1}pt, fill: rgb(228,239,247), radius: 2pt))\n"
));
let fill = if b.best { "rgb(45,156,210)" } else { "rgb(150,177,201)" };
if w > 0.5 {
s.push_str(&format!(
"#place(top + left, dx: {bar_x:.1}pt, dy: {bar_y:.1}pt, \
rect(width: {w:.1}pt, height: {bar_h_pt:.1}pt, fill: {fill}, radius: 2pt))\n"
));
}
let weight = if b.best { "bold" } else { "regular" };
let vfill = if b.best { "rgb(21,109,158)" } else { "rgb(90,104,118)" };
s.push_str(&format!(
"#place(top + left, dx: {:.1}pt, dy: {:.1}pt, \
text(size: 8pt, weight: \"{weight}\", fill: {vfill})[{}])\n",
bar_x + w + 6.0,
row_y,
escape_typst(&fmt_bar_value(b.value, &chart.unit, value_scale)),
));
}
s
}
pub fn render_bars_to_file(chart: &BarChart, out_path: &std::path::Path) -> Result<()> {
let svg = render_bars_svg(chart)?;
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
std::fs::write(out_path, svg)
.with_context(|| format!("write {}", out_path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn g_simple() -> Graph {
Graph {
nodes: vec!["a".into(), "b".into(), "c".into()],
edges: vec![
Edge { from: "a".into(), to: "b".into(), kind: EdgeKind::DependsOn },
Edge { from: "b".into(), to: "c".into(), kind: EdgeKind::DependsOn },
],
}
}
#[test]
fn layered_topological() {
let g = g_simple();
let layers = layered(&g.nodes, &g.edges);
assert_eq!(layers.len(), 3);
assert_eq!(layers[0], vec![0]);
assert_eq!(layers[1], vec![1]);
assert_eq!(layers[2], vec![2]);
}
#[test]
fn renders_non_empty_svg() {
let g = g_simple();
let svg = render_graph_svg(&g).expect("render");
assert!(svg.starts_with("<svg"), "expected svg root, got: {}", &svg[..40.min(svg.len())]);
assert!(svg.contains("</svg>"));
assert!(svg.len() > 200);
}
#[test]
fn cyclic_graph_does_not_hang() {
let g = Graph {
nodes: vec!["a".into(), "b".into()],
edges: vec![
Edge { from: "a".into(), to: "b".into(), kind: EdgeKind::Calls },
Edge { from: "b".into(), to: "a".into(), kind: EdgeKind::Calls },
],
};
let _ = render_graph_svg(&g).expect("cycle should not hang");
}
}