use super::constants::*;
use super::parser::{TimelineDiagram, TimelineTask};
use super::templates::{self, build_style, esc, text_tspan};
use crate::text::measure;
use crate::theme::Theme;
fn section_fill(idx: usize) -> &'static str {
SECTION_STYLES[idx % SECTION_STYLES.len()].fill
}
#[allow(dead_code)]
fn section_line(idx: usize) -> &'static str {
SECTION_STYLES[idx % SECTION_STYLES.len()].line
}
#[allow(dead_code)]
fn section_text(idx: usize) -> &'static str {
SECTION_STYLES[idx % SECTION_STYLES.len()].text
}
fn raw_node_height(text: &str) -> f64 {
let text_height = wrapped_text_height(text, NODE_WIDTH);
text_height + FONT_SIZE * 1.1 * 0.5 + NODE_PADDING
}
fn virtual_node_height(text: &str, max_height: f64) -> f64 {
raw_node_height(text).max(max_height)
}
fn wrapped_text_height(text: &str, max_width: f64) -> f64 {
let first_line_h = FONT_SIZE * 1.0634765625; let additional_line_h = FONT_SIZE * 1.1;
let lines = count_lines(text, max_width);
if lines == 1 {
first_line_h
} else {
first_line_h + (lines - 1) as f64 * additional_line_h
}
}
fn count_lines(text: &str, max_width: f64) -> usize {
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
return 1;
}
let mut lines = 1usize;
let mut current_words: Vec<&str> = Vec::new();
for word in &words {
let candidate_words: Vec<&str> = current_words
.iter()
.copied()
.chain(std::iter::once(*word))
.collect();
let candidate_text = candidate_words.join(" ");
let (w, _) = measure(&candidate_text, FONT_SIZE);
if w > max_width && !current_words.is_empty() {
lines += 1;
current_words = vec![word];
} else {
current_words = candidate_words;
}
}
lines
}
fn node_bg_path(width: f64, height: f64) -> String {
let r = NODE_CORNER_R;
let rd = NODE_CORNER_R;
format!(
"M0 {h_rd} v{neg_hm2rd} q0,-{r},{r},-{r} h{wm2rd} q{r},0,{r},{r} v{hrd} H0 Z",
h_rd = height - rd,
neg_hm2rd = -(height - 2.0 * rd),
r = r,
wm2rd = width - 2.0 * rd,
hrd = height - rd,
)
}
fn draw_node(
label: &str,
section_idx: usize,
node_id: &mut usize,
max_height: f64,
_is_event: bool,
) -> (String, f64) {
let width = RENDERED_WIDTH;
let height = virtual_node_height(label, max_height);
let section_class = (section_idx % 12) as i64 - 1;
let path_d = node_bg_path(width, height);
let id_val = *node_id;
*node_id += 1;
let text_ty = 10.0;
let text_tx = width / 2.0;
let tspans = build_tspans(label, NODE_WIDTH);
let mut svg = String::new();
svg.push_str(&templates::node_group_open(section_class));
svg.push_str(" <g>\n");
svg.push_str(&templates::node_bg_path(id_val, &path_d));
svg.push_str(&templates::node_separator_line(
section_class,
height,
width,
));
svg.push_str(" </g>\n");
svg.push_str(&templates::node_text_group(text_tx, text_ty, &tspans));
svg.push_str("</g>");
(svg, height)
}
fn build_tspans(text: &str, max_width: f64) -> String {
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
return String::new();
}
let line_height_em = 1.1_f64;
let mut lines: Vec<String> = Vec::new();
let mut current_words: Vec<&str> = Vec::new();
for word in &words {
let candidate_words: Vec<&str> = current_words
.iter()
.copied()
.chain(std::iter::once(*word))
.collect();
let candidate_3sp = candidate_words.join(" ");
let (w, _) = measure(&candidate_3sp, FONT_SIZE);
if w > max_width && !current_words.is_empty() {
lines.push(current_words.join(" "));
current_words = vec![word];
} else {
current_words = candidate_words;
}
}
if !current_words.is_empty() {
lines.push(current_words.join(" "));
}
let mut out = String::new();
out.push_str("<text dy=\"1em\" alignment-baseline=\"middle\" dominant-baseline=\"middle\" text-anchor=\"middle\">");
for (i, line) in lines.iter().enumerate() {
let dy = if i == 0 {
"1em".to_string()
} else {
format!("{:.1}em", line_height_em)
};
out.push_str(&text_tspan(&dy, &esc(line)));
}
out.push_str("</text>");
out
}
fn arrowhead_marker(id: &str) -> String {
templates::arrowhead_marker(id)
}
pub fn render(diag: &TimelineDiagram, theme: Theme) -> String {
let vars = theme.resolve();
let ff = vars.font_family;
let diagram_id = DIAGRAM_ID;
let mut node_id: usize = 0;
let tasks = &diag.tasks;
let sections = &diag.sections;
let has_sections = !sections.is_empty();
let mut max_section_height: f64 = 0.0;
let mut max_task_height: f64 = 0.0;
let mut max_event_line_length: f64 = 0.0;
let mut max_event_count: usize = 0;
for section in sections {
let h = raw_node_height(section);
max_section_height = max_section_height.max(h + 20.0);
}
for task in tasks {
let h = raw_node_height(&task.task);
max_task_height = max_task_height.max(h + 20.0);
max_event_count = max_event_count.max(task.events.len());
let mut event_line_len: f64 = 0.0;
for event in &task.events {
event_line_len += raw_node_height(event);
}
if !task.events.is_empty() {
event_line_len += (task.events.len() - 1) as f64 * 10.0;
}
max_event_line_length = max_event_line_length.max(event_line_len);
}
let _ = max_event_count;
let mut parts: Vec<String> = Vec::new();
parts.push(arrowhead_marker(diagram_id));
let mut master_x = MASTER_START_X;
let mut master_y = MASTER_START_Y;
let section_begin_y = SECTION_START_Y;
if has_sections {
for (section_number, section) in sections.iter().enumerate() {
let tasks_for_section: Vec<&TimelineTask> =
tasks.iter().filter(|t| t.section == *section).collect();
let section_width = 200.0 * (tasks_for_section.len().max(1) as f64) - 10.0;
let sec_h = virtual_node_height(section, max_section_height).max(max_section_height);
let sec_svg = draw_section_box(
section,
section_number,
section_width,
sec_h,
master_x,
section_begin_y,
);
parts.push(sec_svg);
master_y = section_begin_y + max_section_height + 50.0;
let task_svgs = draw_tasks(
tasks_for_section.as_slice(),
section_number,
master_x,
master_y,
max_task_height,
max_event_line_length,
diagram_id,
&mut node_id,
false,
);
parts.extend(task_svgs);
master_x += 200.0 * (tasks_for_section.len().max(1) as f64);
master_y = section_begin_y; let _ = master_y; }
} else {
let all_tasks: Vec<&TimelineTask> = tasks.iter().collect();
let task_svgs = draw_tasks(
all_tasks.as_slice(),
0,
master_x,
master_y,
max_task_height,
max_event_line_length,
diagram_id,
&mut node_id,
true,
);
parts.extend(task_svgs);
master_x += 200.0 * tasks.len() as f64;
}
let depth_y = if has_sections {
max_section_height + max_task_height + 150.0
} else {
max_task_height + 100.0
};
let box_width_before_line = master_x - TASK_STEP + RENDERED_WIDTH - MASTER_START_X;
let activity_line_x2 = box_width_before_line + 3.0 * LEFT_MARGIN;
parts.push(templates::activity_line(
LEFT_MARGIN,
depth_y,
activity_line_x2,
diagram_id,
));
let title_x = box_width_before_line / 2.0 - LEFT_MARGIN;
let title_svg = if let Some(title) = &diag.title {
templates::title_text(title_x, &esc(title))
} else {
String::new()
};
let vb_x = LEFT_MARGIN - VIEWBOX_PADDING; let vb_y = -60.0; let vb_w = (activity_line_x2 - LEFT_MARGIN) + 2.0 * VIEWBOX_PADDING; let task_y = if has_sections {
SECTION_START_Y + max_section_height + 50.0
} else {
MASTER_START_Y
};
let connector_y2 = task_y + max_task_height + 200.0 + max_event_line_length;
let vb_h = connector_y2 + 110.0;
let style = build_style(diagram_id, ff);
let mut svg = templates::svg_root(diagram_id, vb_w, vb_x, vb_y, vb_w, vb_h, &style);
for part in &parts {
svg.push_str(part);
svg.push('\n');
}
if !title_svg.is_empty() {
svg.push_str(&title_svg);
svg.push('\n');
}
svg.push_str("</svg>");
svg
}
fn draw_section_box(
label: &str,
section_idx: usize,
width: f64,
height: f64,
x: f64,
y: f64,
) -> String {
let color = section_fill(section_idx);
let text_color = section_text(section_idx);
let r = NODE_CORNER_R;
let rd = NODE_CORNER_R;
let path = format!(
"M0 {h_rd} v{neg} q0,-{r},{r},-{r} h{wm} q{r},0,{r},{r} v{hrd} H0 Z",
h_rd = height - rd,
neg = -(height - 2.0 * rd),
r = r,
wm = width - 2.0 * rd,
hrd = height - rd,
);
let text_x = width / 2.0;
let text_y = height / 2.0;
let line_class_idx = (section_idx % 12) as i64 - 1;
let line_color = section_line(section_idx);
templates::section_box(
x,
y,
&path,
color,
line_class_idx,
height,
width,
line_color,
text_x,
text_y,
text_color,
&esc(label),
)
}
#[allow(clippy::too_many_arguments)]
fn draw_tasks(
tasks: &[&TimelineTask],
section_color_start: usize,
mut master_x: f64,
master_y: f64,
max_task_height: f64,
max_event_line_length: f64,
diagram_id: &str,
node_id: &mut usize,
is_without_sections: bool,
) -> Vec<String> {
let mut parts: Vec<String> = Vec::new();
let mut section_color_idx = section_color_start;
for task in tasks {
let (task_svg, task_h) = draw_node(
&task.task,
section_color_idx,
node_id,
max_task_height,
false,
);
parts.push(templates::task_wrapper(master_x, master_y, &task_svg));
if !task.events.is_empty() {
let line_x = master_x + RENDERED_WIDTH / 2.0;
let line_y1 = master_y + task_h;
let line_y2 = master_y + max_task_height + 100.0 + max_event_line_length + 100.0;
parts.push(templates::connector_line(
line_x, line_y1, line_y2, diagram_id,
));
let event_start_y = master_y + 100.0; draw_events_append(
&task.events,
section_color_idx,
master_x,
event_start_y,
node_id,
&mut parts,
);
}
master_x += TASK_STEP;
if is_without_sections {
section_color_idx += 1;
}
}
parts
}
fn draw_events_append(
events: &[String],
section_idx: usize,
master_x: f64,
start_y: f64,
node_id: &mut usize,
parts: &mut Vec<String>,
) {
let mut current_y = start_y + 100.0;
for event in events {
let (event_svg, event_h) = draw_node(event, section_idx, node_id, 50.0, true);
parts.push(templates::event_wrapper(master_x, current_y, &event_svg));
current_y += 10.0 + event_h;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagrams::timeline::parser;
#[test]
fn basic_render_produces_svg() {
let input = concat!(
"timeline\n",
" title History of Social Media\n",
" 2002 : LinkedIn\n",
" 2004 : Facebook\n",
" : Google\n",
);
let diag = parser::parse(input).diagram;
let svg = render(&diag, Theme::Default);
assert!(svg.contains("<svg"), "missing <svg tag");
assert!(svg.contains("LinkedIn"));
assert!(svg.contains("Facebook"));
assert!(svg.contains("Google"));
assert!(svg.contains("History of Social Media"));
assert!(svg.contains("2002"));
assert!(svg.contains("2004"));
}
#[test]
fn with_sections_renders() {
let input = concat!(
"timeline\n",
" title History of Social Media Platform\n",
" section ICT and Internet\n",
" 1978 : first commercial social network\n",
" 1994 : GeoCities\n",
);
let diag = parser::parse(input).diagram;
let svg = render(&diag, Theme::Default);
assert!(svg.contains("ICT and Internet"));
assert!(svg.contains("1978"));
assert!(svg.contains("GeoCities"));
}
#[test]
fn activity_line_present() {
let input = "timeline\n 2002 : LinkedIn\n";
let diag = parser::parse(input).diagram;
let svg = render(&diag, Theme::Default);
assert!(svg.contains("stroke-width:4")); }
#[test]
fn node_bg_path_roundtrip() {
let p = node_bg_path(190.0, 60.0);
assert!(p.starts_with("M0 "));
assert!(p.contains('q'));
assert!(p.ends_with('Z'));
}
#[test]
fn full_example_with_sections() {
let input = concat!(
"timeline\n",
" title History of Social Media Platform\n",
" section ICT and Internet\n",
" 1978 : first commercial social network\n",
" 1994 : GeoCities\n",
" section Social Media\n",
" 2002 : LinkedIn\n",
" 2004 : Facebook\n",
" : Google\n",
);
let diag = parser::parse(input).diagram;
let svg = render(&diag, Theme::Default);
assert!(svg.contains("ICT and Internet"));
assert!(svg.contains("GeoCities"));
assert!(svg.contains("LinkedIn"));
assert!(svg.contains("Facebook"));
assert!(svg.contains("Google"));
}
#[test]
fn full_example_no_sections() {
let input = concat!(
"timeline\n",
" title History of Social Media Platform\n",
" 2002 : LinkedIn\n",
" 2004 : Facebook\n",
" : Google\n",
" 2005 : YouTube\n",
" 2006 : Twitter\n",
);
let diag = parser::parse(input).diagram;
let svg = render(&diag, Theme::Default);
assert!(svg.contains("YouTube"));
assert!(svg.contains("Twitter"));
assert!(svg.contains("LinkedIn"));
assert!(svg.contains("Facebook"));
}
#[test]
fn snapshot_default_theme() {
let input =
"timeline\n title History of Social Media\n 2002 : LinkedIn\n 2004 : Facebook";
let diag = parser::parse(input).diagram;
let svg = render(&diag, crate::theme::Theme::Default);
insta::assert_snapshot!(crate::svg::normalize_floats(&svg));
}
}