/** * Pure git-graph lane layout (L8 / git-graph view). * * `computeGraphRows` turns a newest-first commit list into render-ready rows that * a renderer can draw as a GitKraken-style graph: **continuous coloured lanes** * with curved connectors at branch points (forks) and merges. * * ## Model * * We keep an ordered list of "active lanes": `lanes[i]` is the hash of the commit * we expect next on lane `i` (or `null` when the lane is free). A lane index is a * stable column; we never re-index existing lanes (a freed lane is reused in * place), so a branch that doesn't bend draws a **straight vertical line**. * * Each row is split into two halves around the commit node (drawn at mid-height): * - `incoming` links go from the **top** boundary to the node's mid level, * - `outgoing` links go from the node's mid level to the **bottom** boundary. * * Straight links (`fromLane === toLane`) are pass-through lane lines; bent links * are the fork/merge connectors. Because lane x-positions are identical across * rows, a row's `outgoing` meets the next row's `incoming` at the shared * boundary, yielding unbroken lanes top-to-bottom. */ import type { GraphCommit } from "@/domain"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- /** A line segment within one half of a row. */ export interface GraphLink { /** Lane index at the start of the segment. */ fromLane: number; /** Lane index at the end of the segment. */ toLane: number; /** Stroke colour. */ color: string; } export interface GraphRow { commit: GraphCommit; /** Lane (column) of this commit's node. */ lane: number; /** Node colour (its lane's colour). */ color: string; /** Links in the top half: top boundary → node mid. */ incoming: GraphLink[]; /** Links in the bottom half: node mid → bottom boundary. */ outgoing: GraphLink[]; /** Number of lane columns occupied around this row (for SVG width). */ laneCount: number; } // --------------------------------------------------------------------------- // Colour palette (dark-theme-friendly) // --------------------------------------------------------------------------- const LANE_COLORS: readonly string[] = [ "#4f9cf9", // blue "#a78bfa", // violet "#34d399", // emerald "#fbbf24", // amber "#f87171", // rose "#38bdf8", // sky "#fb923c", // orange "#c084fc", // purple "#f472b6", // pink "#2dd4bf", // teal ]; /** Stable colour for a lane index. */ export function laneColor(laneIndex: number): string { return LANE_COLORS[laneIndex % LANE_COLORS.length]; } // --------------------------------------------------------------------------- // Core algorithm // --------------------------------------------------------------------------- /** Index of the first free (null) lane, or -1. */ function firstFree(lanes: (string | null)[]): number { return lanes.indexOf(null); } /** Places `hash` on a lane: an existing free slot if any, else a new column. */ function allocLane(lanes: (string | null)[], hash: string): number { const free = firstFree(lanes); if (free !== -1) { lanes[free] = hash; return free; } lanes.push(hash); return lanes.length - 1; } /** * Assigns lanes + links to each commit. * * @param commits Commits ordered **newest-first** (as `git log`). */ export function computeGraphRows(commits: GraphCommit[]): GraphRow[] { // lanes[i] = hash expected next on lane i (null = free). const lanes: (string | null)[] = []; const rows: GraphRow[] = []; for (const commit of commits) { const { hash, parents } = commit; const top = lanes.slice(); // ── Resolve the commit's lane ──────────────────────────────────────── let commitLane = lanes.indexOf(hash); if (commitLane === -1) { // A tip nobody pointed at yet: open a lane for it. commitLane = allocLane(lanes, hash); } // Other lanes also expecting this hash converge into the node and end. for (let i = 0; i < top.length; i++) { if (i !== commitLane && top[i] === hash) lanes[i] = null; } // ── Route parents ──────────────────────────────────────────────────── const parentLanes: number[] = []; if (parents.length === 0) { lanes[commitLane] = null; // root: lane ends here. } else { lanes[commitLane] = parents[0]; // first parent keeps the lane. parentLanes.push(commitLane); for (let k = 1; k < parents.length; k++) { const ph = parents[k]; const existing = lanes.indexOf(ph); parentLanes.push(existing !== -1 ? existing : allocLane(lanes, ph)); } } const bottom = lanes.slice(); // ── Top half: every active top lane → node mid ─────────────────────── const incoming: GraphLink[] = []; for (let i = 0; i < top.length; i++) { if (top[i] === null) continue; if (i === commitLane || top[i] === hash) { // Into the node (straight if i === commitLane, a converging curve else). incoming.push({ fromLane: i, toLane: commitLane, color: laneColor(i) }); } else { incoming.push({ fromLane: i, toLane: i, color: laneColor(i) }); // pass-through } } // ── Bottom half: node mid → every active bottom lane ───────────────── const outgoing: GraphLink[] = []; for (let j = 0; j < bottom.length; j++) { if (bottom[j] === null) continue; if (parentLanes.includes(j)) { outgoing.push({ fromLane: commitLane, toLane: j, color: laneColor(j) }); // node → parent } else { outgoing.push({ fromLane: j, toLane: j, color: laneColor(j) }); // pass-through } } rows.push({ commit, lane: commitLane, color: laneColor(commitLane), incoming, outgoing, laneCount: Math.max(top.length, bottom.length, commitLane + 1), }); } return rows; }