182 lines
6.3 KiB
TypeScript
182 lines
6.3 KiB
TypeScript
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<string | null> = [];
|
||
|
||
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<number>();
|
||
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;
|
||
}
|