Skip to main content

merman_render/
gitgraph.rs

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