use crate::preview::mermaid::flowchart::{Shape, Stroke};
use crate::preview::mermaid::gantt::time::{self, Instant};
use crate::preview::mermaid::gantt::{Gantt, Task};
use crate::preview::mermaid::layout::Point;
use crate::preview::mermaid::text_metrics;
use super::band;
use super::chart::{label_node, rule, TICK_GAP, TICK_LEN};
use super::{
normalise, svg, Diagram, Glyph, Label, PlacedCluster, PlacedNode, RenderError, Size, Theme,
};
pub const PLOT_WIDTH: f64 = 520.0;
pub const ROW_HEIGHT: f64 = 24.0;
pub const ROW_GAP: f64 = 6.0;
pub const SECTION_PAD: f64 = 2.0;
pub const AXIS_GAP: f64 = 10.0;
pub const MILESTONE: f64 = 16.0;
pub const TICKS: usize = 6;
pub fn render(code: &str, theme: &str) -> Result<String, RenderError> {
let gantt = crate::preview::mermaid::gantt::parse(code)?;
let diagram = lay_out(&gantt)?;
Ok(svg::emit(&diagram, &Theme::named(theme)))
}
pub struct Scale {
pub left: f64,
pub right: f64,
pub from: Instant,
pub to: Instant,
}
impl Scale {
pub fn x(&self, at: Instant) -> f64 {
let span = (self.to.millis() - self.from.millis()) as f64;
if span <= 0.0 {
return self.left;
}
self.left + (at.millis() - self.from.millis()) as f64 / span * (self.right - self.left)
}
}
pub fn lay_out(gantt: &Gantt) -> Result<Diagram, RenderError> {
if !text_metrics::fonts_available() {
return Err(RenderError::NoFonts);
}
let Some((from, to)) = gantt.extent() else {
return Err(RenderError::NothingToDraw);
};
if to.millis() <= from.millis() {
return Err(RenderError::ChartHasNoExtent {
what: "every task starts and ends at the same moment",
});
}
let names: Vec<Label> = gantt
.tasks
.iter()
.map(|t| Label::measure(&t.name))
.collect();
let widest = names.iter().map(|l| l.width).fold(0.0_f64, f64::max);
let left = widest + TICK_GAP * 2.0;
let ticks = tick_dates(from, to, &gantt.tick_interval);
let tick_labels: Vec<Label> = ticks
.iter()
.map(|t| Label::measure(&time::format(*t, &gantt.axis_format)))
.collect();
let widest_tick = tick_labels.iter().map(|l| l.width).fold(0.0_f64, f64::max);
let want = (widest_tick + TICK_GAP * 2.0) * ticks.len().max(1) as f64;
let scale = Scale {
left,
right: left + PLOT_WIDTH.max(want),
from,
to,
};
let mut nodes: Vec<PlacedNode> = Vec::new();
let mut edges = Vec::new();
let axis_h = tick_labels.iter().map(|l| l.height).fold(0.0_f64, f64::max) + TICK_LEN + TICK_GAP;
let top = axis_h + AXIS_GAP;
let extra: f64 = section_bands(gantt);
let bottom = top + gantt.tasks.len() as f64 * (ROW_HEIGHT + ROW_GAP) + extra;
for (t, label) in ticks.iter().zip(tick_labels.iter()) {
let x = scale.x(*t);
edges.push(rule(
Point::new(x, axis_h - TICK_LEN),
Point::new(x, bottom),
Stroke::Dotted,
None,
));
if let Some(n) = label_node(
format!("tick#{}", time::format(*t, "%Y-%m-%dT%H:%M")),
label.clone(),
Point::new(x, axis_h - TICK_LEN - TICK_GAP - label.height / 2.0),
None,
) {
nodes.push(n);
}
}
let mut spans: Vec<(usize, f64, f64)> = Vec::new();
let mut y = top;
let mut open_section: Option<&str> = None;
for (i, task) in gantt.tasks.iter().enumerate() {
if open_section != Some(task.section.as_str()) {
if open_section.is_some() {
y += band::title_room(&task.section) + SECTION_PAD * 2.0;
}
open_section = Some(task.section.as_str());
}
let cy = y + ROW_HEIGHT / 2.0;
if let Some(n) = label_node(
format!("task#{i}#name"),
names[i].clone(),
Point::new(widest - names[i].width / 2.0, cy),
None,
) {
nodes.push(n);
}
if task.tags.milestone {
nodes.push(PlacedNode {
id: format!("task#{i}"),
shape: Glyph::Flow(Shape::Diamond),
center: Point::new(scale.x(task.start), cy),
size: Size::new(MILESTONE, MILESTONE),
label: Label::measure(""),
panel: None,
series: Some(series_of(task)),
mark: None,
});
} else {
let x0 = scale.x(task.start);
let x1 = scale.x(task.end);
let w = (x1 - x0).max(1.0);
nodes.push(PlacedNode {
id: format!("task#{i}"),
shape: Glyph::ChartBar,
center: Point::new(x0 + w / 2.0, cy),
size: Size::new(w, ROW_HEIGHT * 0.7),
label: Label::measure(""),
panel: None,
series: Some(series_of(task)),
mark: None,
});
}
spans.push((i, y, y + ROW_HEIGHT));
y += ROW_HEIGHT + ROW_GAP;
}
let content_right = nodes
.iter()
.map(|n| n.bounds().2)
.fold(scale.right, f64::max);
let mut sections: Vec<PlacedCluster> = Vec::new();
let mut run: Option<(String, f64, f64)> = None;
let mut ordinal = 0usize;
for (i, start, end) in &spans {
let name = gantt.tasks[*i].section.clone();
match &mut run {
Some((open, _, last)) if *open == name => *last = *end,
_ => {
if let Some((open, t0, t1)) = run.take() {
sections.push(section(ordinal, &open, t0, t1, 0.0, content_right));
ordinal += 1;
}
run = Some((name, *start, *end));
}
}
}
if let Some((open, t0, t1)) = run.take() {
sections.push(section(ordinal, &open, t0, t1, 0.0, content_right));
}
sections.retain(|s| !s.title.is_blank());
let mut diagram = Diagram {
nodes,
edges,
clusters: sections,
..Diagram::default()
};
band::add_title(&mut diagram, gantt.preamble.title.as_deref());
normalise(&mut diagram);
Ok(diagram)
}
fn section_bands(gantt: &Gantt) -> f64 {
let mut total = 0.0;
let mut open: Option<&str> = None;
for task in &gantt.tasks {
if open != Some(task.section.as_str()) {
if open.is_some() {
total += band::title_room(&task.section) + SECTION_PAD * 2.0;
}
open = Some(task.section.as_str());
}
}
total
}
fn section(
ordinal: usize,
title: &str,
top: f64,
bottom: f64,
left: f64,
right: f64,
) -> PlacedCluster {
band::frame(
format!("section#{ordinal}#{title}"),
title,
left - SECTION_PAD,
top - SECTION_PAD,
right + SECTION_PAD,
bottom + SECTION_PAD,
)
}
fn series_of(task: &Task) -> usize {
match (task.tags.crit, task.tags.done, task.tags.active) {
(true, _, _) => 3,
(_, true, _) => 1,
(_, _, true) => 2,
_ => 0,
}
}
pub fn tick_dates(from: Instant, to: Instant, interval: &str) -> Vec<Instant> {
let span = (to.millis() - from.millis()).max(1);
let (count, unit) = read_interval(interval).unwrap_or_else(|| {
let day = time::MS_PER_DAY;
let days = span / day;
if days <= 1 {
(6, time::Unit::Hour)
} else if days <= TICKS as i64 * 2 {
(1, time::Unit::Day)
} else if days <= TICKS as i64 * 14 {
(1, time::Unit::Week)
} else if days <= TICKS as i64 * 90 {
(1, time::Unit::Month)
} else {
(1, time::Unit::Year)
}
});
let mut out = Vec::new();
let mut at = floor_to(from, unit);
if at < from {
at = at.add(count.max(1), unit);
}
while at <= to && out.len() < 64 {
if at >= from {
out.push(at);
}
let next = at.add(count.max(1), unit);
if next <= at {
break;
}
at = next;
}
if out.is_empty() {
out.push(from);
out.push(to);
}
out
}
fn read_interval(text: &str) -> Option<(i64, time::Unit)> {
let t = text.trim();
let digits = t.find(|c: char| !c.is_ascii_digit())?;
if digits == 0 {
return None;
}
let n: i64 = t[..digits].parse().ok()?;
let unit = match t[digits..].trim().to_ascii_lowercase().as_str() {
"millisecond" => time::Unit::Millisecond,
"second" => time::Unit::Second,
"minute" => time::Unit::Minute,
"hour" => time::Unit::Hour,
"day" => time::Unit::Day,
"week" => time::Unit::Week,
"month" => time::Unit::Month,
_ => return None,
};
Some((n, unit))
}
fn floor_to(at: Instant, unit: time::Unit) -> Instant {
let (y, m, _, _, _, _, _) = at.civil();
match unit {
time::Unit::Year => Instant::from_civil(y, 1, 1, 0, 0, 0, 0),
time::Unit::Month => Instant::from_civil(y, m, 1, 0, 0, 0, 0),
time::Unit::Week | time::Unit::Day => at.start_of_day(),
_ => at,
}
}