feat: lay out and navigate presentation figures

This commit is contained in:
lda
2026-07-05 17:45:56 +07:00 Verified
parent dc38251f3b
commit 957077709f
5 changed files with 237 additions and 4 deletions
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import type { FigureDefinition, FigureNodeDefinition } from "./model.js";
import { layoutFigure, type PositionedFigure } from "./layout.js";
const position = (layout: PositionedFigure, nodeId: string) =>
layout.nodes.find((n) => n.id === nodeId)!.position;
describe("layoutFigure", () => {
it("places layered edges from top to bottom", () => {
const layout = layoutFigure(layeredFigure);
expect(position(layout, "client").y).toBeLessThan(position(layout, "runtime").y);
});
it("places flow edges from left to right", () => {
const layout = layoutFigure(flowFigure);
expect(position(layout, "discover").x).toBeLessThan(position(layout, "repair").x);
});
it("preserves explicit authored positions", () => {
expect(position(layoutFigure(explicitFigure), "runtime")).toEqual({ x: 420, y: 180 });
});
it("rejects an explicit layout missing a node position", () => {
expect(() => layoutFigure(explicitFigureMissingPosition))
.toThrow("missing_explicit_position:runtime");
});
it("is deterministic and does not mutate the definition", () => {
const before = structuredClone(layeredFigure);
expect(layoutFigure(layeredFigure)).toEqual(layoutFigure(layeredFigure));
expect(layeredFigure).toEqual(before);
});
});
const layeredFigure: FigureDefinition = {
id: "layered",
title: "Layered",
layout: { kind: "layered" },
nodes: [
{ id: "client", label: "Client", summary: "caller", kind: "actor" },
{ id: "runtime", label: "Runtime", summary: "server", kind: "runtime" },
],
edges: [{ id: "e1", from: "client", to: "runtime" }],
};
const flowFigure: FigureDefinition = {
id: "flow",
title: "Flow",
layout: { kind: "flow" },
nodes: [
{ id: "discover", label: "Discover", summary: "find", kind: "operation" },
{ id: "repair", label: "Repair", summary: "fix", kind: "operation" },
],
edges: [{ id: "e2", from: "discover", to: "repair" }],
};
const explicitFigure: FigureDefinition = {
id: "explicit",
title: "Explicit",
layout: {
kind: "explicit",
positions: { runtime: { x: 420, y: 180 }, client: { x: 100, y: 50 } },
},
nodes: [
{ id: "client", label: "Client", summary: "caller", kind: "actor" },
{ id: "runtime", label: "Runtime", summary: "server", kind: "runtime" },
],
edges: [{ id: "e-explicit", from: "client", to: "runtime" }],
};
const explicitFigureMissingPosition: FigureDefinition = {
id: "explicit-missing",
title: "Explicit Missing",
layout: {
kind: "explicit",
positions: { client: { x: 100, y: 50 } },
},
nodes: [
{ id: "client", label: "Client", summary: "caller", kind: "actor" },
{ id: "runtime", label: "Runtime", summary: "server", kind: "runtime" },
],
edges: [{ id: "e-explicit-missing", from: "client", to: "runtime" }],
};
@@ -0,0 +1,71 @@
import Dagre from "@dagrejs/dagre";
import type {
FigureDefinition,
FigureEdgeDefinition,
FigureNodeDefinition,
} from "./model.js";
export type PositionedFigureNode = FigureNodeDefinition & {
readonly position: { readonly x: number; readonly y: number };
};
export type PositionedFigure = {
readonly definition: FigureDefinition;
readonly nodes: readonly PositionedFigureNode[];
readonly edges: readonly FigureEdgeDefinition[];
};
const NODE_WIDTH = 196;
const NODE_HEIGHT = 84;
const NODESEP = 56;
const RANKSEP = 88;
export const layoutFigure = (figure: FigureDefinition): PositionedFigure => {
if (figure.layout.kind === "explicit") {
const { positions } = figure.layout;
const nodes: PositionedFigureNode[] = figure.nodes.map((node) => {
const pos = positions[node.id];
if (!pos) {
throw new Error(`missing_explicit_position:${node.id}`);
}
return { ...node, position: pos };
});
return { definition: figure, nodes, edges: figure.edges };
}
return layoutDagre(figure);
};
const layoutDagre = (figure: FigureDefinition): PositionedFigure => {
const g = new Dagre.graphlib.Graph();
g.setGraph({
rankdir: figure.layout.kind === "flow" ? "LR" : "TB",
nodesep: NODESEP,
ranksep: RANKSEP,
});
g.setDefaultEdgeLabel(() => ({}));
const sortedNodes = [...figure.nodes].sort((a, b) => a.id.localeCompare(b.id));
for (const node of sortedNodes) {
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
}
const sortedEdges = [...figure.edges].sort((a, b) => a.id.localeCompare(b.id));
for (const edge of sortedEdges) {
g.setEdge(edge.from, edge.to);
}
Dagre.layout(g);
const nodes: PositionedFigureNode[] = sortedNodes.map((node) => {
const dagreNode = g.node(node.id);
return {
...node,
position: {
x: dagreNode.x - NODE_WIDTH / 2,
y: dagreNode.y - NODE_HEIGHT / 2,
},
};
});
return { definition: figure, nodes, edges: figure.edges };
};
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { nextFigureNodeId, type FigureDirection } from "./navigation.js";
import type { PositionedFigure } from "./layout.js";
import { navigationLayout, tiedNavigationLayout } from "./test-fixtures.js";
import { layoutFigure } from "./layout.js";
const layoutCache = new Map<string, PositionedFigure>();
const getLayout = (figure: import("./model.js").FigureDefinition): PositionedFigure => {
let cached = layoutCache.get(figure.id);
if (!cached) {
cached = layoutFigure(figure);
layoutCache.set(figure.id, cached);
}
return cached;
};
describe("nextFigureNodeId", () => {
it.each([
["ArrowRight", "left", "right"],
["ArrowLeft", "right", "left"],
["ArrowDown", "top", "bottom"],
["ArrowUp", "bottom", "top"],
] as const)("moves %s spatially", (direction, start, expected) => {
const layout = getLayout(navigationLayout);
expect(nextFigureNodeId(layout, start, direction)).toBe(expected);
});
it("keeps focus when no node exists in that direction", () => {
const layout = getLayout(navigationLayout);
expect(nextFigureNodeId(layout, "left", "ArrowLeft")).toBe("left");
});
it("keeps an unknown current node unchanged", () => {
const layout = getLayout(navigationLayout);
expect(nextFigureNodeId(layout, "missing", "ArrowRight"))
.toBe("missing");
});
it("breaks equally distant candidates by node id", () => {
const layout = getLayout(tiedNavigationLayout);
expect(nextFigureNodeId(layout, "start", "ArrowRight"))
.toBe("alpha");
});
});
@@ -0,0 +1,35 @@
import type { PositionedFigure } from "./layout.js";
export type FigureDirection = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight";
export const nextFigureNodeId = (
figure: PositionedFigure,
currentNodeId: string,
direction: FigureDirection,
): string => {
const current = figure.nodes.find((node) => node.id === currentNodeId);
if (!current) return currentNodeId;
const horizontal = direction === "ArrowLeft" || direction === "ArrowRight";
const sign = direction === "ArrowLeft" || direction === "ArrowUp" ? -1 : 1;
const candidates = figure.nodes
.filter((node) => node.id !== currentNodeId)
.map((node) => {
const dx = node.position.x - current.position.x;
const dy = node.position.y - current.position.y;
return {
id: node.id,
primary: horizontal ? dx * sign : dy * sign,
secondary: Math.abs(horizontal ? dy : dx),
};
})
.filter((candidate) => candidate.primary > 0)
.sort((left, right) =>
left.primary - right.primary ||
left.secondary - right.secondary ||
left.id.localeCompare(right.id),
);
return candidates[0]?.id ?? currentNodeId;
};
@@ -217,10 +217,10 @@ export const navigationLayout: FigureDefinition = {
layout: {
kind: "explicit",
positions: {
left: { x: 0, y: 100 },
right: { x: 400, y: 100 },
top: { x: 200, y: 0 },
bottom: { x: 200, y: 200 },
left: { x: 0, y: 0 },
right: { x: 200, y: 0 },
top: { x: 0, y: 300 },
bottom: { x: 0, y: 500 },
},
},
nodes: [