返回文章列表
OrbitX Logo专栏文章
从零到一构建智能终端

深入 OrbitX 智能终端的架构设计与实现细节,从底层 Mux 架构到前端渲染优化,从 Shell Integration 到 AI 功能集成

从零到一构建智能终端(五):Git 提交图的可视化算法

解析如何将 Git 的 DAG 提交历史转换为二维平面图,探讨列占用算法的设计思路、分支合并处理和 SVG 路径生成

11 min read
5

引言

Git 的提交历史是一个有向无环图(DAG),每个提交可以有多个父节点(合并)和多个子节点(分支)。将这个 DAG 渲染成类似 git log --graph 的二维图形,需要解决两个核心问题:

  1. X 轴分配:每个提交应该放在哪一列?
  2. 连线绘制:如何连接子提交和父提交?

本文介绍一种简洁有效的算法——列占用法(Column Reservation)

问题建模

输入

Git 提交列表,按时间倒序排列(最新的在前):

typescriptinterface CommitInfo {
  hash: string
  parents: string[]  // 父提交的 hash 列表
  message: string
  // ...其他字段
}

关键点:

  • parents[0] 是"第一父提交"(主线)
  • parents[1...] 是"其他父提交"(合并进来的分支)

输出

节点和边的坐标信息:

typescriptinterface GraphNode {
  commit: CommitInfo
  x: number      // 列号(0, 1, 2, ...)
  index: number  // 行号(在列表中的位置)
  color: string  // 该列的颜色
}

interface GraphEdge {
  fromHash: string
  fromX: number
  toHash: string
  toX: number
  color: string
}

列占用算法

核心思想

维护一个"列状态数组" columns,记录每一列当前被哪个提交"占用":

columns[x] = commitHash  // 该列被某个提交占用
columns[x] = null        // 该列空闲

当处理一个提交时:

  1. 找到它应该在的列(可能已被预留,或者找一个空闲的)
  2. 清空该列(我们已经"到达"了)
  3. 为它的父提交预留列

算法步骤

输入: commits[] 按时间倒序
输出: nodes[], edges[]

初始化:
  columns = []           // 列状态数组
  hashToIndex = Map()    // hash → 行号的映射

预处理:
  for i, commit in commits:
    hashToIndex[commit.hash] = i

主循环:
  for i, commit in commits:
    hash = commit.hash
    parents = commit.parents

    // 步骤1: 确定当前提交的列
    myX = columns.indexOf(hash)
    if myX == -1:
      // 没有预留列,找一个空闲的
      myX = columns.indexOf(null)
      if myX == -1:
        // 没有空闲列,新建一列
        myX = columns.length
        columns.push(null)

    // 步骤2: 清空该列(我们已到达)
    columns[myX] = null

    // 记录节点
    nodes.push({ commit, x: myX, index: i })

    // 步骤3: 为父提交预留列
    for p, parentHash in parents:
      parentIndex = hashToIndex[parentHash]
      if parentIndex == undefined:
        continue  // 父提交不在当前视图中

      parentX = columns.indexOf(parentHash)

      if parentX == -1:
        // 父提交还没有预留列
        if p == 0:
          // 第一父提交:继承当前列(主线延续)
          parentX = myX
          columns[myX] = parentHash
        else:
          // 其他父提交:需要新列(合并线)
          parentX = columns.indexOf(null)
          if parentX == -1:
            parentX = columns.length
            columns.push(null)
          columns[parentX] = parentHash

      // 记录边
      edges.push({
        fromHash: hash, fromX: myX,
        toHash: parentHash, toX: parentX
      })

执行示例

考虑以下提交历史(从上到下时间递增):

A ← B ← D ← F  (main)
     ↖ C ← E ↗ (feature,在 F 合并)

提交顺序(倒序):[F, E, D, C, B, A]

父子关系:

  • F: parents = [D, E](合并提交)
  • E: parents = [C]
  • D: parents = [B]
  • C: parents = [B]
  • B: parents = [A]
  • A: parents = []

执行过程:

步骤处理columns 状态分配 X
1F[null] → [D]F→0, 预留 D→0, E→1
2E[D, E] → [D, C]E→1, 预留 C→1
3D[D, C] → [B, C]D→0, 预留 B→0
4C[B, C] → [B, B]C→1, 预留 B→0(已有)
5B[B, B] → [A, null]B→0, 预留 A→0
6A[A, null] → [null, null]A→0

最终布局:

列:    0    1
       F────┐
       │    E
       D    │
       │    C
       B←───┘
       │
       A

关键设计决策

第一父提交继承当前列

这是让图形美观的关键:

typescriptif (p === 0) {
  // First parent: stays in same column (main line continues)
  parentX = myX
  columns[myX] = parentHash
}

如果不这样做,主线会频繁跳列,看起来很乱。

合并提交的处理

当一个提交有多个父节点时(合并提交):

  • 第一父提交(被合并进的分支,通常是 main)→ 继承当前列
  • 其他父提交(被合并的分支)→ 分配新列

这保证了主线保持垂直,分支线斜向合入。

列复用

当一个提交被处理后,它占用的列就释放了:

typescriptcolumns[myX] = null

后续提交可以复用这个列,避免列数无限增长。

SVG 路径生成

有了节点坐标,需要绘制连接线。

直线 vs 曲线

如果起点和终点在同一列,画直线:

typescriptif (fromX === toX) {
  return `M ${x1} ${y1} L ${x2} ${y2}`
}

如果跨列,画贝塞尔曲线:

typescriptconst controlY = y1 + Math.abs(y2 - y1) * 0.3
return `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`

这里用三次贝塞尔曲线(Cubic Bezier),控制点设置为:

  • 控制点1:(x1, controlY) — 从起点垂直向下
  • 控制点2:(x2, controlY) — 在终点列垂直向下

效果是:线条先垂直向下,然后平滑转向目标列。

为什么用 0.3 系数

typescriptconst controlY = y1 + Math.abs(y2 - y1) * 0.3

这个 0.3 控制曲线的"弯曲程度":

  • 太小(如 0.1):曲线太尖锐
  • 太大(如 0.5):曲线太平缓,可能与其他线重叠
  • 0.3 是经验值,视觉效果较好

颜色分配

每一列分配一个固定颜色:

typescriptconst LANE_COLORS = [
  '#f97316', '#eab308', '#22c55e', '#06b6d4',
  '#3b82f6', '#8b5cf6', '#ec4899', '#ef4444'
]

const getLaneColor = (lane: number) => LANE_COLORS[lane % LANE_COLORS.length]

8 种颜色循环使用。边的颜色继承目标列的颜色(columnColors[parentX]),这样视觉上能追踪分支流向。

Y 坐标计算

X 坐标由算法分配,Y 坐标有两种策略:

固定行高

最简单的方案:

typescriptconst ROW_HEIGHT = 28
const getNodeY = (index: number) => index * ROW_HEIGHT + ROW_HEIGHT / 2

每个提交占一行,Y 坐标直接由行号计算。

动态行高

当提交行展开显示文件列表时,行高会变化。需要动态计算:

typescriptconst commitRowCenterY = new Map<string, number>()

const updateCommitRowCenters = () => {
  for (const [hash, rowEl] of commitRowRefs) {
    const infoHeight = rowEl.querySelector('.commit-info')?.offsetHeight ?? ROW_HEIGHT
    commitRowCenterY.set(hash, rowEl.offsetTop + infoHeight / 2)
  }
}

const getNodeY = (hash: string, index: number) => {
  return commitRowCenterY.get(hash) ?? index * ROW_HEIGHT + ROW_HEIGHT / 2
}

每次行高变化(展开/折叠)后,重新计算所有节点的 Y 坐标。

渲染层次

SVG 图层需要正确的层次关系:

1. 边(path)— 底层
2. 节点外圈(circle,背景色)— 中层
3. 节点内圈(circle,彩色)— 顶层

这样节点会盖住经过它的边,看起来更清晰:

html<svg>
  <!-- 先画所有边 -->
  <path v-for="edge in edges" :d="getEdgePath(edge)" />

  <!-- 再画所有节点 -->
  <g v-for="node in nodes">
    <circle r="5" fill="背景色" />  <!-- 外圈,遮住边 -->
    <circle r="4" :fill="node.color" />  <!-- 内圈,实际颜色 -->
  </g>
</svg>

边界情况处理

父提交不在视图中

当滚动加载时,可能只加载了部分历史。如果父提交不在当前列表中:

typescriptconst parentIndex = hashToIndex.get(parentHash)
if (parentIndex === undefined) {
  continue  // 跳过,不画这条边
}

这条边会在加载更多提交后出现。

空提交列表

typescriptif (commits.length === 0) {
  return { nodes: [], edges: [], maxX: 0 }
}

无父提交(初始提交)

初始提交的 parents 为空数组,算法自然处理——不会创建任何边。

性能考虑

时间复杂度

  • 主循环:O(n),n 是提交数量
  • columns.indexOf():O(k),k 是当前列数(通常很小,< 10)
  • 总体:O(n × k) ≈ O(n)

空间复杂度

  • columns:O(k)
  • nodesedges:O(n + e),e 是边数(最多 2n)
  • 总体:O(n)

增量更新

当加载更多提交时,可以选择:

  1. 重新计算:简单,代码已经是 O(n)
  2. 增量追加:复杂,需要保存中间状态

对于几百条提交,重新计算完全可以接受(< 1ms)。

与其他算法对比

算法复杂度美观度适用场景
列占用法O(n)良好一般场景
拓扑排序+层次布局O(n log n)优秀复杂 DAG
力导向布局O(n²)可变交互式探索

列占用法是简单与效果的平衡点,适合 Git 这种"主要是线性,偶尔分支合并"的 DAG 结构。

总结

Git 提交图可视化的核心是列占用法

  1. 维护列状态数组,记录每列被哪个提交占用
  2. 处理提交时:找到或分配列 → 清空列 → 为父提交预留列
  3. 第一父提交继承当前列,保持主线垂直
  4. 跨列边用贝塞尔曲线,同列边用直线
  5. 正确处理渲染层次:边在底,节点在顶

这个算法的优点是单次遍历完成布局,不需要回溯或多轮迭代,非常适合实时渲染场景。