use super::constants::*;
use super::parser::{GanttDiagram, Task, TaskStatus};
#[allow(unused_imports)]
use super::templates::{
self, build_style, esc, escape_id, exclude_rect, grid_domain_path, grid_group_open, grid_tick,
milestone_rect, section_band_rect, section_title, svg_root, task_bar_rect, task_text,
title_text, today_line,
};
use crate::text::measure;
use crate::theme::Theme;
fn svg_height(num_rows: usize) -> f64 {
CHART_TOP + (num_rows as f64) * ROW_HEIGHT + GRID_AXIS_OFFSET + GRID_BOTTOM_PAD + 25.0
}
fn compute_tick_interval(span_days: f64, explicit: Option<f64>) -> f64 {
if let Some(days) = explicit {
return days;
}
let desired = span_days / 10.0;
if desired <= 1.5 {
1.0
} else if desired <= 3.5 {
2.0
} else if desired <= 7.0 {
7.0
} else if desired <= 14.0 {
14.0
} else {
30.0
}
}
fn format_date(days: f64) -> String {
let z = days as i64 + 719468;
let era = z.div_euclid(146097);
let doe = z.rem_euclid(146097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{:04}-{:02}-{:02}", y, m, d)
}
fn task_class(task: &Task) -> String {
let sec = task.section_index % 4;
let base = match (&task.status, task.crit) {
(TaskStatus::Done, true) => format!("doneCrit{sec}"),
(TaskStatus::Done, false) => format!("done{sec}"),
(TaskStatus::Active, true) => format!("activeCrit{sec}"),
(TaskStatus::Active, false) => format!("active{sec}"),
(TaskStatus::Normal, true) => format!("crit{sec}"),
(TaskStatus::Normal, false) => format!("task{sec}"),
};
base
}
fn text_class(task: &Task) -> String {
let sec = task.section_index % 4;
let mut classes = vec![format!("taskText{sec}")];
match (&task.status, task.crit) {
(TaskStatus::Done, true) => {
classes.push(format!("doneCritText{sec}"));
}
(TaskStatus::Done, false) => {
classes.push(format!("doneText{sec}"));
}
(TaskStatus::Active, true) => {
classes.push(format!("activeCritText{sec}"));
classes.push(format!("critText{sec}"));
}
(TaskStatus::Active, false) => {
classes.push(format!("activeText{sec}"));
}
(TaskStatus::Normal, true) => {
classes.push(format!("critText{sec}"));
}
(TaskStatus::Normal, false) => {}
}
if task.milestone {
classes.push("milestoneText".to_string());
}
classes.join(" ")
}
pub fn render(diag: &GanttDiagram, theme: Theme, _use_foreign_object: bool) -> String {
if diag.tasks.is_empty() {
return empty_svg();
}
let vars = theme.resolve();
let t_min = diag
.tasks
.iter()
.map(|t| t.start_day)
.fold(f64::INFINITY, f64::min);
let t_max_tasks = diag
.tasks
.iter()
.map(|t| t.end_day)
.fold(f64::NEG_INFINITY, f64::max);
let span_raw = (t_max_tasks - t_min).max(1.0);
let tick_days = compute_tick_interval(span_raw, diag.tick_interval_days);
let first_tick = if (tick_days - 7.0).abs() < 0.01 {
let t_floor = t_min.floor() as i64;
let dow = t_floor.rem_euclid(7); let days_to_sunday = (3 - dow).rem_euclid(7) as f64;
if days_to_sunday == 0.0 {
t_min
} else {
t_floor as f64 + days_to_sunday
}
} else if (tick_days - 1.0).abs() < 0.01 {
t_min.ceil()
} else if (tick_days - 2.0).abs() < 0.01 {
t_min.floor()
} else {
(t_min / tick_days).ceil() * tick_days
};
let mut ticks: Vec<f64> = Vec::new();
let mut t = first_tick;
while t <= t_max_tasks + tick_days * 0.01 {
ticks.push(t);
t += tick_days;
}
if tick_days > 1.0 {
if let Some(&last) = ticks.last() {
if (last - t_max_tasks).abs() < tick_days * 0.01 {
ticks.push(last + tick_days);
}
}
}
let t_max = ticks
.last()
.copied()
.unwrap_or(t_max_tasks)
.max(t_max_tasks);
let span = (t_max - t_min).max(1.0);
let day_to_x = |d: f64| -> f64 { LEFT_PAD + (d - t_min) / span * DRAW_WIDTH };
let num_rows = diag.tasks.len();
let height = svg_height(num_rows);
let grid_y = CHART_TOP + (num_rows as f64) * ROW_HEIGHT + GRID_AXIS_OFFSET;
let id = "mermaid-gantt";
let mut out = String::new();
out.push_str(&svg_root(id, SVG_WIDTH, height as i64));
out.push_str("<style>");
out.push_str(&build_style(id, &vars));
out.push_str("</style>");
out.push_str("<g></g>");
let exclude_weekends = diag.excludes.iter().any(|e| e == "weekends");
let excl_y = TITLE_TOP + 10.0; let excl_height = grid_y - excl_y;
out.push_str("<g>");
if exclude_weekends {
let t_min_i = t_min.floor() as i64;
let first_sat = {
let dow = t_min_i.rem_euclid(7); let days_to_sat = (2 - dow).rem_euclid(7);
t_min_i + days_to_sat
};
let mut sat = first_sat;
while (sat as f64) < t_max {
let excl_start = sat as f64;
let excl_end = excl_start + 2.0; let ex = day_to_x(excl_start).round() as i64;
let ex_end = day_to_x(excl_end).round() as i64;
let ew = (ex_end - ex).max(0);
let date_label = super::parser::format_date_public(excl_start);
out.push_str(&exclude_rect(
id,
&date_label,
ex,
excl_y as i64,
ew,
excl_height as i64,
(ex as f64 + ew as f64 / 2.0).round() as i64,
(excl_y + excl_height / 2.0).round() as i64,
));
sat += 7; }
}
out.push_str("</g>");
let grid_height = grid_y - (TITLE_TOP + 10.0);
out.push_str(&grid_group_open(
LEFT_PAD as i64,
grid_y as i64,
AXIS_FONT_SIZE as i64,
));
out.push_str(&grid_domain_path(
-(grid_height.round() as i64),
DRAW_WIDTH + 0.5,
));
for tick in &ticks {
let x = ((*tick - t_min) / span * DRAW_WIDTH).round() + 0.5;
let label = format_date(*tick);
out.push_str(&grid_tick(
x,
-(grid_height as i64),
AXIS_FONT_SIZE as i64,
&label,
));
}
out.push_str("</g>");
out.push_str("<g>");
let section_bands = compute_section_bands(diag);
let band_width = SVG_WIDTH - RIGHT_PAD / 2.0; for (sec_name, sec_idx, row_start, row_count) in §ion_bands {
let _ = sec_name;
let class_idx = sec_idx % 4;
for row_offset in 0..*row_count {
let band_y = CHART_TOP + (*row_start + row_offset) as f64 * ROW_HEIGHT;
out.push_str(§ion_band_rect(
band_y as i64,
band_width,
ROW_HEIGHT as i64,
class_idx,
));
}
}
out.push_str("</g>");
out.push_str("<g>");
for (row_idx, task) in diag.tasks.iter().enumerate() {
let bar_y = CHART_TOP + (row_idx as f64) * ROW_HEIGHT + BAR_OFFSET;
let bar_x = day_to_x(task.start_day);
let bar_w = (day_to_x(task.end_day) - bar_x).max(0.0);
let bar_cx = bar_x + bar_w / 2.0;
let bar_cy = bar_y + BAR_HEIGHT / 2.0;
let tc = task_class(task);
let _base_tc = base_task_class(task);
if task.milestone {
let half = BAR_HEIGHT / 2.0;
let mx = bar_cx;
let my = bar_y + BAR_HEIGHT / 2.0;
out.push_str(&milestone_rect(
id,
&escape_id(&task.id),
mx - half * 0.8,
my - half * 0.8,
BAR_HEIGHT * 0.8,
mx,
my,
&tc,
));
} else {
out.push_str(&task_bar_rect(
id,
&escape_id(&task.id),
bar_x.round() as i64,
bar_y as i64,
bar_w.round() as i64,
BAR_HEIGHT as i64,
bar_cx.round() as i64,
bar_cy.round() as i64,
&tc,
));
}
let text = task.label.trim_end().to_string();
let (text_w, _) = measure(&text, FONT_SIZE);
let text_y = bar_y + BAR_HEIGHT / 2.0 + 3.5;
let text_cls = format!(" taskText {}", text_class(task));
let tid = escape_id(&task.id);
if bar_w > 0.0 && text_w + 2.0 <= bar_w {
out.push_str(&task_text(
id,
&tid,
FONT_SIZE as i64,
bar_cx as i64,
text_y as i64,
BAR_HEIGHT as i64,
text_cls.trim(),
&esc(&text),
));
} else if bar_w < LEFT_PAD {
let outside_cls = format!("taskTextOutsideRight {}", text_class(task));
out.push_str(&task_text(
id,
&tid,
FONT_SIZE as i64,
(bar_x + bar_w + 2.0) as i64,
text_y as i64,
BAR_HEIGHT as i64,
outside_cls.trim(),
&esc(&text),
));
} else {
out.push_str(&task_text(
id,
&tid,
FONT_SIZE as i64,
bar_cx as i64,
text_y as i64,
BAR_HEIGHT as i64,
text_cls.trim(),
&esc(&text),
));
}
}
out.push_str("</g>");
out.push_str("<g>");
for (sec_name, sec_idx, row_start, row_count) in §ion_bands {
let band_center_y =
CHART_TOP + (*row_start as f64) * ROW_HEIGHT + (*row_count as f64) * ROW_HEIGHT / 2.0;
let class_idx = sec_idx % 4;
out.push_str(§ion_title(
band_center_y as i64,
SECTION_FONT_SIZE as i64,
class_idx,
&esc(sec_name),
));
}
out.push_str("</g>");
let today_days = today_days();
let today_x = day_to_x(today_days);
let chart_bottom = grid_y + GRID_BOTTOM_PAD;
out.push_str(&today_line(
today_x as i64,
TITLE_TOP as i64,
chart_bottom as i64,
));
if let Some(ref title) = diag.title {
out.push_str(&title_text(
(SVG_WIDTH / 2.0) as i64,
TITLE_TOP as i64,
&esc(title),
));
}
out.push_str("</svg>");
out
}
fn today_days() -> f64 {
super::parser::parse_date("2026-05-17").unwrap_or(0.0)
}
fn compute_section_bands(diag: &GanttDiagram) -> Vec<(String, usize, usize, usize)> {
let mut bands: Vec<(String, usize, usize, usize)> = Vec::new();
let mut last_sec: Option<(String, usize)> = None;
let mut row_start = 0usize;
let mut count = 0usize;
for task in &diag.tasks {
let sec_key = (task.section.clone(), task.section_index);
if let Some(ref lk) = last_sec {
if *lk == sec_key {
count += 1;
} else {
bands.push((lk.0.clone(), lk.1, row_start, count));
row_start += count;
count = 1;
last_sec = Some(sec_key);
}
} else {
last_sec = Some(sec_key);
count = 1;
}
}
if let Some(lk) = last_sec {
if count > 0 {
bands.push((lk.0.clone(), lk.1, row_start, count));
}
}
bands
}
fn base_task_class(task: &Task) -> String {
let sec = task.section_index % 4;
match (&task.status, task.crit) {
(TaskStatus::Done, true) => format!("doneCrit{sec}"),
(TaskStatus::Done, false) => format!("done{sec}"),
(TaskStatus::Active, true) => format!("activeCrit{sec}"),
(TaskStatus::Active, false) => format!("active{sec}"),
(TaskStatus::Normal, true) => format!("crit{sec}"),
(TaskStatus::Normal, false) => format!("task{sec}"),
}
}
fn empty_svg() -> String {
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50"><text x="10" y="30">Empty Gantt</text></svg>"#.to_string()
}
#[cfg(test)]
mod tests {
use super::super::parser;
use super::*;
#[test]
fn basic_render_produces_svg() {
let input = "gantt\n title A Gantt Diagram\n dateFormat YYYY-MM-DD\n section Section\n A task :a1, 2024-01-01, 30d\n Another task :after a1, 20d";
let diag = parser::parse(input).diagram;
let svg = render(&diag, crate::theme::Theme::Default, false);
assert!(svg.contains("<svg"), "no <svg element");
assert!(svg.contains("A Gantt Diagram"), "no title");
assert!(svg.contains("task0"), "no task bars");
assert!(svg.contains("sectionTitle"), "no section title");
}
#[test]
fn sections_render() {
let input = "gantt\n title Project Schedule\n dateFormat YYYY-MM-DD\n section Design\n Wireframes :des1, 2024-01-01, 14d\n Mockups :des2, after des1, 14d\n section Development\n Backend :dev1, after des1, 30d\n section Testing\n QA :qa1, after dev1, 14d";
let diag = parser::parse(input).diagram;
let svg = render(&diag, crate::theme::Theme::Default, false);
assert!(svg.contains("section0"));
assert!(svg.contains("section1"));
assert!(svg.contains("Design"));
assert!(svg.contains("Development"));
}
#[test]
fn milestones_render() {
let input = "gantt\n dateFormat YYYY-MM-DD\n title Adding GANTT milestones\n section A\n Completed task :done, des1, 2024-01-06, 2024-01-08\n Active task :active, des2, 2024-01-09, 3d\n Future task :des3, after des2, 5d\n section Critical\n Crit done task :crit, done, 2024-01-06, 24h\n Crit active task :crit, active, 3d\n Crit task :crit, 5d";
let diag = parser::parse(input).diagram;
let svg = render(&diag, crate::theme::Theme::Default, false);
assert!(svg.contains("done0"), "done task not rendered");
assert!(svg.contains("activeCrit"), "activeCrit not rendered");
assert!(svg.contains("doneCrit"), "doneCrit not rendered");
}
#[test]
fn format_date_roundtrip() {
let d = super::super::parser::parse_date("2024-01-15").unwrap();
let s = format_date(d);
assert_eq!(s, "2024-01-15");
}
#[test]
fn snapshot_default_theme() {
let input = "gantt\n title A Gantt Diagram\n dateFormat YYYY-MM-DD\n section Section\n A task :a1, 2024-01-01, 30d\n Another task :after a1, 20d";
let diag = parser::parse(input).diagram;
let svg = render(&diag, crate::theme::Theme::Default, false);
insta::assert_snapshot!(crate::svg::normalize_floats(&svg));
}
}