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 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 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 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 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 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}