@@ -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+ }
0 commit comments