Skip to content

Commit f0bd31c

Browse files
authored
add gantt compact display mode via yaml frontmatter (#55)
1 parent b2d8780 commit f0bd31c

5 files changed

Lines changed: 222 additions & 6 deletions

File tree

src/ir.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ pub struct Graph {
412412
pub gantt_tasks: Vec<GanttTask>,
413413
pub gantt_title: Option<String>,
414414
pub gantt_sections: Vec<String>,
415+
pub gantt_display_mode: Option<String>,
415416
pub journey_title: Option<String>,
416417
pub gitgraph: GitGraphData,
417418
pub class_defs: HashMap<String, NodeStyle>,
@@ -554,6 +555,7 @@ impl Graph {
554555
gantt_tasks: Vec::new(),
555556
gantt_title: None,
556557
gantt_sections: Vec::new(),
558+
gantt_display_mode: None,
557559
journey_title: None,
558560
gitgraph: GitGraphData::default(),
559561
class_defs: HashMap::new(),

src/layout/gantt.rs

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,12 @@ pub(super) fn compute_gantt_layout(graph: &Graph, theme: &Theme, config: &Layout
252252
ticks.push(GanttTick { x, label });
253253
}
254254

255+
let compact = graph
256+
.gantt_display_mode
257+
.as_deref()
258+
.map(|m| m.eq_ignore_ascii_case("compact"))
259+
.unwrap_or(false);
260+
255261
let palette = gantt_palette(theme);
256262
let section_palette = gantt_section_palette(theme, &graph.gantt_sections);
257263
let mut current_section: Option<String> = None;
@@ -260,13 +266,18 @@ pub(super) fn compute_gantt_layout(graph: &Graph, theme: &Theme, config: &Layout
260266
let mut tasks: Vec<GanttTaskLayout> = Vec::new();
261267
let mut y = chart_y;
262268

269+
// Compact mode: pack non-overlapping tasks into the same row.
270+
// Each lane is y_position, end_time
271+
let mut lanes: Vec<(f32, f32)> = Vec::new();
272+
263273
for (idx, (label, start, duration, status, section)) in computed.iter().enumerate() {
264274
if section != &current_section {
265275
if let Some(sec) = section.as_ref() {
266276
if let Some(prev_idx) = current_section_idx {
267277
let height = (y - sections[prev_idx].y).max(row_height);
268278
sections[prev_idx].height = height;
269279
}
280+
lanes.clear();
270281
let base_color = section_palette
271282
.get(sec)
272283
.cloned()
@@ -284,6 +295,7 @@ pub(super) fn compute_gantt_layout(graph: &Graph, theme: &Theme, config: &Layout
284295
let height = (y - sections[prev_idx].y).max(row_height);
285296
sections[prev_idx].height = height;
286297
current_section_idx = None;
298+
lanes.clear();
287299
}
288300
current_section = section.clone();
289301
}
@@ -304,18 +316,35 @@ pub(super) fn compute_gantt_layout(graph: &Graph, theme: &Theme, config: &Layout
304316
};
305317
let color = gantt_task_color(*status, &base_color, &palette[0]);
306318

319+
let task_end = start + duration;
320+
let task_y = if compact {
321+
if let Some(lane) = lanes.iter_mut().find(|(_, end)| *start >= *end) {
322+
let ly = lane.0;
323+
lane.1 = task_end;
324+
ly
325+
} else {
326+
let ly = y;
327+
lanes.push((ly, task_end));
328+
y += row_height;
329+
ly
330+
}
331+
} else {
332+
let ly = y;
333+
y += row_height;
334+
ly
335+
};
336+
307337
tasks.push(GanttTaskLayout {
308338
label: measure_label(label, theme, config),
309339
x: bar_x,
310-
y,
340+
y: task_y,
311341
width: bar_width,
312342
height: row_height,
313343
color,
314344
start: *start,
315345
duration: *duration,
316346
status: *status,
317347
});
318-
y += row_height;
319348
}
320349
if let Some(prev_idx) = current_section_idx {
321350
let height = (y - sections[prev_idx].y).max(row_height);
@@ -339,8 +368,15 @@ pub(super) fn compute_gantt_layout(graph: &Graph, theme: &Theme, config: &Layout
339368
.fold(0.0_f32, f32::max);
340369
let axis_pad = row_height * 0.9 + theme.font_size;
341370
let height = y + padding + axis_pad;
342-
let width = (chart_x + chart_width + padding)
343-
.max(chart_x + chart_width + max_tick_half_width + padding * 0.4);
371+
let label_overflow = if compact {
372+
padding + task_label_width
373+
} else {
374+
0.0
375+
};
376+
let right_padding = (max_tick_half_width + padding * 0.4)
377+
.max(label_overflow)
378+
.max(padding);
379+
let width = chart_x + chart_width + right_padding;
344380

345381
Layout {
346382
kind: graph.kind,
@@ -366,8 +402,113 @@ pub(super) fn compute_gantt_layout(graph: &Graph, theme: &Theme, config: &Layout
366402
task_label_width,
367403
title_y: chart_y - row_height * 0.6,
368404
ticks,
405+
compact,
369406
}),
370407
width,
371408
height,
372409
}
373410
}
411+
412+
#[cfg(test)]
413+
mod tests {
414+
use crate::ir::{DiagramKind, GanttStatus, GanttTask, Graph};
415+
use crate::layout::LayoutConfig;
416+
use crate::layout::types::DiagramData;
417+
use crate::theme::Theme;
418+
419+
use super::compute_gantt_layout;
420+
421+
fn make_graph(display_mode: Option<&str>, tasks: Vec<GanttTask>) -> Graph {
422+
let mut graph = Graph::new();
423+
graph.kind = DiagramKind::Gantt;
424+
graph.gantt_display_mode = display_mode.map(|s| s.to_string());
425+
let mut sections = Vec::new();
426+
for t in &tasks {
427+
if let Some(sec) = &t.section {
428+
if !sections.contains(sec) {
429+
sections.push(sec.clone());
430+
}
431+
}
432+
}
433+
graph.gantt_sections = sections;
434+
graph.gantt_tasks = tasks;
435+
graph
436+
}
437+
438+
fn task(id: &str, section: &str, start: &str, dur: &str) -> GanttTask {
439+
GanttTask {
440+
id: id.to_string(),
441+
label: id.to_string(),
442+
start: Some(start.to_string()),
443+
duration: Some(dur.to_string()),
444+
after: None,
445+
section: Some(section.to_string()),
446+
status: None,
447+
}
448+
}
449+
450+
fn milestone(id: &str, section: &str, start: &str) -> GanttTask {
451+
GanttTask {
452+
id: id.to_string(),
453+
label: id.to_string(),
454+
start: Some(start.to_string()),
455+
duration: Some("0d".to_string()),
456+
after: None,
457+
section: Some(section.to_string()),
458+
status: Some(GanttStatus::Milestone),
459+
}
460+
}
461+
462+
fn extract_task_ys(graph: &Graph) -> Vec<f32> {
463+
let theme = Theme::modern();
464+
let config = LayoutConfig::default();
465+
let layout = compute_gantt_layout(graph, &theme, &config);
466+
match &layout.diagram {
467+
DiagramData::Gantt(g) => g.tasks.iter().map(|t| t.y).collect(),
468+
_ => panic!("expected Gantt layout"),
469+
}
470+
}
471+
472+
#[test]
473+
fn compact_non_overlapping_tasks_share_row() {
474+
let graph = make_graph(
475+
Some("compact"),
476+
vec![
477+
milestone("m1", "MacOS", "2025-09-01"),
478+
milestone("m2", "MacOS", "2026-09-01"),
479+
milestone("m3", "MacOS", "2027-09-01"),
480+
],
481+
);
482+
let ys = extract_task_ys(&graph);
483+
assert_eq!(ys[0], ys[1], "m1 and m2 should share a row");
484+
assert_eq!(ys[1], ys[2], "m2 and m3 should share a row");
485+
}
486+
487+
#[test]
488+
fn compact_overlapping_tasks_get_separate_rows() {
489+
let graph = make_graph(
490+
Some("compact"),
491+
vec![
492+
task("hw", "support", "2025-09-01", "1y"),
493+
task("sw", "support", "2025-09-01", "2y"),
494+
],
495+
);
496+
let ys = extract_task_ys(&graph);
497+
assert_ne!(ys[0], ys[1], "overlapping tasks must be on different rows");
498+
}
499+
500+
#[test]
501+
fn non_compact_always_separate_rows() {
502+
let graph = make_graph(
503+
None,
504+
vec![
505+
milestone("m1", "MacOS", "2025-09-01"),
506+
milestone("m2", "MacOS", "2026-09-01"),
507+
milestone("m3", "MacOS", "2027-09-01"),
508+
],
509+
);
510+
let ys = extract_task_ys(&graph);
511+
assert_ne!(ys[0], ys[1]);
512+
assert_ne!(ys[1], ys[2]);
513+
}
514+
}

src/layout/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,7 @@ pub struct GanttLayout {
585585
pub task_label_width: f32,
586586
pub title_y: f32,
587587
pub ticks: Vec<GanttTick>,
588+
pub compact: bool,
588589
}
589590

590591
#[derive(Debug, Clone)]

src/parser.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,19 @@ pub fn parse_mermaid(input: &str) -> Result<ParseOutput> {
8484
}
8585

8686
fn detect_diagram_kind(input: &str) -> DiagramKind {
87+
let mut in_frontmatter = false;
8788
for raw_line in input.lines() {
8889
let trimmed_line = raw_line.trim();
8990
if trimmed_line.is_empty() {
9091
continue;
9192
}
93+
if trimmed_line == "---" {
94+
in_frontmatter = !in_frontmatter;
95+
continue;
96+
}
97+
if in_frontmatter {
98+
continue;
99+
}
92100
if trimmed_line.starts_with("%%") {
93101
continue;
94102
}
@@ -176,12 +184,20 @@ fn detect_diagram_kind(input: &str) -> DiagramKind {
176184
fn preprocess_input(input: &str) -> Result<(Vec<String>, Option<serde_json::Value>)> {
177185
let mut init_config: Option<serde_json::Value> = None;
178186
let mut lines = Vec::new();
187+
let mut in_frontmatter = false;
179188

180189
for raw_line in input.lines() {
181190
let trimmed_line = raw_line.trim();
182191
if trimmed_line.is_empty() {
183192
continue;
184193
}
194+
if trimmed_line == "---" {
195+
in_frontmatter = !in_frontmatter;
196+
continue;
197+
}
198+
if in_frontmatter {
199+
continue;
200+
}
185201
if let Some(caps) = INIT_RE.captures(trimmed_line) {
186202
if let Some(json_str) = caps.get(1).map(|m| m.as_str()) {
187203
if let Ok(value) = serde_json::from_str::<serde_json::Value>(json_str) {
@@ -2072,12 +2088,39 @@ fn parse_timeline_diagram(input: &str) -> Result<ParseOutput> {
20722088
Ok(ParseOutput { graph, init_config })
20732089
}
20742090

2091+
fn extract_frontmatter_value(input: &str, key: &str) -> Option<String> {
2092+
let mut in_frontmatter = false;
2093+
for line in input.lines() {
2094+
let trimmed = line.trim();
2095+
if trimmed == "---" {
2096+
if in_frontmatter {
2097+
return None;
2098+
}
2099+
in_frontmatter = true;
2100+
continue;
2101+
}
2102+
if in_frontmatter {
2103+
if let Some((k, v)) = trimmed.split_once(':') {
2104+
if k.trim() == key {
2105+
let val = v.trim().to_string();
2106+
if !val.is_empty() {
2107+
return Some(val);
2108+
}
2109+
}
2110+
}
2111+
}
2112+
}
2113+
None
2114+
}
2115+
20752116
fn parse_gantt_diagram(input: &str) -> Result<ParseOutput> {
20762117
let mut graph = Graph::new();
20772118
graph.kind = DiagramKind::Gantt;
20782119
graph.direction = Direction::LeftRight;
20792120
let (lines, init_config) = preprocess_input(input)?;
20802121

2122+
graph.gantt_display_mode = extract_frontmatter_value(input, "displayMode");
2123+
20812124
let mut current_section: Option<usize> = None;
20822125
let mut current_section_name: Option<String> = None;
20832126
let mut last_task: Option<String> = None;
@@ -6396,6 +6439,22 @@ A["foo & bar"] & B --> C"#;
63966439
assert_eq!(parsed.graph.edges.len(), 1);
63976440
}
63986441

6442+
#[test]
6443+
fn parse_gantt_frontmatter_display_mode() {
6444+
let input = "---\ndisplayMode: compact\n---\ngantt\n title Plan\n section Alpha\n Task A : a1, 2020-01-01, 5d";
6445+
let parsed = parse_mermaid(input).unwrap();
6446+
assert_eq!(parsed.graph.kind, DiagramKind::Gantt);
6447+
assert_eq!(parsed.graph.gantt_display_mode.as_deref(), Some("compact"),);
6448+
assert!(
6449+
!parsed
6450+
.graph
6451+
.gantt_tasks
6452+
.iter()
6453+
.any(|t| t.label.contains("displayMode")),
6454+
"displayMode should not appear as a task"
6455+
);
6456+
}
6457+
63996458
#[test]
64006459
fn parse_requirement_basic() {
64016460
let input = "requirementDiagram\n requirement req1 {\n id: 1\n text: Login\n }\n requirement req2\n req1 - satisfies -> req2";

src/render.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3388,14 +3388,27 @@ fn render_gantt(
33883388
}
33893389
// Task label
33903390
if !label_rendered_inside {
3391+
// In compact mode, place next to the element to avoid
3392+
// overlapping labels when multiple tasks share a row.
3393+
let (label_x, anchor) = if layout.compact {
3394+
let gap = theme.font_size * 0.4;
3395+
if matches!(task.status, Some(crate::ir::GanttStatus::Milestone)) {
3396+
let size = bar_height * 0.6;
3397+
(task.x + size + gap, "start")
3398+
} else {
3399+
(task.x + task.width + gap, "start")
3400+
}
3401+
} else {
3402+
(layout.task_label_x, "start")
3403+
};
33913404
svg.push_str(&text_block_svg_with_font_size(
3392-
layout.task_label_x,
3405+
label_x,
33933406
row_center,
33943407
&task.label,
33953408
theme,
33963409
config,
33973410
task_font,
3398-
"start",
3411+
anchor,
33993412
Some(theme.primary_text_color.as_str()),
34003413
false,
34013414
));

0 commit comments

Comments
 (0)