import type { GitLogEntry } from '@/lib/api/types'; type LaneColor = string; /** * Describes one visible line/curve in a commit row's SVG. * Each segment covers the FULL row height (y=0 to y=100%). * * Types: * - 'passing' : straight vertical line, lane active but this row is not its commit * - 'commit-lane': straight vertical line for this commit's lane (has both incoming and outgoing) * - 'top-stub' : line from y=0 to dot-y only (branch HEAD — no child above) * - 'bottom-stub': line from dot-y to y=100% only (root commit — nothing above) * - 'branch-out' : bezier from (dot-x, dot-y) to (toLane-x, 100%) — new parent lane opens * - 'merge-in' : bezier from (fromLane-x, 0) to (dot-x, dot-y) — lane converges here */ interface ConnectorSegment { fromLane: number; toLane: number; color: LaneColor; type: 'passing' | 'commit-lane' | 'top-stub' | 'bottom-stub' | 'branch-out' | 'merge-in'; } export interface LanedCommit { commit: GitLogEntry; lane: number; color: LaneColor; /** All visible line segments in this row's height. */ connectors: ConnectorSegment[]; } const LANE_COLORS: LaneColor[] = [ 'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)', 'var(--syntax-keyword)', 'var(--syntax-string)', 'var(--status-info)', ]; function laneColor(lane: number): LaneColor { return LANE_COLORS[lane % LANE_COLORS.length]; } /** * Assigns visual lanes to a list of commits (newest-first order). * * Greedy lane assignment algorithm (O(n × lanes) where lanes = max concurrent active branches): * - activeLanes[i] holds the hash expected next on lane i (or null if free) * - Each commit takes the lane that was waiting for it, or the next free lane * - Merge commits open new lanes for additional parents * - Connectors describe ALL visible lines in each row (both above and below the dot) */ export function assignLanes(commits: GitLogEntry[]): LanedCommit[] { if (commits.length === 0) return []; // activeLanes[i] = hash of the next commit expected on lane i, or null if free const activeLanes: Array = []; const result: LanedCommit[] = []; for (let i = 0; i < commits.length; i++) { const commit = commits[i]; // Find all lanes waiting for this commit const waitingLanes: number[] = []; for (let li = 0; li < activeLanes.length; li++) { if (activeLanes[li] === commit.hash) { waitingLanes.push(li); } } // Use the first waiting lane as the commit's lane let assignedLane = waitingLanes.length > 0 ? waitingLanes[0] : -1; if (assignedLane === -1) { // No existing lane claimed this commit; take the first free lane const freeLane = activeLanes.indexOf(null); if (freeLane !== -1) { assignedLane = freeLane; } else { assignedLane = activeLanes.length; activeLanes.push(null); } } // Mark other waiting lanes as converging here (will emit merge-in connectors) const convergingLanes = waitingLanes.slice(1); const color = laneColor(assignedLane); const hasIncoming = activeLanes[assignedLane] === commit.hash; const hasParent = commit.parents.length > 0; // Update this commit's lane to point at its first parent if (hasParent) { activeLanes[assignedLane] = commit.parents[0]; } else { activeLanes[assignedLane] = null; } // Open new lanes for additional parents (merge commits) const extraParentLanes: number[] = []; const extraParentIsNew = new Set(); for (let p = 1; p < commit.parents.length; p++) { const parentHash = commit.parents[p]; // Check if another lane is already waiting for this parent const existingLane = activeLanes.indexOf(parentHash); if (existingLane !== -1) { extraParentLanes.push(existingLane); } else { const freeLane = activeLanes.indexOf(null); if (freeLane !== -1) { activeLanes[freeLane] = parentHash; extraParentLanes.push(freeLane); extraParentIsNew.add(freeLane); } else { const newLane = activeLanes.length; activeLanes.push(parentHash); extraParentLanes.push(newLane); extraParentIsNew.add(newLane); } } } // Build connectors: ALL visible line segments in this row const connectors: ConnectorSegment[] = []; // This commit's own lane segment if (hasIncoming && hasParent) { connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'commit-lane' }); } else if (hasIncoming && !hasParent) { connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'top-stub' }); } else if (!hasIncoming && hasParent) { connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'bottom-stub' }); } // else: orphan with no parent and no child — just the dot, no lines // Merge-in connectors for converging lanes for (const convergingLane of convergingLanes) { connectors.push({ fromLane: convergingLane, toLane: assignedLane, color: laneColor(convergingLane), type: 'merge-in', }); // Clear the converging lane activeLanes[convergingLane] = null; } // Branch-out segments for merge commit's extra parents for (const extraLane of extraParentLanes) { connectors.push({ fromLane: assignedLane, toLane: extraLane, color: laneColor(extraLane), type: 'branch-out', }); } // Passing-through lanes (active but not this commit's lane or newly-opened extra parent lanes) // Reused extra parents already have an active lane above the merge, so they must keep their // vertical passing segment for continuity (otherwise a gap appears between // the commit above and the merge row, as in the double-merge-of-same-branch case). for (let lane = 0; lane < activeLanes.length; lane++) { if (activeLanes[lane] === null) continue; if (lane === assignedLane) continue; if (extraParentIsNew.has(lane)) continue; connectors.push({ fromLane: lane, toLane: lane, color: laneColor(lane), type: 'passing', }); } result.push({ commit, lane: assignedLane, color, connectors }); } return result; }