use crate::Result;
use crate::model::{
Bounds, GitGraphArrowLayout, GitGraphBranchLayout, GitGraphCommitLayout, GitGraphDiagramLayout,
};
use crate::text::{TextMeasurer, TextStyle};
use serde::Deserialize;
use std::collections::HashMap;
const LAYOUT_OFFSET: f64 = 10.0;
const COMMIT_STEP: f64 = 40.0;
const DEFAULT_POS: f64 = 30.0;
const THEME_COLOR_LIMIT: usize = 8;
const COMMIT_TYPE_MERGE: i64 = 3;
#[derive(Debug, Clone, Deserialize)]
struct GitGraphBranch {
name: String,
}
#[derive(Debug, Clone, Deserialize)]
struct GitGraphCommit {
id: String,
#[serde(default)]
message: String,
#[serde(default)]
parents: Vec<String>,
seq: i64,
#[serde(default)]
tags: Vec<String>,
#[serde(rename = "type")]
commit_type: i64,
branch: String,
#[serde(default, rename = "customType")]
custom_type: Option<i64>,
#[serde(default, rename = "customId")]
custom_id: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
struct GitGraphModel {
#[serde(default)]
branches: Vec<GitGraphBranch>,
#[serde(default)]
commits: Vec<GitGraphCommit>,
#[serde(default)]
direction: String,
#[serde(rename = "type")]
diagram_type: String,
}
fn cfg_f64(cfg: &serde_json::Value, path: &[&str]) -> Option<f64> {
let mut cur = cfg;
for k in path {
cur = cur.get(*k)?;
}
cur.as_f64()
}
fn cfg_bool(cfg: &serde_json::Value, path: &[&str]) -> Option<bool> {
let mut cur = cfg;
for k in path {
cur = cur.get(*k)?;
}
cur.as_bool()
}
fn cfg_string(cfg: &serde_json::Value, path: &[&str]) -> Option<String> {
let mut cur = cfg;
for k in path {
cur = cur.get(*k)?;
}
cur.as_str().map(|s| s.to_string())
}
fn parse_css_px_to_f64(s: &str) -> Option<f64> {
let s = s.trim();
let raw = s.strip_suffix("px").unwrap_or(s).trim();
raw.parse::<f64>().ok().filter(|value| value.is_finite())
}
fn json_f64_css_px(v: &serde_json::Value) -> Option<f64> {
v.as_f64()
.or_else(|| v.as_i64().map(|n| n as f64))
.or_else(|| v.as_u64().map(|n| n as f64))
.or_else(|| v.as_str().and_then(parse_css_px_to_f64))
}
fn cfg_font_size(cfg: &serde_json::Value) -> f64 {
cfg.get("themeVariables")
.and_then(|v| v.get("fontSize"))
.and_then(json_f64_css_px)
.or_else(|| cfg.get("fontSize").and_then(json_f64_css_px))
.unwrap_or(16.0)
.max(1.0)
}
fn commit_symbol_type(commit: &GitGraphCommit) -> i64 {
commit.custom_type.unwrap_or(commit.commit_type)
}
#[derive(Debug, Clone, Copy)]
struct CommitPosition {
x: f64,
y: f64,
}
fn find_closest_parent(
parents: &[String],
dir: &str,
commit_pos: &HashMap<String, CommitPosition>,
) -> Option<String> {
let mut target: f64 = if dir == "BT" { f64::INFINITY } else { 0.0 };
let mut closest: Option<String> = None;
for parent in parents {
let Some(pos) = commit_pos.get(parent) else {
continue;
};
let parent_position = if dir == "TB" || dir == "BT" {
pos.y
} else {
pos.x
};
if dir == "BT" {
if parent_position <= target {
closest = Some(parent.clone());
target = parent_position;
}
} else if parent_position >= target {
closest = Some(parent.clone());
target = parent_position;
}
}
closest
}
fn should_reroute_arrow(
commit_a: &GitGraphCommit,
commit_b: &GitGraphCommit,
p1: CommitPosition,
p2: CommitPosition,
all_commits: &HashMap<String, GitGraphCommit>,
dir: &str,
) -> bool {
let commit_b_is_furthest = if dir == "TB" || dir == "BT" {
p1.x < p2.x
} else {
p1.y < p2.y
};
let branch_to_get_curve = if commit_b_is_furthest {
commit_b.branch.as_str()
} else {
commit_a.branch.as_str()
};
all_commits.values().any(|commit_x| {
commit_x.branch == branch_to_get_curve
&& commit_x.seq > commit_a.seq
&& commit_x.seq < commit_b.seq
})
}
fn find_lane(y1: f64, y2: f64, lanes: &mut Vec<f64>, depth: usize) -> f64 {
let candidate = y1 + (y1 - y2).abs() / 2.0;
if depth > 5 {
return candidate;
}
let ok = lanes.iter().all(|lane| (lane - candidate).abs() >= 10.0);
if ok {
lanes.push(candidate);
return candidate;
}
let diff = (y1 - y2).abs();
find_lane(y1, y2 - diff / 5.0, lanes, depth + 1)
}
fn draw_arrow(
commit_a: &GitGraphCommit,
commit_b: &GitGraphCommit,
all_commits: &HashMap<String, GitGraphCommit>,
commit_pos: &HashMap<String, CommitPosition>,
branch_index: &HashMap<String, usize>,
lanes: &mut Vec<f64>,
dir: &str,
) -> Option<GitGraphArrowLayout> {
let p1 = *commit_pos.get(&commit_a.id)?;
let p2 = *commit_pos.get(&commit_b.id)?;
let arrow_needs_rerouting = should_reroute_arrow(commit_a, commit_b, p1, p2, all_commits, dir);
let mut color_class_num = branch_index.get(&commit_b.branch).copied().unwrap_or(0);
if commit_b.commit_type == COMMIT_TYPE_MERGE
&& commit_a
.id
.as_str()
.ne(commit_b.parents.first().map(|s| s.as_str()).unwrap_or(""))
{
color_class_num = branch_index
.get(&commit_a.branch)
.copied()
.unwrap_or(color_class_num);
}
let mut line_def: Option<String> = None;
if arrow_needs_rerouting {
let arc = "A 10 10, 0, 0, 0,";
let arc2 = "A 10 10, 0, 0, 1,";
let radius = 10.0;
let offset = 10.0;
let line_y = if p1.y < p2.y {
find_lane(p1.y, p2.y, lanes, 0)
} else {
find_lane(p2.y, p1.y, lanes, 0)
};
let line_x = if p1.x < p2.x {
find_lane(p1.x, p2.x, lanes, 0)
} else {
find_lane(p2.x, p1.x, lanes, 0)
};
if dir == "TB" {
if p1.x < p2.x {
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
line_x - radius,
p1.y,
arc2,
line_x,
p1.y + offset,
line_x,
p2.y - radius,
arc,
line_x + offset,
p2.y,
p2.x,
p2.y
));
} else {
color_class_num = branch_index.get(&commit_a.branch).copied().unwrap_or(0);
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
line_x + radius,
p1.y,
arc,
line_x,
p1.y + offset,
line_x,
p2.y - radius,
arc2,
line_x - offset,
p2.y,
p2.x,
p2.y
));
}
} else if dir == "BT" {
if p1.x < p2.x {
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
line_x - radius,
p1.y,
arc,
line_x,
p1.y - offset,
line_x,
p2.y + radius,
arc2,
line_x + offset,
p2.y,
p2.x,
p2.y
));
} else {
color_class_num = branch_index.get(&commit_a.branch).copied().unwrap_or(0);
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
line_x + radius,
p1.y,
arc2,
line_x,
p1.y - offset,
line_x,
p2.y + radius,
arc,
line_x - offset,
p2.y,
p2.x,
p2.y
));
}
} else if p1.y < p2.y {
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p1.x,
line_y - radius,
arc,
p1.x + offset,
line_y,
p2.x - radius,
line_y,
arc2,
p2.x,
line_y + offset,
p2.x,
p2.y
));
} else {
color_class_num = branch_index.get(&commit_a.branch).copied().unwrap_or(0);
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p1.x,
line_y + radius,
arc2,
p1.x + offset,
line_y,
p2.x - radius,
line_y,
arc,
p2.x,
line_y - offset,
p2.x,
p2.y
));
}
} else {
let arc = "A 20 20, 0, 0, 0,";
let arc2 = "A 20 20, 0, 0, 1,";
let radius = 20.0;
let offset = 20.0;
if dir == "TB" {
if p1.x < p2.x {
if commit_b.commit_type == COMMIT_TYPE_MERGE
&& commit_a.id.as_str().ne(commit_b
.parents
.first()
.map(|s| s.as_str())
.unwrap_or(""))
{
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p1.x,
p2.y - radius,
arc,
p1.x + offset,
p2.y,
p2.x,
p2.y
));
} else {
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p2.x - radius,
p1.y,
arc2,
p2.x,
p1.y + offset,
p2.x,
p2.y
));
}
}
if p1.x > p2.x {
if commit_b.commit_type == COMMIT_TYPE_MERGE
&& commit_a.id.as_str().ne(commit_b
.parents
.first()
.map(|s| s.as_str())
.unwrap_or(""))
{
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p1.x,
p2.y - radius,
arc2,
p1.x - offset,
p2.y,
p2.x,
p2.y
));
} else {
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p2.x + radius,
p1.y,
arc,
p2.x,
p1.y + offset,
p2.x,
p2.y
));
}
}
if p1.x == p2.x {
line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
}
} else if dir == "BT" {
if p1.x < p2.x {
if commit_b.commit_type == COMMIT_TYPE_MERGE
&& commit_a.id.as_str().ne(commit_b
.parents
.first()
.map(|s| s.as_str())
.unwrap_or(""))
{
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p1.x,
p2.y + radius,
arc2,
p1.x + offset,
p2.y,
p2.x,
p2.y
));
} else {
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p2.x - radius,
p1.y,
arc,
p2.x,
p1.y - offset,
p2.x,
p2.y
));
}
}
if p1.x > p2.x {
if commit_b.commit_type == COMMIT_TYPE_MERGE
&& commit_a.id.as_str().ne(commit_b
.parents
.first()
.map(|s| s.as_str())
.unwrap_or(""))
{
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p1.x,
p2.y + radius,
arc,
p1.x - offset,
p2.y,
p2.x,
p2.y
));
} else {
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p2.x - radius,
p1.y,
arc,
p2.x,
p1.y - offset,
p2.x,
p2.y
));
}
}
if p1.x == p2.x {
line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
}
} else {
if p1.y < p2.y {
if commit_b.commit_type == COMMIT_TYPE_MERGE
&& commit_a.id.as_str().ne(commit_b
.parents
.first()
.map(|s| s.as_str())
.unwrap_or(""))
{
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p2.x - radius,
p1.y,
arc2,
p2.x,
p1.y + offset,
p2.x,
p2.y
));
} else {
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p1.x,
p2.y - radius,
arc,
p1.x + offset,
p2.y,
p2.x,
p2.y
));
}
}
if p1.y > p2.y {
if commit_b.commit_type == COMMIT_TYPE_MERGE
&& commit_a.id.as_str().ne(commit_b
.parents
.first()
.map(|s| s.as_str())
.unwrap_or(""))
{
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p2.x - radius,
p1.y,
arc,
p2.x,
p1.y - offset,
p2.x,
p2.y
));
} else {
line_def = Some(format!(
"M {} {} L {} {} {} {} {} L {} {}",
p1.x,
p1.y,
p1.x,
p2.y + radius,
arc2,
p1.x + offset,
p2.y,
p2.x,
p2.y
));
}
}
if p1.y == p2.y {
line_def = Some(format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y));
}
}
}
let d = line_def?;
Some(GitGraphArrowLayout {
from: commit_a.id.clone(),
to: commit_b.id.clone(),
class_index: (color_class_num % THEME_COLOR_LIMIT) as i64,
d,
})
}
pub fn layout_gitgraph_diagram(
semantic: &serde_json::Value,
effective_config: &serde_json::Value,
measurer: &dyn TextMeasurer,
) -> Result<GitGraphDiagramLayout> {
let model: GitGraphModel = crate::json::from_value_ref(semantic)?;
let _ = model.diagram_type.as_str();
let direction = if model.direction.trim().is_empty() {
"LR".to_string()
} else {
model.direction.trim().to_string()
};
let rotate_commit_label =
cfg_bool(effective_config, &["gitGraph", "rotateCommitLabel"]).unwrap_or(true);
let show_commit_label =
cfg_bool(effective_config, &["gitGraph", "showCommitLabel"]).unwrap_or(true);
let show_branches = cfg_bool(effective_config, &["gitGraph", "showBranches"]).unwrap_or(true);
let diagram_padding = cfg_f64(effective_config, &["gitGraph", "diagramPadding"])
.unwrap_or(8.0)
.max(0.0);
let parallel_commits =
cfg_bool(effective_config, &["gitGraph", "parallelCommits"]).unwrap_or(false);
let font_family = cfg_string(effective_config, &["fontFamily"])
.or_else(|| cfg_string(effective_config, &["themeVariables", "fontFamily"]))
.map(|s| s.trim().trim_end_matches(';').trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "\"trebuchet ms\", verdana, arial, sans-serif".to_string());
let font_size = cfg_font_size(effective_config);
let apply_bbox_corrections = crate::generated::gitgraph_text_overrides_11_12_2::
gitgraph_branch_label_bbox_corrections_enabled(&font_family, font_size);
let label_style = TextStyle {
font_family: Some(font_family),
font_size,
font_weight: None,
};
let mut branches: Vec<GitGraphBranchLayout> = Vec::new();
let mut branch_pos: HashMap<String, f64> = HashMap::new();
let mut branch_index: HashMap<String, usize> = HashMap::new();
let mut pos = 0.0;
for (i, b) in model.branches.iter().enumerate() {
let metrics = measurer.measure(&b.name, &label_style);
let bbox_w = crate::generated::gitgraph_text_overrides_11_12_2::
adjust_gitgraph_branch_label_bbox_width_px(
measurer.measure_svg_simple_text_bbox_width_px(&b.name, &label_style),
&b.name,
apply_bbox_corrections,
);
branch_pos.insert(b.name.clone(), pos);
branch_index.insert(b.name.clone(), i);
branches.push(GitGraphBranchLayout {
name: b.name.clone(),
index: i as i64,
pos,
bbox_width: bbox_w.max(0.0),
bbox_height: metrics.height.max(0.0),
});
pos += 50.0
+ if rotate_commit_label { 40.0 } else { 0.0 }
+ if direction == "TB" || direction == "BT" {
bbox_w.max(0.0) / 2.0
} else {
0.0
};
}
let mut commits_by_id: HashMap<String, GitGraphCommit> = HashMap::new();
for c in &model.commits {
commits_by_id.insert(c.id.clone(), c.clone());
}
let mut commit_order: Vec<GitGraphCommit> = model.commits.clone();
commit_order.sort_by_key(|c| c.seq);
let mut sorted_keys: Vec<String> = commit_order.iter().map(|c| c.id.clone()).collect();
if direction == "BT" {
sorted_keys.reverse();
}
let mut commit_pos: HashMap<String, CommitPosition> = HashMap::new();
let mut commits: Vec<GitGraphCommitLayout> = Vec::new();
let mut max_pos: f64 = 0.0;
let mut cur_pos = if direction == "TB" || direction == "BT" {
DEFAULT_POS
} else {
0.0
};
for id in &sorted_keys {
let Some(commit) = commits_by_id.get(id) else {
continue;
};
if parallel_commits {
if !commit.parents.is_empty() {
if let Some(closest_parent) =
find_closest_parent(&commit.parents, &direction, &commit_pos)
{
if let Some(parent_position) = commit_pos.get(&closest_parent) {
if direction == "TB" {
cur_pos = parent_position.y + COMMIT_STEP;
} else if direction == "BT" {
let current_position = commit_pos
.get(&commit.id)
.copied()
.unwrap_or(CommitPosition { x: 0.0, y: 0.0 });
cur_pos = current_position.y - COMMIT_STEP;
} else {
cur_pos = parent_position.x + COMMIT_STEP;
}
}
}
} else if direction == "TB" {
cur_pos = DEFAULT_POS;
}
}
let pos_with_offset = if direction == "BT" && parallel_commits {
cur_pos
} else {
cur_pos + LAYOUT_OFFSET
};
let Some(branch_lane) = branch_pos.get(&commit.branch).copied() else {
return Err(crate::Error::InvalidModel {
message: format!("unknown branch for commit {}: {}", commit.id, commit.branch),
});
};
let (x, y) = if direction == "TB" || direction == "BT" {
(branch_lane, pos_with_offset)
} else {
(pos_with_offset, branch_lane)
};
commit_pos.insert(commit.id.clone(), CommitPosition { x, y });
commits.push(GitGraphCommitLayout {
id: commit.id.clone(),
message: commit.message.clone(),
seq: commit.seq,
commit_type: commit.commit_type,
custom_type: commit.custom_type,
custom_id: commit.custom_id,
tags: commit.tags.clone(),
parents: commit.parents.clone(),
branch: commit.branch.clone(),
pos: cur_pos,
pos_with_offset,
x,
y,
});
cur_pos = if direction == "BT" && parallel_commits {
cur_pos + COMMIT_STEP
} else {
cur_pos + COMMIT_STEP + LAYOUT_OFFSET
};
max_pos = max_pos.max(cur_pos);
}
let mut lanes: Vec<f64> = if show_branches {
branches.iter().map(|b| b.pos).collect()
} else {
Vec::new()
};
let mut arrows: Vec<GitGraphArrowLayout> = Vec::new();
let mut commits_for_arrows = model.commits.clone();
commits_for_arrows.sort_by_key(|c| c.seq);
for commit_b in &commits_for_arrows {
for parent in &commit_b.parents {
let Some(commit_a) = commits_by_id.get(parent) else {
continue;
};
if let Some(a) = draw_arrow(
commit_a,
commit_b,
&commits_by_id,
&commit_pos,
&branch_index,
&mut lanes,
&direction,
) {
arrows.push(a);
}
}
}
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for b in &branches {
if direction == "TB" || direction == "BT" {
min_x = min_x.min(b.pos);
max_x = max_x.max(b.pos);
min_y = min_y.min(DEFAULT_POS.min(max_pos));
max_y = max_y.max(DEFAULT_POS.max(max_pos));
} else {
min_y = min_y.min(b.pos);
max_y = max_y.max(b.pos);
min_x = min_x.min(0.0);
max_x = max_x.max(max_pos);
let label_left =
-b.bbox_width - 4.0 - if rotate_commit_label { 30.0 } else { 0.0 } - 19.0;
min_x = min_x.min(label_left);
}
}
for c in &commits {
let r = if commit_symbol_type(&commits_by_id[&c.id]) == COMMIT_TYPE_MERGE {
9.0
} else {
10.0
};
min_x = min_x.min(c.x - r);
min_y = min_y.min(c.y - r);
max_x = max_x.max(c.x + r);
max_y = max_y.max(c.y + r);
}
let bounds = if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()
{
Some(Bounds {
min_x: min_x - diagram_padding,
min_y: min_y - diagram_padding,
max_x: max_x + diagram_padding,
max_y: max_y + diagram_padding,
})
} else {
None
};
Ok(GitGraphDiagramLayout {
bounds,
direction,
rotate_commit_label,
show_branches,
show_commit_label,
parallel_commits,
diagram_padding,
max_pos,
branches,
commits,
arrows,
})
}
#[cfg(test)]
mod tests {
#[test]
fn gitgraph_branch_label_bbox_width_overrides_are_generated() {
assert_eq!(
crate::generated::gitgraph_text_overrides_11_12_2::
lookup_gitgraph_branch_label_bbox_width_extra_px("develop"),
16.0 / 2048.0
);
assert_eq!(
crate::generated::gitgraph_text_overrides_11_12_2::
lookup_gitgraph_branch_label_bbox_width_extra_px("feature"),
-48.0 / 2048.0
);
assert_eq!(
crate::generated::gitgraph_text_overrides_11_12_2::
lookup_gitgraph_branch_label_bbox_width_extra_px("unknown"),
0.0
);
assert!(
crate::generated::gitgraph_text_overrides_11_12_2::
gitgraph_branch_label_bbox_corrections_enabled(
"\"trebuchet ms\", verdana, arial, sans-serif;",
16.0
)
);
assert_eq!(
crate::generated::gitgraph_text_overrides_11_12_2::
adjust_gitgraph_branch_label_bbox_width_px(56.0, "feature", true),
56.0 - 48.0 / 2048.0
);
}
}