Skip to main content

merman_render/
gitgraph.rs

1use crate::Result;
2use crate::config::{config_f64 as cfg_f64, json_f64_css_px};
3use crate::model::{
4    Bounds, GitGraphArrowLayout, GitGraphBranchLayout, GitGraphCommitLayout, GitGraphDiagramLayout,
5};
6use crate::text::{TextMeasurer, TextStyle};
7use merman_core::diagrams::git_graph::{
8    GitGraphCommitRenderModel as GitGraphCommit, GitGraphRenderModel,
9};
10use std::collections::HashMap;
11
12const LAYOUT_OFFSET: f64 = 10.0;
13const COMMIT_STEP: f64 = 40.0;
14const DEFAULT_POS: f64 = 30.0;
15const THEME_COLOR_LIMIT: usize = 8;
16
17const COMMIT_TYPE_MERGE: i64 = 3;
18
19fn cfg_bool(cfg: &serde_json::Value, path: &[&str]) -> Option<bool> {
20    let mut cur = cfg;
21    for k in path {
22        cur = cur.get(*k)?;
23    }
24    cur.as_bool()
25}
26
27fn cfg_string(cfg: &serde_json::Value, path: &[&str]) -> Option<String> {
28    let mut cur = cfg;
29    for k in path {
30        cur = cur.get(*k)?;
31    }
32    cur.as_str().map(|s| s.to_string())
33}
34
35fn cfg_font_size(cfg: &serde_json::Value) -> f64 {
36    cfg.get("themeVariables")
37        .and_then(|v| v.get("fontSize"))
38        .and_then(json_f64_css_px)
39        .unwrap_or(16.0)
40        .max(1.0)
41}
42
43#[derive(Debug, Clone, Copy)]
44struct CommitPosition {
45    x: f64,
46    y: f64,
47}
48
49fn find_closest_parent<'a>(
50    parents: &'a [String],
51    dir: &str,
52    commit_pos: &HashMap<&str, CommitPosition>,
53) -> Option<&'a str> {
54    let mut target: f64 = if dir == "BT" { f64::INFINITY } else { 0.0 };
55    let mut closest: Option<&str> = None;
56    for parent in parents {
57        let Some(pos) = commit_pos.get(parent.as_str()) else {
58            continue;
59        };
60        let parent_position = if dir == "TB" || dir == "BT" {
61            pos.y
62        } else {
63            pos.x
64        };
65        if dir == "BT" {
66            if parent_position <= target {
67                closest = Some(parent.as_str());
68                target = parent_position;
69            }
70        } else if parent_position >= target {
71            closest = Some(parent.as_str());
72            target = parent_position;
73        }
74    }
75    closest
76}
77
78fn commit_axis_start_pos(dir: &str) -> f64 {
79    if dir == "TB" || dir == "BT" {
80        DEFAULT_POS
81    } else {
82        0.0
83    }
84}
85
86fn branch_label_bbox_width_px(
87    direction: &str,
88    text: &str,
89    style: &TextStyle,
90    measurer: &dyn TextMeasurer,
91) -> f64 {
92    if direction == "TB" || direction == "BT" {
93        // Mermaid measures GitGraph branch labels with `drawText(name).getBBox()` before placing
94        // the background rect and, for vertical layouts, before advancing the next branch lane.
95        // Chromium's bbox for these unrotated labels behaves like the centered SVG bbox path with
96        // 1/64px ties-to-even quantization; including ASCII glyph overhang makes vertical roots
97        // systematically too wide.
98        let (left, right) = measurer.measure_svg_text_bbox_x(text, style);
99        crate::text::round_to_1_64_px_ties_to_even((left + right).max(0.0))
100    } else {
101        // Horizontal branch labels line up with the text advance rather than ASCII-overhang bbox
102        // width; upstream rects match `<text>.getComputedTextLength()`.
103        crate::text::round_to_1_64_px(
104            measurer
105                .measure_svg_text_computed_length_px(text, style)
106                .max(0.0),
107        )
108    }
109}
110
111fn should_reroute_arrow(
112    commit_a: &GitGraphCommit,
113    commit_b: &GitGraphCommit,
114    p1: CommitPosition,
115    p2: CommitPosition,
116    all_commits: &HashMap<&str, &GitGraphCommit>,
117    dir: &str,
118) -> bool {
119    let commit_b_is_furthest = if dir == "TB" || dir == "BT" {
120        p1.x < p2.x
121    } else {
122        p1.y < p2.y
123    };
124    let branch_to_get_curve = if commit_b_is_furthest {
125        commit_b.branch.as_str()
126    } else {
127        commit_a.branch.as_str()
128    };
129
130    all_commits.values().any(|commit_x| {
131        commit_x.branch == branch_to_get_curve
132            && commit_x.seq > commit_a.seq
133            && commit_x.seq < commit_b.seq
134    })
135}
136
137fn find_lane(y1: f64, y2: f64, lanes: &mut Vec<f64>, depth: usize) -> f64 {
138    let candidate = y1 + (y1 - y2).abs() / 2.0;
139    if depth > 5 {
140        return candidate;
141    }
142
143    let ok = lanes.iter().all(|lane| (lane - candidate).abs() >= 10.0);
144    if ok {
145        lanes.push(candidate);
146        return candidate;
147    }
148
149    let diff = (y1 - y2).abs();
150    find_lane(y1, y2 - diff / 5.0, lanes, depth + 1)
151}
152
153fn draw_arrow(
154    commit_a: &GitGraphCommit,
155    commit_b: &GitGraphCommit,
156    all_commits: &HashMap<&str, &GitGraphCommit>,
157    commit_pos: &HashMap<&str, CommitPosition>,
158    branch_index: &HashMap<&str, usize>,
159    lanes: &mut Vec<f64>,
160    dir: &str,
161) -> Option<GitGraphArrowLayout> {
162    let p1 = *commit_pos.get(commit_a.id.as_str())?;
163    let p2 = *commit_pos.get(commit_b.id.as_str())?;
164    let arrow_needs_rerouting = should_reroute_arrow(commit_a, commit_b, p1, p2, all_commits, dir);
165
166    let mut color_class_num = branch_index
167        .get(commit_b.branch.as_str())
168        .copied()
169        .unwrap_or(0);
170    if commit_b.commit_type == COMMIT_TYPE_MERGE
171        && commit_a
172            .id
173            .as_str()
174            .ne(commit_b.parents.first().map(|s| s.as_str()).unwrap_or(""))
175    {
176        color_class_num = branch_index
177            .get(commit_a.branch.as_str())
178            .copied()
179            .unwrap_or(color_class_num);
180    }
181
182    let mut line_def: Option<String> = None;
183    if arrow_needs_rerouting {
184        let arc = "A 10 10, 0, 0, 0,";
185        let arc2 = "A 10 10, 0, 0, 1,";
186        let radius = 10.0;
187        let offset = 10.0;
188
189        let line_y = if p1.y < p2.y {
190            find_lane(p1.y, p2.y, lanes, 0)
191        } else {
192            find_lane(p2.y, p1.y, lanes, 0)
193        };
194        let line_x = if p1.x < p2.x {
195            find_lane(p1.x, p2.x, lanes, 0)
196        } else {
197            find_lane(p2.x, p1.x, lanes, 0)
198        };
199
200        if dir == "TB" {
201            if p1.x < p2.x {
202                line_def = Some(format!(
203                    "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
204                    p1.x,
205                    p1.y,
206                    line_x - radius,
207                    p1.y,
208                    arc2,
209                    line_x,
210                    p1.y + offset,
211                    line_x,
212                    p2.y - radius,
213                    arc,
214                    line_x + offset,
215                    p2.y,
216                    p2.x,
217                    p2.y
218                ));
219            } else {
220                color_class_num = branch_index
221                    .get(commit_a.branch.as_str())
222                    .copied()
223                    .unwrap_or(0);
224                line_def = Some(format!(
225                    "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
226                    p1.x,
227                    p1.y,
228                    line_x + radius,
229                    p1.y,
230                    arc,
231                    line_x,
232                    p1.y + offset,
233                    line_x,
234                    p2.y - radius,
235                    arc2,
236                    line_x - offset,
237                    p2.y,
238                    p2.x,
239                    p2.y
240                ));
241            }
242        } else if dir == "BT" {
243            if p1.x < p2.x {
244                line_def = Some(format!(
245                    "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
246                    p1.x,
247                    p1.y,
248                    line_x - radius,
249                    p1.y,
250                    arc,
251                    line_x,
252                    p1.y - offset,
253                    line_x,
254                    p2.y + radius,
255                    arc2,
256                    line_x + offset,
257                    p2.y,
258                    p2.x,
259                    p2.y
260                ));
261            } else {
262                color_class_num = branch_index
263                    .get(commit_a.branch.as_str())
264                    .copied()
265                    .unwrap_or(0);
266                line_def = Some(format!(
267                    "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
268                    p1.x,
269                    p1.y,
270                    line_x + radius,
271                    p1.y,
272                    arc2,
273                    line_x,
274                    p1.y - offset,
275                    line_x,
276                    p2.y + radius,
277                    arc,
278                    line_x - offset,
279                    p2.y,
280                    p2.x,
281                    p2.y
282                ));
283            }
284        } else if p1.y < p2.y {
285            line_def = Some(format!(
286                "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
287                p1.x,
288                p1.y,
289                p1.x,
290                line_y - radius,
291                arc,
292                p1.x + offset,
293                line_y,
294                p2.x - radius,
295                line_y,
296                arc2,
297                p2.x,
298                line_y + offset,
299                p2.x,
300                p2.y
301            ));
302        } else {
303            color_class_num = branch_index
304                .get(commit_a.branch.as_str())
305                .copied()
306                .unwrap_or(0);
307            line_def = Some(format!(
308                "M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
309                p1.x,
310                p1.y,
311                p1.x,
312                line_y + radius,
313                arc2,
314                p1.x + offset,
315                line_y,
316                p2.x - radius,
317                line_y,
318                arc,
319                p2.x,
320                line_y - offset,
321                p2.x,
322                p2.y
323            ));
324        }
325    } else {
326        let arc = "A 20 20, 0, 0, 0,";
327        let arc2 = "A 20 20, 0, 0, 1,";
328        let radius = 20.0;
329        let offset = 20.0;
330
331        if dir == "TB" {
332            if p1.x < p2.x {
333                if commit_b.commit_type == COMMIT_TYPE_MERGE
334                    && commit_a.id.as_str().ne(commit_b
335                        .parents
336                        .first()
337                        .map(|s| s.as_str())
338                        .unwrap_or(""))
339                {
340                    line_def = Some(format!(
341                        "M {} {} L {} {} {} {} {} L {} {}",
342                        p1.x,
343                        p1.y,
344                        p1.x,
345                        p2.y - radius,
346                        arc,
347                        p1.x + offset,
348                        p2.y,
349                        p2.x,
350                        p2.y
351                    ));
352                } else {
353                    line_def = Some(format!(
354                        "M {} {} L {} {} {} {} {} L {} {}",
355                        p1.x,
356                        p1.y,
357                        p2.x - radius,
358                        p1.y,
359                        arc2,
360                        p2.x,
361                        p1.y + offset,
362                        p2.x,
363                        p2.y
364                    ));
365                }
366            }
367
368            if p1.x > p2.x {
369                if commit_b.commit_type == COMMIT_TYPE_MERGE
370                    && commit_a.id.as_str().ne(commit_b
371                        .parents
372                        .first()
373                        .map(|s| s.as_str())
374                        .unwrap_or(""))
375                {
376                    line_def = Some(format!(
377                        "M {} {} L {} {} {} {} {} L {} {}",
378                        p1.x,
379                        p1.y,
380                        p1.x,
381                        p2.y - radius,
382                        arc2,
383                        p1.x - offset,
384                        p2.y,
385                        p2.x,
386                        p2.y
387                    ));
388                } else {
389                    line_def = Some(format!(
390                        "M {} {} L {} {} {} {} {} L {} {}",
391                        p1.x,
392                        p1.y,
393                        p2.x + radius,
394                        p1.y,
395                        arc,
396                        p2.x,
397                        p1.y + offset,
398                        p2.x,
399                        p2.y
400                    ));
401                }
402            }
403
404            if p1.x == p2.x {
405                line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
406            }
407        } else if dir == "BT" {
408            if p1.x < p2.x {
409                if commit_b.commit_type == COMMIT_TYPE_MERGE
410                    && commit_a.id.as_str().ne(commit_b
411                        .parents
412                        .first()
413                        .map(|s| s.as_str())
414                        .unwrap_or(""))
415                {
416                    line_def = Some(format!(
417                        "M {} {} L {} {} {} {} {} L {} {}",
418                        p1.x,
419                        p1.y,
420                        p1.x,
421                        p2.y + radius,
422                        arc2,
423                        p1.x + offset,
424                        p2.y,
425                        p2.x,
426                        p2.y
427                    ));
428                } else {
429                    line_def = Some(format!(
430                        "M {} {} L {} {} {} {} {} L {} {}",
431                        p1.x,
432                        p1.y,
433                        p2.x - radius,
434                        p1.y,
435                        arc,
436                        p2.x,
437                        p1.y - offset,
438                        p2.x,
439                        p2.y
440                    ));
441                }
442            }
443
444            if p1.x > p2.x {
445                if commit_b.commit_type == COMMIT_TYPE_MERGE
446                    && commit_a.id.as_str().ne(commit_b
447                        .parents
448                        .first()
449                        .map(|s| s.as_str())
450                        .unwrap_or(""))
451                {
452                    line_def = Some(format!(
453                        "M {} {} L {} {} {} {} {} L {} {}",
454                        p1.x,
455                        p1.y,
456                        p1.x,
457                        p2.y + radius,
458                        arc,
459                        p1.x - offset,
460                        p2.y,
461                        p2.x,
462                        p2.y
463                    ));
464                } else {
465                    line_def = Some(format!(
466                        "M {} {} L {} {} {} {} {} L {} {}",
467                        p1.x,
468                        p1.y,
469                        p2.x - radius,
470                        p1.y,
471                        arc,
472                        p2.x,
473                        p1.y - offset,
474                        p2.x,
475                        p2.y
476                    ));
477                }
478            }
479
480            if p1.x == p2.x {
481                line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
482            }
483        } else {
484            if p1.y < p2.y {
485                if commit_b.commit_type == COMMIT_TYPE_MERGE
486                    && commit_a.id.as_str().ne(commit_b
487                        .parents
488                        .first()
489                        .map(|s| s.as_str())
490                        .unwrap_or(""))
491                {
492                    line_def = Some(format!(
493                        "M {} {} L {} {} {} {} {} L {} {}",
494                        p1.x,
495                        p1.y,
496                        p2.x - radius,
497                        p1.y,
498                        arc2,
499                        p2.x,
500                        p1.y + offset,
501                        p2.x,
502                        p2.y
503                    ));
504                } else {
505                    line_def = Some(format!(
506                        "M {} {} L {} {} {} {} {} L {} {}",
507                        p1.x,
508                        p1.y,
509                        p1.x,
510                        p2.y - radius,
511                        arc,
512                        p1.x + offset,
513                        p2.y,
514                        p2.x,
515                        p2.y
516                    ));
517                }
518            }
519
520            if p1.y > p2.y {
521                if commit_b.commit_type == COMMIT_TYPE_MERGE
522                    && commit_a.id.as_str().ne(commit_b
523                        .parents
524                        .first()
525                        .map(|s| s.as_str())
526                        .unwrap_or(""))
527                {
528                    line_def = Some(format!(
529                        "M {} {} L {} {} {} {} {} L {} {}",
530                        p1.x,
531                        p1.y,
532                        p2.x - radius,
533                        p1.y,
534                        arc,
535                        p2.x,
536                        p1.y - offset,
537                        p2.x,
538                        p2.y
539                    ));
540                } else {
541                    line_def = Some(format!(
542                        "M {} {} L {} {} {} {} {} L {} {}",
543                        p1.x,
544                        p1.y,
545                        p1.x,
546                        p2.y + radius,
547                        arc2,
548                        p1.x + offset,
549                        p2.y,
550                        p2.x,
551                        p2.y
552                    ));
553                }
554            }
555
556            if p1.y == p2.y {
557                line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
558            }
559        }
560    }
561
562    let d = line_def?;
563    Some(GitGraphArrowLayout {
564        from: commit_a.id.clone(),
565        to: commit_b.id.clone(),
566        class_index: (color_class_num % THEME_COLOR_LIMIT) as i64,
567        d,
568    })
569}
570
571pub fn layout_gitgraph_diagram(
572    semantic: &serde_json::Value,
573    effective_config: &serde_json::Value,
574    measurer: &dyn TextMeasurer,
575) -> Result<GitGraphDiagramLayout> {
576    let model: GitGraphRenderModel = crate::json::from_value_ref(semantic)?;
577    layout_gitgraph_diagram_typed(&model, effective_config, measurer)
578}
579
580pub fn layout_gitgraph_diagram_typed(
581    model: &GitGraphRenderModel,
582    effective_config: &serde_json::Value,
583    measurer: &dyn TextMeasurer,
584) -> Result<GitGraphDiagramLayout> {
585    let _ = model.diagram_type.as_str();
586
587    let direction = if model.direction.trim().is_empty() {
588        "LR".to_string()
589    } else {
590        model.direction.trim().to_string()
591    };
592
593    let rotate_commit_label =
594        cfg_bool(effective_config, &["gitGraph", "rotateCommitLabel"]).unwrap_or(true);
595    let show_commit_label =
596        cfg_bool(effective_config, &["gitGraph", "showCommitLabel"]).unwrap_or(true);
597    let show_branches = cfg_bool(effective_config, &["gitGraph", "showBranches"]).unwrap_or(true);
598    let diagram_padding = cfg_f64(effective_config, &["gitGraph", "diagramPadding"])
599        .unwrap_or(8.0)
600        .max(0.0);
601    let parallel_commits =
602        cfg_bool(effective_config, &["gitGraph", "parallelCommits"]).unwrap_or(false);
603
604    // Upstream gitGraph uses SVG `getBBox()` probes for branch label widths while the
605    // `drawText(...)` nodes inherit Mermaid's global font config.
606    let font_family = cfg_string(effective_config, &["fontFamily"])
607        .or_else(|| cfg_string(effective_config, &["themeVariables", "fontFamily"]))
608        .map(|s| s.trim().trim_end_matches(';').trim().to_string())
609        .filter(|s| !s.is_empty())
610        .unwrap_or_else(|| "\"trebuchet ms\", verdana, arial, sans-serif".to_string());
611    let font_size = cfg_font_size(effective_config);
612
613    let label_style = TextStyle {
614        font_family: Some(font_family),
615        font_size,
616        font_weight: None,
617    };
618
619    let mut branches: Vec<GitGraphBranchLayout> = Vec::new();
620    let mut branch_pos: HashMap<&str, f64> = HashMap::new();
621    let mut branch_index: HashMap<&str, usize> = HashMap::new();
622    let mut pos = 0.0;
623    for (i, b) in model.branches.iter().enumerate() {
624        let metrics = measurer.measure(&b.name, &label_style);
625        let bbox_w = branch_label_bbox_width_px(&direction, &b.name, &label_style, measurer);
626        branch_pos.insert(b.name.as_str(), pos);
627        branch_index.insert(b.name.as_str(), i);
628
629        branches.push(GitGraphBranchLayout {
630            name: b.name.clone(),
631            index: i as i64,
632            pos,
633            bbox_width: bbox_w.max(0.0),
634            bbox_height: metrics.height.max(0.0),
635        });
636
637        pos += 50.0
638            + if rotate_commit_label { 40.0 } else { 0.0 }
639            + if direction == "TB" || direction == "BT" {
640                bbox_w.max(0.0) / 2.0
641            } else {
642                0.0
643            };
644    }
645
646    let commits_by_id: HashMap<&str, &GitGraphCommit> =
647        model.commits.iter().map(|c| (c.id.as_str(), c)).collect();
648
649    let mut commit_order: Vec<&GitGraphCommit> = model.commits.iter().collect();
650    commit_order.sort_by_key(|c| c.seq);
651
652    let mut sorted_keys: Vec<&str> = commit_order.iter().map(|c| c.id.as_str()).collect();
653    let mirror_parallel_bt_axis = direction == "BT" && parallel_commits;
654    if direction == "BT" && !mirror_parallel_bt_axis {
655        sorted_keys.reverse();
656    }
657
658    let mut commit_pos: HashMap<&str, CommitPosition> = HashMap::new();
659    let mut commits: Vec<GitGraphCommitLayout> = Vec::new();
660    let mut max_pos: f64 = 0.0;
661    let mut cur_pos = commit_axis_start_pos(&direction);
662
663    for &id in &sorted_keys {
664        let Some(commit) = commits_by_id.get(id).copied() else {
665            continue;
666        };
667
668        if parallel_commits {
669            if !commit.parents.is_empty() {
670                if let Some(closest_parent) =
671                    find_closest_parent(&commit.parents, &direction, &commit_pos)
672                {
673                    if let Some(parent_position) = commit_pos.get(closest_parent) {
674                        if mirror_parallel_bt_axis {
675                            cur_pos = parent_position.y + COMMIT_STEP + LAYOUT_OFFSET;
676                        } else if direction == "TB" {
677                            cur_pos = parent_position.y + COMMIT_STEP;
678                        } else if direction == "BT" {
679                            let current_position = commit_pos
680                                .get(commit.id.as_str())
681                                .copied()
682                                .unwrap_or(CommitPosition { x: 0.0, y: 0.0 });
683                            cur_pos = current_position.y - COMMIT_STEP;
684                        } else {
685                            cur_pos = parent_position.x + COMMIT_STEP;
686                        }
687                    }
688                }
689            } else {
690                cur_pos = commit_axis_start_pos(&direction);
691            }
692        }
693
694        let pos_with_offset = if direction == "BT" && parallel_commits {
695            cur_pos
696        } else {
697            cur_pos + LAYOUT_OFFSET
698        };
699        let Some(branch_lane) = branch_pos.get(commit.branch.as_str()).copied() else {
700            return Err(crate::Error::InvalidModel {
701                message: format!("unknown branch for commit {}: {}", commit.id, commit.branch),
702            });
703        };
704
705        let (x, y) = if direction == "TB" || direction == "BT" {
706            (branch_lane, pos_with_offset)
707        } else {
708            (pos_with_offset, branch_lane)
709        };
710        commit_pos.insert(commit.id.as_str(), CommitPosition { x, y });
711
712        commits.push(GitGraphCommitLayout {
713            id: commit.id.clone(),
714            message: commit.message.clone(),
715            seq: commit.seq,
716            commit_type: commit.commit_type,
717            custom_type: commit.custom_type,
718            custom_id: commit.custom_id,
719            tags: commit.tags.clone(),
720            parents: commit.parents.clone(),
721            branch: commit.branch.clone(),
722            pos: cur_pos,
723            pos_with_offset,
724            x,
725            y,
726        });
727
728        cur_pos += COMMIT_STEP + LAYOUT_OFFSET;
729        max_pos = max_pos.max(cur_pos);
730    }
731
732    if mirror_parallel_bt_axis && !commits.is_empty() {
733        // Mermaid lays out `parallelCommits` in sequence order, then mirrors the commit axis for
734        // bottom-to-top rendering. Doing the mirror after parent placement keeps branch timelines
735        // compact instead of treating the reversed parse order as a new linear timeline.
736        let mirror_axis = max_pos - DEFAULT_POS;
737        max_pos -= 2.0 * LAYOUT_OFFSET;
738
739        for commit in &mut commits {
740            let y = mirror_axis - commit.y;
741            commit.pos = y;
742            commit.pos_with_offset = y;
743            commit.y = y;
744        }
745
746        for position in commit_pos.values_mut() {
747            position.y = mirror_axis - position.y;
748        }
749    }
750
751    let mut lanes: Vec<f64> = if show_branches {
752        branches.iter().map(|b| b.pos).collect()
753    } else {
754        Vec::new()
755    };
756
757    let mut arrows: Vec<GitGraphArrowLayout> = Vec::new();
758    // Mermaid draws arrows by iterating insertion order of the commits map. The DB inserts commits
759    // in sequence order, so iterate by `seq` regardless of direction.
760    for commit_b in commit_order {
761        for parent in &commit_b.parents {
762            let Some(commit_a) = commits_by_id.get(parent.as_str()).copied() else {
763                continue;
764            };
765            if let Some(a) = draw_arrow(
766                commit_a,
767                commit_b,
768                &commits_by_id,
769                &commit_pos,
770                &branch_index,
771                &mut lanes,
772                &direction,
773            ) {
774                arrows.push(a);
775            }
776        }
777    }
778
779    let mut min_x = f64::INFINITY;
780    let mut min_y = f64::INFINITY;
781    let mut max_x = f64::NEG_INFINITY;
782    let mut max_y = f64::NEG_INFINITY;
783
784    for b in &branches {
785        if direction == "TB" || direction == "BT" {
786            min_x = min_x.min(b.pos);
787            max_x = max_x.max(b.pos);
788            min_y = min_y.min(DEFAULT_POS.min(max_pos));
789            max_y = max_y.max(DEFAULT_POS.max(max_pos));
790        } else {
791            min_y = min_y.min(b.pos);
792            max_y = max_y.max(b.pos);
793            min_x = min_x.min(0.0);
794            max_x = max_x.max(max_pos);
795            let label_left =
796                -b.bbox_width - 4.0 - if rotate_commit_label { 30.0 } else { 0.0 } - 19.0;
797            min_x = min_x.min(label_left);
798        }
799    }
800
801    for c in &commits {
802        let r = if c.custom_type.unwrap_or(c.commit_type) == COMMIT_TYPE_MERGE {
803            9.0
804        } else {
805            10.0
806        };
807        min_x = min_x.min(c.x - r);
808        min_y = min_y.min(c.y - r);
809        max_x = max_x.max(c.x + r);
810        max_y = max_y.max(c.y + r);
811    }
812
813    let bounds = if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()
814    {
815        Some(Bounds {
816            min_x: min_x - diagram_padding,
817            min_y: min_y - diagram_padding,
818            max_x: max_x + diagram_padding,
819            max_y: max_y + diagram_padding,
820        })
821    } else {
822        None
823    };
824
825    Ok(GitGraphDiagramLayout {
826        bounds,
827        direction,
828        rotate_commit_label,
829        show_branches,
830        show_commit_label,
831        parallel_commits,
832        diagram_padding,
833        max_pos,
834        branches,
835        commits,
836        arrows,
837    })
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843    use crate::text::VendoredFontMetricsTextMeasurer;
844    use merman_core::diagrams::git_graph::{
845        GitGraphBranchRenderModel, GitGraphCommitRenderModel, GitGraphRenderModel,
846    };
847    use serde_json::json;
848
849    fn commit(id: &str, seq: i64, parents: &[&str], branch: &str) -> GitGraphCommitRenderModel {
850        GitGraphCommitRenderModel {
851            id: id.to_string(),
852            message: id.to_string(),
853            seq,
854            commit_type: 0,
855            tags: Vec::new(),
856            parents: parents.iter().map(|p| (*p).to_string()).collect(),
857            branch: branch.to_string(),
858            custom_type: None,
859            custom_id: Some(true),
860        }
861    }
862
863    #[test]
864    fn font_size_ignores_top_level_font_size() {
865        let cfg = json!({
866            "fontSize": 22,
867            "themeVariables": {
868                "fontFamily": "\"courier new\", courier, monospace;",
869            },
870        });
871
872        assert_eq!(cfg_font_size(&cfg), 16.0);
873    }
874
875    #[test]
876    fn font_size_honors_theme_variable_font_size() {
877        let cfg = json!({
878            "fontSize": 10,
879            "themeVariables": {
880                "fontSize": "24px",
881            },
882        });
883
884        assert_eq!(cfg_font_size(&cfg), 24.0);
885    }
886
887    #[test]
888    fn vertical_branch_label_widths_use_centered_bbox_ties_to_even() {
889        let measurer = VendoredFontMetricsTextMeasurer::default();
890        let style = TextStyle {
891            font_family: Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()),
892            font_size: 16.0,
893            font_weight: None,
894        };
895
896        assert_eq!(
897            branch_label_bbox_width_px("TB", "main", &style, &measurer),
898            35.0
899        );
900        assert_eq!(
901            branch_label_bbox_width_px("TB", "branch1", &style, &measurer),
902            57.34375
903        );
904        assert_eq!(
905            branch_label_bbox_width_px("TB", "branch4", &style, &measurer),
906            57.34375
907        );
908        assert_eq!(
909            branch_label_bbox_width_px("LR", "branch4", &style, &measurer),
910            57.359375
911        );
912    }
913
914    #[test]
915    fn parallel_lr_unconnected_branches_restart_commit_axis() {
916        let model = GitGraphRenderModel {
917            diagram_type: "gitGraph".to_string(),
918            branches: ["main", "dev", "v2", "feat"]
919                .into_iter()
920                .map(|name| GitGraphBranchRenderModel {
921                    name: name.to_string(),
922                })
923                .collect(),
924            commits: vec![
925                commit("1-abcdefg", 0, &[], "feat"),
926                commit("2-abcdefg", 1, &["1-abcdefg"], "feat"),
927                commit("3-abcdefg", 2, &[], "main"),
928                commit("4-abcdefg", 3, &[], "dev"),
929                commit("5-abcdefg", 4, &[], "v2"),
930                commit("6-abcdefg", 5, &["3-abcdefg"], "main"),
931            ],
932            current_branch: "main".to_string(),
933            direction: "LR".to_string(),
934            acc_title: None,
935            acc_descr: None,
936            warnings: Vec::new(),
937        };
938        let cfg = json!({ "gitGraph": { "parallelCommits": true } });
939        let measurer = VendoredFontMetricsTextMeasurer::default();
940        let layout = layout_gitgraph_diagram_typed(&model, &cfg, &measurer).unwrap();
941
942        let x_by_id = layout
943            .commits
944            .iter()
945            .map(|c| (c.id.as_str(), c.x))
946            .collect::<HashMap<_, _>>();
947
948        assert_eq!(x_by_id["1-abcdefg"], 10.0);
949        assert_eq!(x_by_id["2-abcdefg"], 60.0);
950        assert_eq!(x_by_id["3-abcdefg"], 10.0);
951        assert_eq!(x_by_id["4-abcdefg"], 10.0);
952        assert_eq!(x_by_id["5-abcdefg"], 10.0);
953        assert_eq!(x_by_id["6-abcdefg"], 60.0);
954        assert_eq!(layout.max_pos, 100.0);
955    }
956
957    #[test]
958    fn parallel_bt_commits_use_mirrored_compact_axis() {
959        let model = GitGraphRenderModel {
960            diagram_type: "gitGraph".to_string(),
961            branches: ["main", "develop", "feature"]
962                .into_iter()
963                .map(|name| GitGraphBranchRenderModel {
964                    name: name.to_string(),
965                })
966                .collect(),
967            commits: vec![
968                commit("1-abcdefg", 0, &[], "main"),
969                commit("2-abcdefg", 1, &["1-abcdefg"], "main"),
970                commit("3-abcdefg", 2, &["2-abcdefg"], "develop"),
971                commit("4-abcdefg", 3, &["3-abcdefg"], "develop"),
972                commit("5-abcdefg", 4, &["2-abcdefg"], "feature"),
973                commit("6-abcdefg", 5, &["5-abcdefg"], "feature"),
974                commit("7-abcdefg", 6, &["2-abcdefg"], "main"),
975                commit("8-abcdefg", 7, &["7-abcdefg"], "main"),
976            ],
977            current_branch: "main".to_string(),
978            direction: "BT".to_string(),
979            acc_title: None,
980            acc_descr: None,
981            warnings: Vec::new(),
982        };
983        let cfg = json!({ "gitGraph": { "parallelCommits": true } });
984        let measurer = VendoredFontMetricsTextMeasurer::default();
985        let layout = layout_gitgraph_diagram_typed(&model, &cfg, &measurer).unwrap();
986
987        let y_by_id = layout
988            .commits
989            .iter()
990            .map(|c| (c.id.as_str(), c.y))
991            .collect::<HashMap<_, _>>();
992
993        assert_eq!(y_by_id["1-abcdefg"], 170.0);
994        assert_eq!(y_by_id["2-abcdefg"], 120.0);
995        assert_eq!(y_by_id["3-abcdefg"], 70.0);
996        assert_eq!(y_by_id["4-abcdefg"], 20.0);
997        assert_eq!(y_by_id["5-abcdefg"], 70.0);
998        assert_eq!(y_by_id["6-abcdefg"], 20.0);
999        assert_eq!(y_by_id["7-abcdefg"], 70.0);
1000        assert_eq!(y_by_id["8-abcdefg"], 20.0);
1001        assert_eq!(layout.max_pos, 210.0);
1002    }
1003}