feat(vcs): draw lane changes in the Git log graph as straight diagonals (#1406)

Lane changes bent within half a row as S-curves. A lane change and the vertical half of the neighbouring row that continues it now meet halfway between the lanes, so edges between adjacent rows run straight from node to node; only drawn positions change. Fixes #800
This commit is contained in:
l0ng-ai
2026-11-05 09:12:41 +08:00
committed by GitHub
parent f16ec29f4a
commit a247f1ce67
4 changed files with 96 additions and 10 deletions
+8 -7
View File
@@ -1956,10 +1956,9 @@ function x(lane: number) {
return LANE / 2 + lane * LANE;
}
/** Straight, also across lanes (IDEA's diagonal edges). */
function seg(fromX: number, fromY: number, toX: number, toY: number) {
if (fromX === toX) return `M${fromX} ${fromY}L${toX} ${toY}`;
const mid = (fromY + toY) / 2;
return `M${fromX} ${fromY}C${fromX} ${mid} ${toX} ${mid} ${toX} ${toY}`;
return `M${fromX} ${fromY}L${toX} ${toY}`;
}
export function GraphCell({
@@ -2008,9 +2007,11 @@ export function GraphCell({
if (canNavigate) onNavigate?.(s.targetRow!, target);
else if (canFragment) onFragment?.(target!, !!s.dashed, s.source);
};
const fromX = x(s.fromX ?? s.from);
const toX = x(s.toX ?? s.to);
const path =
seg(x(s.from), up ? 0 : cy, x(s.to), up ? cy : ROW) +
(s.arrow ? arrowHead(x(up ? s.from : s.to), up ? 0 : ROW, up ? -1 : 1) : "");
seg(fromX, up ? 0 : cy, toX, up ? cy : ROW) +
(s.arrow ? arrowHead(up ? fromX : toX, up ? 0 : ROW, up ? -1 : 1) : "");
return (
<g
key={`${up ? "u" : "d"}${i}`}
@@ -2033,9 +2034,9 @@ export function GraphCell({
>
<title>{label}</title>
<rect
x={Math.min(x(s.from), x(s.to)) - 5}
x={Math.min(fromX, toX) - 5}
y={up ? 0 : cy}
width={Math.max(10, Math.abs(x(s.from) - x(s.to)) + 10)}
width={Math.max(10, Math.abs(fromX - toX) + 10)}
height={cy}
fill="transparent"
pointerEvents="none"
+45 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computeGraph } from "../graph";
import { computeGraph, straightenLaneChanges } from "../graph";
const c = (id: string, ...parents: string[]) => ({ id, parents });
@@ -62,3 +62,47 @@ describe("computeGraph", () => {
expect(m2.down).toContainEqual({ from: m2.lane, to: 0, color: rows[0]!.color });
});
});
describe("straightenLaneChanges", () => {
it("draws a lane change as one diagonal across the row boundary, keeping the lanes", () => {
// m merges f into main; f branched from a; b sits in lane 0 while lane 1 passes it.
const rows = straightenLaneChanges(computeGraph([c("m", "b", "f"), c("f", "a"), c("b", "a"), c("a")]).rows);
const [m, f, b, a] = rows;
// m → f: leaves m's node towards lane 1 and reaches f's node in a straight line.
expect(m!.down[1]).toMatchObject({ from: 0, to: 1, toX: 0.5 });
expect(f!.up.find((s) => s.from === 1)).toMatchObject({ from: 1, to: 1, fromX: 0.5 });
// Lane 1 converging into a: the vertical line of b's row meets it halfway.
expect(a!.up[1]).toMatchObject({ from: 1, to: 0, fromX: 0.5 });
expect(b!.down.find((s) => s.from === 1)).toMatchObject({ from: 1, to: 1, toX: 0.5 });
// Straight lines stay where they are.
expect(m!.down[0]!.toX).toBeUndefined();
expect(a!.up[0]!.fromX).toBeUndefined();
});
it("leaves a lane that another line also ends in, and arrows, as they are", () => {
const rows = straightenLaneChanges([
{
lane: 0,
color: 0,
up: [],
down: [
{ from: 0, to: 1, color: 0 },
{ from: 1, to: 1, color: 1 },
],
width: 2,
},
{
lane: 1,
color: 1,
up: [{ from: 1, to: 1, color: 1 }],
down: [{ from: 1, to: 2, color: 1, arrow: "down" }],
width: 3,
},
{ lane: 2, color: 1, up: [{ from: 2, to: 2, color: 1 }], down: [], width: 3 },
]);
expect(rows[0]!.down[0]!.toX).toBeUndefined();
expect(rows[1]!.up[0]!.fromX).toBeUndefined();
expect(rows[1]!.down[0]!.toX).toBeUndefined();
expect(rows[2]!.up[0]!.fromX).toBeUndefined();
});
});
+40
View File
@@ -21,6 +21,9 @@ export interface GraphSegment {
arrow?: "up" | "down";
/** Destination row of a long-edge arrow in the displayed graph. */
targetRow?: number;
/** Drawn lane positions when they differ from `from` / `to` (see {@link straightenLaneChanges}). */
fromX?: number;
toX?: number;
}
export interface GraphRow {
@@ -503,6 +506,43 @@ export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE):
return out;
}
/**
* Draws a lane change as one straight diagonal across the row boundary, like IDEA: a half-row
* segment that changes lane and the vertical half of the neighbouring row that continues it meet
* halfway between the two lanes. Only the drawn positions change; `from` / `to` stay the lanes.
*/
export function straightenLaneChanges(rows: readonly GraphRow[]): GraphRow[] {
const out = rows.map((r) => ({ ...r, up: [...r.up], down: [...r.down] }));
const vertical = (s: GraphSegment, lane: number) => s.from === lane && s.to === lane && !s.arrow;
for (let r = 0; r < out.length; r++) {
const row = out[r]!;
// Leaving the node towards another lane: continues straight down in the next row.
const next = out[r + 1];
// Another line ending at the same lane (a merge into a lane that continues) keeps its position.
const shared = (list: readonly GraphSegment[], lane: number, end: "from" | "to") =>
list.filter((x) => x[end] === lane).length > 1;
row.down.forEach((s, i) => {
if (s.from === s.to || s.arrow || !next || shared(row.down, s.to, "to")) return;
const j = next.up.findIndex((u) => vertical(u, s.to) && u.edge === s.edge && u.fromX === undefined);
if (j < 0) return;
const mid = (s.from + s.to) / 2;
row.down[i] = { ...s, toX: mid };
next.up[j] = { ...next.up[j]!, fromX: mid };
});
// Converging into the node from another lane: continues a vertical line of the previous row.
const prev = out[r - 1];
row.up.forEach((s, i) => {
if (s.from === s.to || s.arrow || !prev || shared(prev.down, s.from, "to")) return;
const j = prev.down.findIndex((d) => vertical(d, s.from) && d.edge === s.edge && d.toX === undefined);
if (j < 0) return;
const mid = (s.from + s.to) / 2;
row.up[i] = { ...s, fromX: mid };
prev.down[j] = { ...prev.down[j]!, toX: mid };
});
}
return out;
}
/** Lane colours (tokens), cycled by colour index. */
export const LANE_COLORS = [
"var(--accent)",
+3 -2
View File
@@ -1,4 +1,4 @@
import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges } from "./graph";
import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges, straightenLaneChanges } from "./graph";
import { commitKey, graphCommit, type RepoCommit } from "./multiRoot";
import type { LogOptions } from "./logOptions";
@@ -26,5 +26,6 @@ export function presentLogGraph(
const byId = new Map(all.map((c) => [commitKey(c), c]));
const commits = graph.map((c) => byId.get(c.id)!);
const laid = computeGraph(graph).rows;
return { commits, graph, rows: options.longEdges ? laid : hideLongEdges(laid), linear, collapsed };
const rows = straightenLaneChanges(options.longEdges ? laid : hideLongEdges(laid));
return { commits, graph, rows, linear, collapsed };
}