feat: lay out and navigate presentation figures
This commit is contained in:
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user