Git graphs from data

Branches become lanes, commits chain left to right on their branch's lane, and parent edges draw straight lines - two parents make a merge

import { type Branch, type Commit, GitGraph } from "./GitGraph";

const branches: Branch[] = [
	{ name: "main" },
	{ name: "develop" },
	{ name: "feature" },
];

// Commits in topological order; two parents make a merge commit
const commits: Commit[] = [
	{ id: "a1f9", branch: "main" },
	{ id: "b7c2", branch: "main", parents: ["a1f9"] },
	{ id: "c3d8", branch: "develop", parents: ["b7c2"] },
	{ id: "d9e4", branch: "feature", parents: ["c3d8"] },
	{ id: "e5f0", branch: "develop", parents: ["c3d8"] },
	{ id: "f1a6", branch: "develop", parents: ["e5f0", "d9e4"] },
	{ id: "g7b3", branch: "main", parents: ["b7c2", "f1a6"], tag: "v1.0" },
];

export function GitGraphExample() {
	return (
		<div style={{ display: "flex", flexWrap: "wrap", gap: "1.5rem" }}>
			<div>
				<h3 style={{ margin: "0 0 0.5rem" }}>Horizontal (LR)</h3>
				<GitGraph branches={branches} commits={commits} />
			</div>
			<div>
				<h3 style={{ margin: "0 0 0.5rem" }}>Vertical (TB)</h3>
				<GitGraph branches={branches} commits={commits} direction="vertical" />
			</div>
		</div>
	);
}

Horizontal (LR)

Vertical (TB)

How the GitGraph component works

Branch pills define lane rows; invisible commit anchors chain horizontally with distribute and align to their lane; edges reference the anchors (painted under), then circles, ids, and tags go on top

import { Graphic } from "@modular-svg/react";
import * as React from "react";

export type Branch = { name: string; color?: string };

export type Commit = {
	id: string;
	branch: string;
	/** parent commit ids; two parents make a merge commit */
	parents?: string[];
	tag?: string;
};

export type GitGraphProps = {
	/** lane order (top to bottom when horizontal, left to right when vertical) */
	branches: Branch[];
	/** commits in topological order */
	commits: Commit[];
	/** horizontal chains commits left-to-right (mermaid LR, default);
	 * vertical chains them top-to-bottom (mermaid TB) */
	direction?: "horizontal" | "vertical";
	commitSpacing?: number;
	laneSpacing?: number;
	fontSize?: number;
};

const laneId = (branch: string) => `lane-${branch}`;
const labelId = (branch: string) => `lane-${branch}-label`;
const anchorId = (commit: string) => `commit-${commit}`;
const dotId = (commit: string) => `commit-${commit}-dot`;
const edgeId = (from: string, to: string) => `edge-${from}-${to}`;

// Edges reference invisible anchors declared before them, so the visible
// circles (declared after) paint on top without any z-order juggling.
// The stroke color is left to the constraint solver (SameColor), so no
// color is set here.
function Edge({
	id,
	from,
	to,
	direction,
}: {
	id: string;
	from: string;
	to: string;
	direction: "horizontal" | "vertical";
}) {
	return (
		<curve key={id} stroke-width={2} direction={direction}>
			<ref target={anchorId(from)} />
			<ref target={anchorId(to)} />
		</curve>
	);
}

export function GitGraph({
	branches,
	commits,
	direction = "horizontal",
	commitSpacing = 50,
	laneSpacing = 34,
	fontSize = 12,
}: GitGraphProps) {
	const horizontal = direction === "horizontal";
	// the axis commits chain along, and the axis lanes align across
	const chainAxis = horizontal ? "x" : "y";
	const laneAlign = horizontal ? "centerY" : "centerX";

	// Every element that should follow a branch's color, grouped by branch:
	// the commit dots on that branch and the edges landing on those commits.
	const branchElements = new Map<string, string[]>(
		branches.map((b) => [b.name, []]),
	);
	for (const c of commits) {
		branchElements.get(c.branch)?.push(dotId(c.id));
		for (const p of c.parents ?? []) {
			branchElements.get(c.branch)?.push(edgeId(p, c.id));
		}
	}

	const LanePill = ({ branch }: { branch: Branch }) => (
		<background
			key={laneId(branch.name)}
			padding={4}
			fill={branch.color}
			stroke-width={0}
			rx={6}
		>
			<text key={labelId(branch.name)} font-size={fontSize}>
				{branch.name}
			</text>
		</background>
	);

	return (
		<Graphic
			style={{
				border: "1px solid #ddd",
				borderRadius: "8px",
				display: "inline-block",
			}}
			margin={15}
		>
			<group>
				{/* Branch label pills define the lanes */}
				{horizontal ? (
					<stackV key="lanes" spacing={laneSpacing} alignment="right">
						{branches.map((b) => (
							<LanePill key={b.name} branch={b} />
						))}
					</stackV>
				) : (
					<stackH key="lanes" spacing={laneSpacing} alignment="bottom">
						{branches.map((b) => (
							<LanePill key={b.name} branch={b} />
						))}
					</stackH>
				)}

				{/* Invisible commit anchors: one column per commit, on its lane */}
				{commits.map((c, i) => (
					<React.Fragment key={anchorId(c.id)}>
						<rect key={anchorId(c.id)} width={0} height={0} stroke-width={0} />
						<align alignment={laneAlign}>
							<ref target={laneId(c.branch)} />
							<ref target={anchorId(c.id)} />
						</align>
						<distribute axis={chainAxis} spacing={i === 0 ? 28 : commitSpacing}>
							<ref target={i === 0 ? "lanes" : anchorId(commits[i - 1].id)} />
							<ref target={anchorId(c.id)} />
						</distribute>
					</React.Fragment>
				))}

				{/* Edges (under), then commit circles, ids, and tags (over) */}
				{commits.flatMap((c) =>
					(c.parents ?? []).map((p) => (
						<Edge
							key={edgeId(p, c.id)}
							id={edgeId(p, c.id)}
							from={p}
							to={c.id}
							direction={horizontal ? "horizontal" : "vertical"}
						/>
					)),
				)}

				{commits.map((c) => (
					<React.Fragment key={`commit-${c.id}-dot`}>
						<circle key={dotId(c.id)} r={8} stroke="#333" stroke-width={1.5} />
						<align alignment="center">
							<ref target={anchorId(c.id)} />
							<ref target={dotId(c.id)} />
						</align>

						<text
							key={`${anchorId(c.id)}-id`}
							font-size={fontSize - 2}
							fill="#555"
						>
							{c.id}
						</text>
						<align alignment={horizontal ? "centerX" : "centerY"}>
							<ref target={anchorId(c.id)} />
							<ref target={`${anchorId(c.id)}-id`} />
						</align>
						<distribute axis={horizontal ? "y" : "x"} spacing={12}>
							<ref target={anchorId(c.id)} />
							<ref target={`${anchorId(c.id)}-id`} />
						</distribute>

						{c.tag && (
							<>
								<background
									key={`${anchorId(c.id)}-tag`}
									padding={3}
									fill="#FFF3C4"
									stroke="#C9A227"
									stroke-width={1}
									rx={4}
								>
									<text font-size={fontSize - 2}>{c.tag}</text>
								</background>
								<align alignment={horizontal ? "centerX" : "centerY"}>
									<ref target={anchorId(c.id)} />
									<ref target={`${anchorId(c.id)}-tag`} />
								</align>
								<distribute axis={horizontal ? "y" : "x"} spacing={16}>
									<ref target={`${anchorId(c.id)}-tag`} />
									<ref target={anchorId(c.id)} />
								</distribute>
								{React.createElement(
									"line",
									{
										key: `${anchorId(c.id)}-tagline`,
										stroke: "#C9A227",
										"stroke-width": 1,
									},
									React.createElement("ref", {
										target: `${anchorId(c.id)}-tag`,
									}),
									React.createElement("ref", { target: anchorId(c.id) }),
								)}
							</>
						)}
					</React.Fragment>
				))}

				{/* Color constraints (declared last so every dot and edge they
				    reference already exists): pick a distinct color per branch,
				    copy it onto that branch's dots and edges, and make each
				    label readable against its pill */}
				<distinctColors>
					{branches.map((b) => (
						<ref key={b.name} target={laneId(b.name)} />
					))}
				</distinctColors>
				{branches.map((b) => (
					<sameColor key={`same-${b.name}`}>
						<ref target={laneId(b.name)} />
						{(branchElements.get(b.name) ?? []).map((el) => (
							<ref key={el} target={el} />
						))}
					</sameColor>
				))}
				{branches.map((b) => (
					<contrast key={`contrast-${b.name}`} ratio={4.5}>
						<ref target={labelId(b.name)} />
						<ref target={laneId(b.name)} />
					</contrast>
				))}
			</group>
		</Graphic>
	);
}