Skip to main content

merman_render/
timeline.rs

1#![allow(clippy::too_many_arguments)]
2
3use crate::Result;
4use crate::model::{
5    Bounds, TimelineDiagramLayout, TimelineLineLayout, TimelineNodeLayout, TimelineSectionLayout,
6    TimelineTaskLayout,
7};
8use crate::text::{TextMeasurer, TextStyle};
9use serde::Deserialize;
10use std::borrow::Cow;
11
12const MAX_SECTIONS: i64 = 12;
13
14const BASE_MARGIN: f64 = 50.0;
15const NODE_PADDING: f64 = 20.0;
16const TASK_STEP_X: f64 = 200.0;
17const TASK_CONTENT_WIDTH_DEFAULT: f64 = 150.0;
18const EVENT_VERTICAL_OFFSET_FROM_TASK_Y: f64 = 200.0;
19const EVENT_GAP_Y: f64 = 10.0;
20
21const TITLE_Y: f64 = 20.0;
22const DEFAULT_VIEWBOX_PADDING: f64 = 50.0;
23
24#[derive(Debug, Clone, Deserialize)]
25struct TimelineTaskModel {
26    #[allow(dead_code)]
27    id: i64,
28    section: String,
29    #[serde(rename = "type")]
30    #[allow(dead_code)]
31    task_type: String,
32    task: String,
33    #[allow(dead_code)]
34    score: i64,
35    #[serde(default)]
36    events: Vec<String>,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40struct TimelineModel {
41    #[serde(rename = "accTitle")]
42    acc_title: Option<String>,
43    #[serde(rename = "accDescr")]
44    acc_descr: Option<String>,
45    #[serde(default)]
46    sections: Vec<String>,
47    #[serde(default)]
48    tasks: Vec<TimelineTaskModel>,
49    title: Option<String>,
50    #[serde(rename = "type")]
51    diagram_type: String,
52}
53
54fn cfg_f64(cfg: &serde_json::Value, path: &[&str]) -> Option<f64> {
55    let mut cur = cfg;
56    for k in path {
57        cur = cur.get(*k)?;
58    }
59    cur.as_f64()
60}
61
62fn cfg_bool(cfg: &serde_json::Value, path: &[&str]) -> Option<bool> {
63    let mut cur = cfg;
64    for k in path {
65        cur = cur.get(*k)?;
66    }
67    cur.as_bool()
68}
69
70fn cfg_string(cfg: &serde_json::Value, path: &[&str]) -> Option<String> {
71    let mut cur = cfg;
72    for k in path {
73        cur = cur.get(*k)?;
74    }
75    cur.as_str().map(|s| s.to_string())
76}
77
78fn cfg_f64_css_px(cfg: &serde_json::Value, path: &[&str]) -> Option<f64> {
79    let mut cur = cfg;
80    for k in path {
81        cur = cur.get(*k)?;
82    }
83    cur.as_f64()
84        .or_else(|| cur.as_i64().map(|n| n as f64))
85        .or_else(|| cur.as_u64().map(|n| n as f64))
86        .or_else(|| {
87            let raw = cur.as_str()?;
88            let t = raw.trim().trim_end_matches(';').trim();
89            let t = t.trim_end_matches("!important").trim();
90            let t = t.trim_end_matches("px").trim();
91            t.parse::<f64>().ok()
92        })
93}
94
95fn timeline_text_style(effective_config: &serde_json::Value) -> TextStyle {
96    let font_family = cfg_string(effective_config, &["themeVariables", "fontFamily"])
97        .or_else(|| cfg_string(effective_config, &["fontFamily"]))
98        .map(|s| s.trim().trim_end_matches(';').trim().to_string())
99        .filter(|s| !s.is_empty());
100    let font_size = cfg_f64_css_px(effective_config, &["themeVariables", "fontSize"])
101        .or_else(|| cfg_f64_css_px(effective_config, &["fontSize"]))
102        .unwrap_or(16.0)
103        .max(1.0);
104    TextStyle {
105        font_family,
106        font_size,
107        font_weight: None,
108    }
109}
110
111fn section_index(full_section: i64) -> i64 {
112    (full_section % MAX_SECTIONS) - 1
113}
114
115fn section_class(full_section: i64) -> String {
116    format!("section-{}", section_index(full_section))
117}
118
119fn wrap_tokens(text: &str) -> Vec<String> {
120    let mut out: Vec<String> = Vec::new();
121    let mut buf = String::new();
122    let bytes = text.as_bytes();
123    let mut i = 0;
124    while i < bytes.len() {
125        let ch = text[i..].chars().next().unwrap();
126        if ch.is_whitespace() {
127            if !buf.is_empty() {
128                out.push(std::mem::take(&mut buf));
129            }
130            // Coalesce any whitespace run into a single token.
131            while i < bytes.len() {
132                let c = text[i..].chars().next().unwrap();
133                if !c.is_whitespace() {
134                    break;
135                }
136                i += c.len_utf8();
137            }
138            out.push(" ".to_string());
139            continue;
140        }
141
142        let rest = &text[i..];
143        if rest.starts_with("<br>") || rest.starts_with("<br/>") || rest.starts_with("<br />") {
144            if !buf.is_empty() {
145                out.push(std::mem::take(&mut buf));
146            }
147            if rest.starts_with("<br>") {
148                i += "<br>".len();
149            } else if rest.starts_with("<br/>") {
150                i += "<br/>".len();
151            } else {
152                i += "<br />".len();
153            }
154            out.push("<br>".to_string());
155            continue;
156        }
157
158        buf.push(ch);
159        i += ch.len_utf8();
160    }
161    if !buf.is_empty() {
162        out.push(buf);
163    }
164    out
165}
166
167fn join_trim(tokens: &[String]) -> String {
168    tokens.join(" ").trim().to_string()
169}
170
171fn svg_collapse_whitespace_for_measure(s: &str) -> Cow<'_, str> {
172    // Mermaid timeline wrap decisions use `tspan.getComputedTextLength()`, which measures the
173    // rendered text. SVG text collapses whitespace runs unless `xml:space="preserve"` is set.
174    // Mermaid does not set `xml:space`, so we mirror that collapsing here.
175    let mut out: Option<String> = None;
176    let mut last_space = false;
177    let mut saw_non_space = false;
178
179    for ch in s.chars() {
180        if ch.is_whitespace() {
181            if !saw_non_space || last_space {
182                continue;
183            }
184            out.get_or_insert_with(|| String::with_capacity(s.len()))
185                .push(' ');
186            last_space = true;
187        } else {
188            saw_non_space = true;
189            out.get_or_insert_with(|| String::with_capacity(s.len()))
190                .push(ch);
191            last_space = false;
192        }
193    }
194
195    let Some(mut out) = out else {
196        return Cow::Borrowed(s.trim());
197    };
198    if out.ends_with(' ') {
199        out.pop();
200    }
201    Cow::Owned(out)
202}
203
204fn wrap_lines(
205    text: &str,
206    max_width: f64,
207    style: &TextStyle,
208    measurer: &dyn TextMeasurer,
209) -> Vec<String> {
210    let tokens = wrap_tokens(text);
211    if tokens.is_empty() {
212        return vec![String::new()];
213    }
214
215    let mut lines: Vec<String> = Vec::new();
216    let mut cur: Vec<String> = Vec::new();
217
218    for tok in tokens {
219        cur.push(tok.clone());
220        let candidate = join_trim(&cur);
221        let candidate = svg_collapse_whitespace_for_measure(&candidate);
222        // Mermaid uses `getComputedTextLength()` here, but our headless measurer cannot reproduce
223        // the exact font fallback behavior of the Puppeteer baselines. Empirically, the single-run
224        // SVG `getBBox().width` probe is a closer and more stable approximation for wrap
225        // boundaries in timeline fixtures.
226        let candidate_width = measurer.measure_svg_simple_text_bbox_width_px(&candidate, style);
227        if candidate_width > max_width || tok == "<br>" {
228            cur.pop();
229            lines.push(join_trim(&cur));
230            if tok == "<br>" {
231                cur = vec![String::new()];
232            } else {
233                cur = vec![tok];
234            }
235        }
236    }
237
238    lines.push(join_trim(&cur));
239    if lines.is_empty() {
240        vec![String::new()]
241    } else {
242        lines
243    }
244}
245
246fn text_bbox_height(lines: &[String], font_size: f64) -> f64 {
247    // Mermaid timeline measures SVG `<text>.getBBox().height` (see upstream `svgDraw.js`).
248    //
249    // In Mermaid@11.12.2, the bbox behaves as:
250    // - first line: ~1.1875em (Trebuchet MS default)
251    // - additional lines: 1.1em each
252    let font_size = font_size.max(1.0);
253    let lines = lines.iter().filter(|l| !l.trim().is_empty()).count();
254    if lines == 0 {
255        return 0.0;
256    }
257    let first_line_em = 1.1875;
258    // Empirically, browser `getBBox().height` for SVG `<text>` in upstream timeline fixtures can
259    // "snap" the first-line height down to an integer for non-default font sizes (notably when
260    // the diagram sets `themeVariables.fontSize`). Mirror that so our layout stays on the same
261    // lattice as upstream `mmdc` baselines.
262    let first = (font_size * first_line_em).floor();
263    let additional = (lines.saturating_sub(1) as f64) * font_size * 1.1;
264    first + additional
265}
266
267fn virtual_node_height(
268    text: &str,
269    content_width: f64,
270    style: &TextStyle,
271    layout_font_size: f64,
272    padding: f64,
273    measurer: &dyn TextMeasurer,
274) -> (f64, Vec<String>) {
275    // Mermaid timeline `wrap()` compares `tspan.getComputedTextLength()` against `node.width`
276    // (the configured inner width, excluding padding).
277    let lines = wrap_lines(text, content_width.max(1.0), style, measurer);
278    let bbox_h = text_bbox_height(&lines, style.font_size);
279    // Mermaid timeline uses `conf.fontSize` (top-level `config.fontSize`) for the extra vertical
280    // offset, even when the actual rendered font size comes from `themeVariables.fontSize`.
281    let h = bbox_h + layout_font_size.max(1.0) * 1.1 * 0.5 + padding;
282    (h, lines)
283}
284
285fn compute_node(
286    kind: &str,
287    label: &str,
288    full_section: i64,
289    x: f64,
290    y: f64,
291    content_width: f64,
292    max_height: f64,
293    style: &TextStyle,
294    layout_font_size: f64,
295    measurer: &dyn TextMeasurer,
296) -> TimelineNodeLayout {
297    let (h0, label_lines) = virtual_node_height(
298        label,
299        content_width,
300        style,
301        layout_font_size,
302        NODE_PADDING,
303        measurer,
304    );
305    let height = h0.max(max_height).max(1.0);
306    let width = (content_width + NODE_PADDING * 2.0).max(1.0);
307    TimelineNodeLayout {
308        x,
309        y,
310        width,
311        height,
312        content_width: content_width.max(1.0),
313        padding: NODE_PADDING,
314        section_class: section_class(full_section),
315        label: label.to_string(),
316        label_lines,
317        kind: kind.to_string(),
318    }
319}
320
321fn bounds_from_nodes_and_lines<'a, 'b>(
322    nodes: impl IntoIterator<Item = &'a TimelineNodeLayout>,
323    lines: impl IntoIterator<Item = &'b TimelineLineLayout>,
324) -> Option<(f64, f64, f64, f64)> {
325    let mut min_x = f64::INFINITY;
326    let mut min_y = f64::INFINITY;
327    let mut max_x = f64::NEG_INFINITY;
328    let mut max_y = f64::NEG_INFINITY;
329
330    let mut any = false;
331    for n in nodes {
332        any = true;
333        min_x = min_x.min(n.x);
334        min_y = min_y.min(n.y);
335        max_x = max_x.max(n.x + n.width);
336        max_y = max_y.max(n.y + n.height);
337    }
338    for l in lines {
339        any = true;
340        min_x = min_x.min(l.x1.min(l.x2));
341        min_y = min_y.min(l.y1.min(l.y2));
342        max_x = max_x.max(l.x1.max(l.x2));
343        max_y = max_y.max(l.y1.max(l.y2));
344    }
345
346    if any {
347        Some((min_x, min_y, max_x, max_y))
348    } else {
349        None
350    }
351}
352
353fn expand_bounds_for_node_text(
354    min_x: &mut f64,
355    _min_y: &mut f64,
356    max_x: &mut f64,
357    _max_y: &mut f64,
358    nodes: &[TimelineNodeLayout],
359    style: &TextStyle,
360    measurer: &dyn TextMeasurer,
361) {
362    for n in nodes {
363        if n.kind == "title-bounds" {
364            continue;
365        }
366
367        let anchor_x = n.x + n.width / 2.0;
368        for line in &n.label_lines {
369            if line.trim().is_empty() {
370                continue;
371            }
372            // Timeline node labels can overflow the node shape. Mermaid computes the final
373            // viewport from SVG `getBBox()`, which includes glyph overhang and can be asymmetric
374            // even for ASCII strings (observed in upstream fixtures).
375            let (left, right) = crate::generated::timeline_text_overrides_11_12_2::
376                lookup_timeline_svg_bbox_x_with_ascii_overhang_px(
377                    style.font_family.as_deref().unwrap_or_default(),
378                    style.font_size,
379                    line,
380                )
381                .unwrap_or_else(|| measurer.measure_svg_text_bbox_x_with_ascii_overhang(line, &style));
382            *min_x = (*min_x).min(anchor_x - left);
383            *max_x = (*max_x).max(anchor_x + right);
384        }
385    }
386}
387
388pub fn layout_timeline_diagram(
389    semantic: &serde_json::Value,
390    effective_config: &serde_json::Value,
391    measurer: &dyn TextMeasurer,
392) -> Result<TimelineDiagramLayout> {
393    let model: TimelineModel = crate::json::from_value_ref(semantic)?;
394    let _ = (
395        model.acc_title.as_deref(),
396        model.acc_descr.as_deref(),
397        model.diagram_type.as_str(),
398    );
399
400    let text_style = timeline_text_style(effective_config);
401    let render_font_size = text_style.font_size;
402    let layout_font_size = cfg_f64_css_px(effective_config, &["fontSize"])
403        .unwrap_or(render_font_size)
404        .max(1.0);
405
406    let left_margin = cfg_f64(effective_config, &["timeline", "leftMargin"])
407        .unwrap_or(150.0)
408        .max(0.0);
409    let disable_multicolor =
410        cfg_bool(effective_config, &["timeline", "disableMulticolor"]).unwrap_or(false);
411    // Mermaid's Timeline renderer hardcodes the text-wrap width to `150` (see upstream
412    // `drawTasks`/`drawEvents`: node objects use `width: 150` and `wrap(..., node.width)`),
413    // even though the config schema exposes a `timeline.width` field.
414    //
415    // For upstream parity, treat `timeline.width` as a no-op and keep the wrap width constant.
416    let task_content_width = TASK_CONTENT_WIDTH_DEFAULT;
417    let _ = cfg_f64(effective_config, &["timeline", "width"]);
418
419    let mut max_section_height: f64 = 0.0;
420    for section in &model.sections {
421        let (h, _lines) = virtual_node_height(
422            section,
423            task_content_width,
424            &text_style,
425            layout_font_size,
426            NODE_PADDING,
427            measurer,
428        );
429        max_section_height = max_section_height.max(h + 20.0);
430    }
431
432    let mut max_task_height: f64 = 0.0;
433    let mut max_event_line_length: f64 = 0.0;
434    for task in &model.tasks {
435        // Upstream Mermaid's Timeline renderer computes `maxTaskHeight` by passing the entire
436        // task object into `getVirtualNodeHeight(...)`, which stringifies to `"[object Object]"`.
437        // This inflates `maxTaskHeight` when all task labels are short; replicate for parity.
438        let virtual_task_label = "[object Object]";
439        let (h, _lines) = virtual_node_height(
440            virtual_task_label,
441            task_content_width,
442            &text_style,
443            layout_font_size,
444            NODE_PADDING,
445            measurer,
446        );
447        max_task_height = max_task_height.max(h + 20.0);
448
449        let mut task_event_len: f64 = 0.0;
450        for ev in &task.events {
451            let (eh, _lines) = virtual_node_height(
452                ev,
453                task_content_width,
454                &text_style,
455                layout_font_size,
456                NODE_PADDING,
457                measurer,
458            );
459            task_event_len += eh;
460        }
461        if !task.events.is_empty() {
462            task_event_len += (task.events.len().saturating_sub(1) as f64) * EVENT_GAP_Y;
463        }
464        max_event_line_length = max_event_line_length.max(task_event_len);
465    }
466
467    let base_x = BASE_MARGIN + left_margin;
468    let base_y = BASE_MARGIN;
469
470    let mut sections: Vec<TimelineSectionLayout> = Vec::new();
471    let mut orphan_tasks: Vec<TimelineTaskLayout> = Vec::new();
472
473    let mut all_nodes_pre_title: Vec<TimelineNodeLayout> = Vec::new();
474    let mut all_lines_pre_title: Vec<TimelineLineLayout> = Vec::new();
475
476    let has_sections = !model.sections.is_empty();
477
478    if has_sections {
479        let mut master_x = base_x;
480        let section_y = base_y;
481
482        for (section_number, section_label) in model.sections.iter().enumerate() {
483            let section_number = section_number as i64;
484            let tasks_for_section: Vec<&TimelineTaskModel> = model
485                .tasks
486                .iter()
487                .filter(|t| t.section == *section_label)
488                .collect();
489            let tasks_for_section_count = tasks_for_section.len().max(1);
490
491            let content_width = TASK_STEP_X * (tasks_for_section_count as f64) - 50.0;
492            let section_node = compute_node(
493                "section",
494                section_label,
495                section_number,
496                master_x,
497                section_y,
498                content_width,
499                max_section_height,
500                &text_style,
501                layout_font_size,
502                measurer,
503            );
504            all_nodes_pre_title.push(section_node.clone());
505
506            let mut tasks: Vec<TimelineTaskLayout> = Vec::new();
507            let mut task_x = master_x;
508            let task_y = section_y + max_section_height + 50.0;
509
510            for task in &tasks_for_section {
511                let full_section = section_number;
512                let task_node = compute_node(
513                    "task",
514                    &task.task,
515                    full_section,
516                    task_x,
517                    task_y,
518                    task_content_width,
519                    max_task_height,
520                    &text_style,
521                    layout_font_size,
522                    measurer,
523                );
524                all_nodes_pre_title.push(task_node.clone());
525
526                let connector = TimelineLineLayout {
527                    kind: "task-events".to_string(),
528                    x1: task_x + (task_node.width / 2.0),
529                    y1: task_y + max_task_height,
530                    x2: task_x + (task_node.width / 2.0),
531                    y2: task_y + max_task_height + 100.0 + max_event_line_length + 100.0,
532                };
533                all_lines_pre_title.push(connector.clone());
534
535                let mut events: Vec<TimelineNodeLayout> = Vec::new();
536                let mut event_y = task_y + EVENT_VERTICAL_OFFSET_FROM_TASK_Y;
537                for ev in &task.events {
538                    let event_node = compute_node(
539                        "event",
540                        ev,
541                        full_section,
542                        task_x,
543                        event_y,
544                        task_content_width,
545                        50.0,
546                        &text_style,
547                        layout_font_size,
548                        measurer,
549                    );
550                    event_y += event_node.height + EVENT_GAP_Y;
551                    all_nodes_pre_title.push(event_node.clone());
552                    events.push(event_node);
553                }
554
555                tasks.push(TimelineTaskLayout {
556                    node: task_node,
557                    connector,
558                    events,
559                });
560
561                task_x += TASK_STEP_X;
562            }
563
564            sections.push(TimelineSectionLayout {
565                node: section_node,
566                tasks,
567            });
568
569            master_x += TASK_STEP_X * (tasks_for_section_count as f64);
570        }
571    } else {
572        let mut master_x = base_x;
573        let master_y = base_y;
574        let mut section_color: i64 = 0;
575
576        for task in &model.tasks {
577            let task_node = compute_node(
578                "task",
579                &task.task,
580                section_color,
581                master_x,
582                master_y,
583                task_content_width,
584                max_task_height,
585                &text_style,
586                layout_font_size,
587                measurer,
588            );
589            all_nodes_pre_title.push(task_node.clone());
590
591            let connector = TimelineLineLayout {
592                kind: "task-events".to_string(),
593                x1: master_x + (task_node.width / 2.0),
594                y1: master_y + max_task_height,
595                x2: master_x + (task_node.width / 2.0),
596                y2: master_y + max_task_height + 100.0 + max_event_line_length + 100.0,
597            };
598            all_lines_pre_title.push(connector.clone());
599
600            let mut events: Vec<TimelineNodeLayout> = Vec::new();
601            let mut event_y = master_y + EVENT_VERTICAL_OFFSET_FROM_TASK_Y;
602            for ev in &task.events {
603                let event_node = compute_node(
604                    "event",
605                    ev,
606                    section_color,
607                    master_x,
608                    event_y,
609                    task_content_width,
610                    50.0,
611                    &text_style,
612                    layout_font_size,
613                    measurer,
614                );
615                event_y += event_node.height + EVENT_GAP_Y;
616                all_nodes_pre_title.push(event_node.clone());
617                events.push(event_node);
618            }
619
620            orphan_tasks.push(TimelineTaskLayout {
621                node: task_node,
622                connector,
623                events,
624            });
625
626            master_x += TASK_STEP_X;
627            if !disable_multicolor {
628                section_color += 1;
629            }
630        }
631    }
632
633    let (mut pre_min_x, mut pre_min_y, mut pre_max_x, mut pre_max_y) =
634        bounds_from_nodes_and_lines(&all_nodes_pre_title, &all_lines_pre_title)
635            .unwrap_or((0.0, 0.0, 100.0, 100.0));
636    expand_bounds_for_node_text(
637        &mut pre_min_x,
638        &mut pre_min_y,
639        &mut pre_max_x,
640        &mut pre_max_y,
641        &all_nodes_pre_title,
642        &text_style,
643        measurer,
644    );
645    let pre_title_box_width = (pre_max_x - pre_min_x).max(1.0);
646
647    let title = model
648        .title
649        .as_deref()
650        .map(|s| s.trim().to_string())
651        .filter(|s| !s.is_empty());
652    let title_x = pre_title_box_width / 2.0 - left_margin;
653
654    let depth_y = if has_sections {
655        max_section_height + max_task_height + 150.0
656    } else {
657        max_task_height + 100.0
658    };
659
660    let activity_line = TimelineLineLayout {
661        kind: "activity".to_string(),
662        x1: left_margin,
663        y1: depth_y,
664        x2: pre_title_box_width + 3.0 * left_margin,
665        y2: depth_y,
666    };
667
668    let mut all_nodes_full: Vec<TimelineNodeLayout> = all_nodes_pre_title.clone();
669    let mut all_lines_full: Vec<TimelineLineLayout> = all_lines_pre_title.clone();
670    all_lines_full.push(activity_line.clone());
671
672    if let Some(t) = title.as_deref() {
673        // Approximate the title bounds so the viewBox can include it (Mermaid uses a bold 4ex).
674        //
675        // Note: `ex` depends on the font x-height; for Mermaid's default theme at 16px, `4ex`
676        // resolves to ~31px in upstream fixtures.
677        let title_font_size = render_font_size * 1.9375;
678        let title_style = TextStyle {
679            font_family: text_style.font_family.clone(),
680            font_size: title_font_size,
681            font_weight: Some("bold".to_string()),
682        };
683        let metrics = measurer.measure(t, &title_style);
684        all_nodes_full.push(TimelineNodeLayout {
685            x: title_x,
686            y: TITLE_Y - title_style.font_size,
687            width: metrics.width.max(1.0),
688            height: title_style.font_size.max(1.0),
689            content_width: metrics.width.max(1.0),
690            padding: 0.0,
691            section_class: "section-root".to_string(),
692            label: t.to_string(),
693            label_lines: vec![t.to_string()],
694            kind: "title-bounds".to_string(),
695        });
696    }
697
698    let (mut full_min_x, mut full_min_y, mut full_max_x, mut full_max_y) =
699        bounds_from_nodes_and_lines(&all_nodes_full, &all_lines_full)
700            .unwrap_or((pre_min_x, pre_min_y, pre_max_x, pre_max_y));
701    expand_bounds_for_node_text(
702        &mut full_min_x,
703        &mut full_min_y,
704        &mut full_max_x,
705        &mut full_max_y,
706        &all_nodes_full,
707        &text_style,
708        measurer,
709    );
710
711    let viewbox_padding =
712        cfg_f64(effective_config, &["timeline", "padding"]).unwrap_or(DEFAULT_VIEWBOX_PADDING);
713    let vb_min_x = full_min_x - viewbox_padding;
714    let vb_min_y = full_min_y - viewbox_padding;
715    let vb_max_x = full_max_x + viewbox_padding;
716    let vb_max_y = full_max_y + viewbox_padding;
717
718    Ok(TimelineDiagramLayout {
719        bounds: Some(Bounds {
720            min_x: vb_min_x,
721            min_y: vb_min_y,
722            max_x: vb_max_x,
723            max_y: vb_max_y,
724        }),
725        left_margin,
726        base_x,
727        base_y,
728        pre_title_box_width,
729        sections,
730        orphan_tasks,
731        activity_line,
732        title,
733        title_x,
734        title_y: TITLE_Y,
735    })
736}