mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user