use super::constants::*;
use super::parser::{KanbanDiagram, KanbanSection, NodeShape};
use super::templates::{self, assignee_label, build_css, esc, priority_line, ticket_link};
use crate::text::measure;
use crate::theme::Theme;
fn item_height_full(item: &crate::diagrams::kanban::parser::KanbanItem) -> f64 {
let base = item_height(&item.label);
if item.ticket.is_some() || item.assigned.is_some() {
base + 12.0
} else {
base
}
}
fn item_height(label: &str) -> f64 {
let (text_w, _) = measure(label, FONT_SIZE);
let lines = ((text_w * TEXT_SCALE) / AVAILABLE_WIDTH).ceil().max(1.0);
(lines * LINE_HEIGHT + V_PADDING).max(ITEM_HEIGHT)
}
fn priority_color(priority: &str) -> Option<&'static str> {
match priority.to_lowercase().as_str() {
"very high" => Some("red"),
"high" => Some("orange"),
"low" => Some("blue"),
"very low" => Some("lightblue"),
_ => None,
}
}
fn fo_height(label: &str) -> f64 {
item_height(label) - 4.0
}
#[allow(dead_code)]
fn text_height(label: &str) -> f64 {
let (text_w, _) = measure(label, FONT_SIZE);
let lines = ((text_w * TEXT_SCALE) / AVAILABLE_WIDTH).ceil().max(1.0);
lines * LINE_HEIGHT
}
fn col_left_x(idx: usize) -> f64 {
COL_LEFT_BASE + idx as f64 * (SECTION_WIDTH + SECTION_GAP)
}
fn col_center_x(idx: usize) -> f64 {
col_left_x(idx) + SECTION_WIDTH / 2.0
}
fn col_height_dynamic(items: &[crate::diagrams::kanban::parser::KanbanItem]) -> f64 {
if items.is_empty() {
return 50.0;
}
let total: f64 = items
.iter()
.map(|it| item_height_full(it) + ITEM_GAP)
.sum::<f64>()
- ITEM_GAP;
LABEL_HEIGHT + total + 10.0
}
fn item_center_ys(items: &[crate::diagrams::kanban::parser::KanbanItem]) -> Vec<f64> {
let mut ys = Vec::with_capacity(items.len());
let mut y = COL_TOP + LABEL_HEIGHT;
for item in items {
let h = item_height_full(item);
ys.push(y + h / 2.0);
y += h + ITEM_GAP;
}
ys
}
fn render_section_and_items(
section: &KanbanSection,
sec_idx: usize, col_idx: usize, svg_id: &str,
ticket_base_url: Option<&str>,
) -> (String, String) {
let cx = col_center_x(col_idx);
let lx = col_left_x(col_idx);
let col_top = COL_TOP;
let col_h = col_height_dynamic(§ion.items);
let item_centers = item_center_ys(§ion.items);
let mut sec_svg = templates::section_group_open(sec_idx, svg_id, &esc(§ion.id));
sec_svg.push_str(&templates::section_rect(lx, col_top, SECTION_WIDTH, col_h));
sec_svg.push_str(&templates::section_label_fo(
lx + 20.0,
col_top,
&esc(§ion.label),
));
sec_svg.push_str("</g>");
let mut items_svg = String::new();
let item_w_half = ITEM_WIDTH / 2.0;
for (item_idx, item) in section.items.iter().enumerate() {
let icy = item_centers[item_idx];
let dyn_h = item_height_full(item);
let item_h_half = dyn_h / 2.0;
let has_meta = item.ticket.is_some() || item.assigned.is_some();
items_svg.push_str(&templates::item_group_open(svg_id, &esc(&item.id), cx, icy));
let (rx_val, _shape_extra) = match item.shape {
NodeShape::Circle => {
let r = item_h_half.min(item_w_half);
items_svg.push_str(&templates::item_circle(r));
items_svg.push_str(&templates::item_label_fo_fixed(
-item_w_half + 10.0,
-item_h_half + 4.0,
ITEM_WIDTH - 10.0,
ITEM_WIDTH - 10.0,
fo_height(&item.label),
&esc(&item.label),
));
items_svg.push_str("</g>");
continue;
}
NodeShape::RoundedRect => (item_h_half / 2.0, None::<String>),
NodeShape::Hexagon => {
let dx = item_w_half / 2.0;
let pts = format!(
"{:.2},{:.2} {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} {:.2},{:.2}",
-item_w_half,
0.0,
-item_w_half + dx,
-item_h_half,
item_w_half - dx,
-item_h_half,
item_w_half,
0.0,
item_w_half - dx,
item_h_half,
-item_w_half + dx,
item_h_half,
);
items_svg.push_str(&templates::item_hexagon(&pts));
items_svg.push_str(&templates::item_label_fo_fixed(
-item_w_half + 10.0,
-item_h_half / 2.0,
ITEM_WIDTH - 10.0,
ITEM_WIDTH - 10.0,
fo_height(&item.label),
&esc(&item.label),
));
items_svg.push_str("</g>");
continue;
}
NodeShape::Cloud => (item_h_half, None),
NodeShape::Bang => (4.0, None),
NodeShape::Default => {
items_svg.push_str(&templates::item_default_rect(
-item_w_half,
-item_h_half,
ITEM_WIDTH,
dyn_h,
));
items_svg.push_str(&templates::item_label_fo_fixed(
-item_w_half + 10.0,
-item_h_half / 2.0 - 6.0,
ITEM_WIDTH - 10.0,
ITEM_WIDTH - 10.0,
fo_height(&item.label),
&esc(&item.label),
));
items_svg.push_str("</g>");
continue;
}
NodeShape::Rect => (5.0, None),
};
items_svg.push_str(&templates::item_rect(
rx_val,
-item_w_half,
-item_h_half,
ITEM_WIDTH,
dyn_h,
));
let (label_ty, label_fo_h) = if has_meta {
let ty = -(item_h_half - 4.0);
let fh = text_height(&item.label);
(ty, fh)
} else {
(-item_h_half + 10.0, fo_height(&item.label))
};
items_svg.push_str(&templates::item_label_fo(
-item_w_half + 10.0,
label_ty,
ITEM_WIDTH - 10.0,
ITEM_WIDTH - 10.0,
label_fo_h,
&esc(&item.label),
));
if has_meta {
let meta_y = label_ty + label_fo_h;
if let Some(ref ticket) = item.ticket {
let ticket_url = ticket_base_url
.map(|u| u.replace("#TICKET#", ticket))
.unwrap_or_default();
if ticket_url.is_empty() {
items_svg.push_str(&templates::item_label_fo_fixed(
-item_w_half + 10.0,
meta_y,
60.0,
60.0,
24.0,
&esc(ticket),
));
} else {
items_svg.push_str(&ticket_link(
&ticket_url,
-item_w_half + 10.0,
meta_y,
&esc(ticket),
));
}
}
if let Some(ref assigned) = item.assigned {
items_svg.push_str(&assignee_label(
-5.0,
meta_y,
item_w_half + 5.0,
&esc(assigned),
));
}
} else {
items_svg.push_str(&templates::item_label_empty(
-item_w_half + 10.0,
item_h_half - 10.0,
ITEM_WIDTH - 10.0,
));
items_svg.push_str(&templates::item_label_empty(
item_w_half - 10.0,
item_h_half - 10.0,
ITEM_WIDTH - 10.0,
));
}
if let Some(ref p) = item.priority {
if let Some(color) = priority_color(p) {
items_svg.push_str(&priority_line(
-item_w_half + 2.0,
-item_h_half + 2.0,
item_h_half - 2.0,
color,
));
}
}
items_svg.push_str("</g>");
}
(sec_svg, items_svg)
}
#[allow(dead_code)]
fn estimate_text_width(text: &str, font_size: f64) -> f64 {
text.len() as f64 * font_size * 0.6
}
pub fn render(diag: &KanbanDiagram, theme: Theme) -> String {
let vars = theme.resolve();
let ff = vars.font_family;
if diag.sections.is_empty() {
return templates::empty_svg().to_string();
}
let svg_id = "mermaid-svg-65";
let n_cols = diag.sections.len();
let max_col_h = diag
.sections
.iter()
.map(|s| col_height_dynamic(&s.items))
.fold(0.0_f64, f64::max);
let vb_w = 15.0 + n_cols as f64 * (SECTION_WIDTH + SECTION_GAP);
let vb_h = max_col_h + MARGIN * 2.0;
let css = build_css(svg_id, ff);
let mut out = String::new();
out.push_str(&templates::svg_root(
svg_id,
vb_w,
VIEWBOX_X as i64,
VIEWBOX_Y as i64,
vb_w as u64,
vb_h as u64,
));
out.push_str("<style>");
out.push_str(&css);
out.push_str("</style>");
out.push_str("<g></g>");
let mut sections_svg = String::new();
let mut items_svg_parts: Vec<String> = Vec::new();
for (i, section) in diag.sections.iter().enumerate() {
let sec_idx = i + 1; let (sec_svg, items_svg) = render_section_and_items(
section,
sec_idx,
i,
svg_id,
diag.config.ticket_base_url.as_deref(),
);
sections_svg.push_str(&sec_svg);
items_svg_parts.push(items_svg);
}
out.push_str(r#"<g class="sections">"#);
out.push_str(§ions_svg);
out.push_str("</g>");
out.push_str(r#"<g class="items">"#);
for items_svg in &items_svg_parts {
out.push_str(items_svg);
}
out.push_str("</g>");
out.push_str("</svg>");
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagrams::kanban::parser;
#[test]
fn basic_render_produces_svg() {
let input = "kanban\n todo\n id1[Task 1]\n id2[Task 2]\n inProgress\n id3[Task 3]\n done\n id4[Task 4]";
let diag = parser::parse(input).diagram;
let svg = render(&diag, Theme::Default);
assert!(svg.contains("<svg"), "missing <svg");
assert!(svg.contains("Task 1"));
assert!(svg.contains("Task 2"));
assert!(svg.contains("Task 3"));
assert!(svg.contains("Task 4"));
assert!(svg.contains("todo"));
assert!(svg.contains("done"));
}
#[test]
fn empty_kanban_produces_svg() {
let diag = KanbanDiagram {
sections: vec![],
config: crate::diagrams::kanban::parser::KanbanConfig {
ticket_base_url: None,
},
};
let svg = render(&diag, Theme::Default);
assert!(svg.contains("<svg"));
}
#[test]
fn section_with_label_renders() {
let input = "kanban\n col1[\"To Do\"]\n item1[\"My Task\"]\n";
let diag = parser::parse(input).diagram;
let svg = render(&diag, Theme::Default);
assert!(svg.contains("To Do"));
assert!(svg.contains("My Task"));
}
#[test]
fn multiple_columns_have_different_x() {
let input = "kanban\n col1\n a[A]\n col2\n b[B]\n";
let diag = parser::parse(input).diagram;
let svg = render(&diag, Theme::Default);
assert!(svg.contains("col1"));
assert!(svg.contains("col2"));
}
#[test]
fn viewbox_matches_mermaid() {
let input = "kanban\n Todo\n id1[Write blog post]\n id2[Plan vacation]\n In Progress\n id3[Write code]\n Done\n id4[Create diagrams]";
let diag = parser::parse(input).diagram;
let svg = render(&diag, Theme::Default);
assert!(
svg.contains(r#"viewBox="90 -310 630 "#),
"viewBox width mismatch: {}",
&svg[..200]
);
}
#[test]
fn snapshot_default_theme() {
let input = "kanban\n Todo\n id1[Write blog post]\n id2[Plan vacation]\n In Progress\n id3[Write code]\n Done\n id4[Create diagrams]";
let diag = parser::parse(input).diagram;
let svg = render(&diag, crate::theme::Theme::Default);
insta::assert_snapshot!(crate::svg::normalize_floats(&svg));
}
}