feat(vcs): draw the Git log graph's lane changes as straight diagonals

A line that changed lane bent within half a row as an S-curve and then ran on vertically, so branches and merges looked like rounded steps. A lane change is now a straight diagonal across the row boundary: the half-row segment that changes lane and the vertical half of the neighbouring row that continues it meet halfway between the two lanes, so an edge between adjacent rows runs straight from node to node, both where a branch leaves and where it converges. Only the drawn positions change (fromX / toX); lanes, navigation and fragment actions keep using from / to. A lane that another line also ends in, and long-edge arrows, keep their positions.

Fixes #800
This commit is contained in:
l0ng-ai
2026-11-05 09:12:05 +08:00
parent f16ec29f4a
commit 42d97daffd
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 };
}