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: {
|
layout: {
|
||||||
kind: "explicit",
|
kind: "explicit",
|
||||||
positions: {
|
positions: {
|
||||||
left: { x: 0, y: 100 },
|
left: { x: 0, y: 0 },
|
||||||
right: { x: 400, y: 100 },
|
right: { x: 200, y: 0 },
|
||||||
top: { x: 200, y: 0 },
|
top: { x: 0, y: 300 },
|
||||||
bottom: { x: 200, y: 200 },
|
bottom: { x: 0, y: 500 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
nodes: [
|
nodes: [
|
||||||
|
|||||||
Reference in New Issue
Block a user