Skip to content

Commit 4dc63b3

Browse files
Merge pull request #25 from 0xPlaygrounds/ryz-1562-per-group-direction
feat(canvas-layout-ts): per-group direction override
2 parents e7f103b + bce4c87 commit 4dc63b3

6 files changed

Lines changed: 193 additions & 24 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ryzome-ai/canvas-layout-ts": minor
3+
---
4+
5+
Add `rectpacking` layout algorithm alongside the existing `layered` flow. Configurable at the root (`LayoutOptions.algorithm`, `LayoutOptions.aspectRatio`) and per-group (`LayoutGroupInput.algorithm`, `LayoutGroupInput.aspectRatio`). Groups whose algorithm or direction differs from the root are isolated via `SEPARATE_CHILDREN` so the override actually applies.

packages/canvas-layout-ts/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ryzome-ai/canvas-layout-ts",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Ryzome canvas layout — compound-graph placement for nodes, edges, and groups via elkjs",
55
"license": "MIT",
66
"type": "module",

packages/canvas-layout-ts/src/__tests__/layout.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,85 @@ describe("computeCanvasLayout", () => {
204204
expect(result.nodes.a.y).toBeLessThan(result.nodes.b.y);
205205
});
206206

207+
it("lays out a group's members along its direction override", async () => {
208+
// Horizontal root direction, but g1 asks for DOWN internally.
209+
const result = await computeCanvasLayout(
210+
{
211+
nodes: [
212+
{ id: "a", group: "g1" },
213+
{ id: "b", group: "g1", dependsOn: ["a"] },
214+
{ id: "c", group: "g1", dependsOn: ["b"] },
215+
],
216+
groups: [{ id: "g1", direction: "DOWN" }],
217+
},
218+
{ direction: "RIGHT" },
219+
);
220+
221+
const { a, b, c } = result.nodes;
222+
// DOWN inside g1 -> successor y strictly greater, x roughly equal.
223+
expect(a.y).toBeLessThan(b.y);
224+
expect(b.y).toBeLessThan(c.y);
225+
expect(Math.abs(a.x - b.x)).toBeLessThan(10);
226+
expect(Math.abs(b.x - c.x)).toBeLessThan(10);
227+
});
228+
229+
it("packs edgeless nodes into a rectangle when algorithm is rectpacking", async () => {
230+
const nodes = Array.from({ length: 30 }, (_, i) => ({ id: `n${i}` }));
231+
const result = await computeCanvasLayout(
232+
{ nodes },
233+
{ algorithm: "rectpacking", aspectRatio: 1.6 },
234+
);
235+
236+
const rects = Object.values(result.nodes);
237+
expect(rects).toHaveLength(30);
238+
// No overlaps.
239+
for (let i = 0; i < rects.length; i++) {
240+
for (let j = i + 1; j < rects.length; j++) {
241+
expect(rectsOverlap(rects[i], rects[j])).toBe(false);
242+
}
243+
}
244+
// With 30 identical 320x180 nodes and aspectRatio 1.6, rectpacking
245+
// should produce a rough rectangle — nowhere near a single row or
246+
// single column. Bounding-box width and height should both be
247+
// meaningfully non-zero.
248+
const minX = Math.min(...rects.map((r) => r.x));
249+
const maxX = Math.max(...rects.map((r) => r.x + r.width));
250+
const minY = Math.min(...rects.map((r) => r.y));
251+
const maxY = Math.max(...rects.map((r) => r.y + r.height));
252+
const boxWidth = maxX - minX;
253+
const boxHeight = maxY - minY;
254+
expect(boxWidth).toBeGreaterThan(500);
255+
expect(boxHeight).toBeGreaterThan(500);
256+
});
257+
258+
it("packs a group's edgeless members with rectpacking while the root stays layered", async () => {
259+
// 12 edgeless members inside g1 (rectpacking), plus a free node outside.
260+
const members = Array.from({ length: 12 }, (_, i) => ({
261+
id: `m${i}`,
262+
group: "g1",
263+
}));
264+
const result = await computeCanvasLayout({
265+
nodes: [...members, { id: "free" }],
266+
groups: [{ id: "g1", algorithm: "rectpacking", aspectRatio: 1.6 }],
267+
});
268+
269+
const memberRects = members.map((m) => result.nodes[m.id]);
270+
// Every member nested inside g1's rect.
271+
for (const rect of memberRects) {
272+
expect(rectContains(result.groups.g1, rect, 1)).toBe(true);
273+
}
274+
// Members packed into a rectangle — both dimensions should be
275+
// substantially > the width of a single node.
276+
const minX = Math.min(...memberRects.map((r) => r.x));
277+
const maxX = Math.max(...memberRects.map((r) => r.x + r.width));
278+
const minY = Math.min(...memberRects.map((r) => r.y));
279+
const maxY = Math.max(...memberRects.map((r) => r.y + r.height));
280+
expect(maxX - minX).toBeGreaterThan(400);
281+
expect(maxY - minY).toBeGreaterThan(200);
282+
// Free node must not overlap g1.
283+
expect(rectsOverlap(result.groups.g1, result.nodes.free, 1)).toBe(false);
284+
});
285+
207286
it("handles a 50-node DAG with groups without overlaps", async () => {
208287
const nodes = Array.from({ length: 50 }, (_, i) => ({
209288
id: `n${i}`,

packages/canvas-layout-ts/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { computeCanvasLayout } from "./layout.js";
22
export type {
3+
LayoutAlgorithm,
34
LayoutDirection,
45
LayoutEdgeInput,
56
LayoutGroupInput,

packages/canvas-layout-ts/src/layout.ts

Lines changed: 73 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import ELK from "elkjs/lib/elk.bundled.js";
2-
import type { ElkNode, ElkExtendedEdge, LayoutOptions as ElkLayoutOptions } from "elkjs/lib/elk-api";
32
import type {
3+
ElkNode,
4+
ElkExtendedEdge,
5+
LayoutOptions as ElkLayoutOptions,
6+
} from "elkjs/lib/elk-api";
7+
import type {
8+
LayoutAlgorithm,
9+
LayoutDirection,
410
LayoutInput,
511
LayoutNodeInput,
612
LayoutOptions,
@@ -20,6 +26,38 @@ const DEFAULT_SPACING = {
2026
const DEFAULT_GROUP_PADDING = 40;
2127
const GROUP_HEADER_PADDING = 60;
2228

29+
/**
30+
* Build ELK layoutOptions for a given algorithm. `rectpacking` and `layered`
31+
* share `elk.spacing.nodeNode` but otherwise take disjoint option keys — ELK
32+
* silently ignores irrelevant keys but we keep the output narrow for clarity.
33+
*/
34+
function buildAlgorithmLayoutOptions(params: {
35+
algorithm: LayoutAlgorithm;
36+
direction: LayoutDirection;
37+
spacing: { nodeNode: number; nodeNodeBetweenLayers: number; edgeNode: number };
38+
aspectRatio?: number;
39+
includeHierarchyHandling?: "INCLUDE_CHILDREN" | "SEPARATE_CHILDREN";
40+
}): ElkLayoutOptions {
41+
const options: ElkLayoutOptions = {
42+
"elk.algorithm": params.algorithm,
43+
"elk.spacing.nodeNode": String(params.spacing.nodeNode),
44+
};
45+
if (params.algorithm === "layered") {
46+
options["elk.direction"] = params.direction;
47+
options["elk.layered.spacing.nodeNodeBetweenLayers"] = String(
48+
params.spacing.nodeNodeBetweenLayers,
49+
);
50+
options["elk.spacing.edgeNode"] = String(params.spacing.edgeNode);
51+
}
52+
if (params.algorithm === "rectpacking" && params.aspectRatio !== undefined) {
53+
options["elk.aspectRatio"] = String(params.aspectRatio);
54+
}
55+
if (params.includeHierarchyHandling) {
56+
options["elk.hierarchyHandling"] = params.includeHierarchyHandling;
57+
}
58+
return options;
59+
}
60+
2361
/**
2462
* Lay out a Ryzome canvas with elkjs. Groups become compound (parent) nodes so
2563
* members stay spatially clustered; the result includes both individual node
@@ -36,6 +74,7 @@ export async function computeCanvasLayout(
3674

3775
const spacing = { ...DEFAULT_SPACING, ...options.spacing };
3876
const direction = options.direction ?? "DOWN";
77+
const algorithm: LayoutAlgorithm = options.algorithm ?? "layered";
3978
const groupPadding = options.groupPadding ?? DEFAULT_GROUP_PADDING;
4079

4180
const measureNode =
@@ -100,36 +139,47 @@ export async function computeCanvasLayout(
100139
if (members.length === 0) continue;
101140

102141
const pad = group.padding ?? groupPadding;
142+
const groupDirection = group.direction ?? direction;
143+
const groupAlgorithm = group.algorithm ?? algorithm;
144+
// When the group's direction or algorithm differs from the root, the
145+
// root's INCLUDE_CHILDREN hierarchy handling would otherwise let the
146+
// parent layout dominate. Force SEPARATE_CHILDREN so the override
147+
// actually takes effect on this group's members.
148+
const needsLocalLayout =
149+
groupDirection !== direction || groupAlgorithm !== algorithm;
150+
const groupLayoutOptions = buildAlgorithmLayoutOptions({
151+
algorithm: groupAlgorithm,
152+
direction: groupDirection,
153+
spacing,
154+
aspectRatio: group.aspectRatio,
155+
includeHierarchyHandling: needsLocalLayout
156+
? "SEPARATE_CHILDREN"
157+
: undefined,
158+
});
159+
groupLayoutOptions["elk.padding"] =
160+
`[top=${GROUP_HEADER_PADDING},left=${pad},bottom=${pad},right=${pad}]`;
103161
const groupNode: ElkNode = {
104162
id: group.id,
105163
children: members.map(makeLeafNode),
106-
layoutOptions: {
107-
"elk.padding": `[top=${GROUP_HEADER_PADDING},left=${pad},bottom=${pad},right=${pad}]`,
108-
// Still layer members inside the group.
109-
"elk.algorithm": "layered",
110-
"elk.direction": direction,
111-
"elk.layered.spacing.nodeNodeBetweenLayers": String(
112-
spacing.nodeNodeBetweenLayers,
113-
),
114-
"elk.spacing.nodeNode": String(spacing.nodeNode),
115-
},
164+
layoutOptions: groupLayoutOptions,
116165
};
117166
rootChildren.push(groupNode);
118167
}
119168

120-
const rootLayoutOptions: ElkLayoutOptions = {
121-
"elk.algorithm": "layered",
122-
"elk.direction": direction,
123-
"elk.layered.spacing.nodeNodeBetweenLayers": String(
124-
spacing.nodeNodeBetweenLayers,
125-
),
126-
"elk.spacing.nodeNode": String(spacing.nodeNode),
127-
"elk.spacing.edgeNode": String(spacing.edgeNode),
128-
// Place disconnected components side-by-side instead of overlapping them at the origin.
129-
"elk.separateConnectedComponents": "true",
169+
const rootLayoutOptions = buildAlgorithmLayoutOptions({
170+
algorithm,
171+
direction,
172+
spacing,
173+
aspectRatio: options.aspectRatio,
130174
// Consider node size when routing; hierarchical edges cross group boundaries.
131-
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
132-
};
175+
// Only meaningful for `layered`.
176+
includeHierarchyHandling:
177+
algorithm === "layered" ? "INCLUDE_CHILDREN" : undefined,
178+
});
179+
if (algorithm === "layered") {
180+
// Place disconnected components side-by-side instead of overlapping at origin.
181+
rootLayoutOptions["elk.separateConnectedComponents"] = "true";
182+
}
133183

134184
const root: ElkNode = {
135185
id: "__root__",

packages/canvas-layout-ts/src/types.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,23 @@ export interface LayoutGroupInput {
1515
id: string;
1616
title?: string;
1717
padding?: number;
18+
/**
19+
* Override the layout direction for this group's members only. When omitted,
20+
* the group inherits the root layout direction. Useful for canvases whose
21+
* subgraphs want different orientations (e.g., a vertical pipeline nested
22+
* inside a horizontally-flowing outer graph).
23+
*/
24+
direction?: LayoutDirection;
25+
/**
26+
* Override the algorithm used inside this group. Set to `"rectpacking"`
27+
* for edgeless clusters (moodboards) while the root stays `"layered"`.
28+
*/
29+
algorithm?: LayoutAlgorithm;
30+
/**
31+
* Target aspect ratio (width / height) forwarded as `elk.aspectRatio`
32+
* when using `rectpacking`. Ignored for `layered`.
33+
*/
34+
aspectRatio?: number;
1835
}
1936

2037
export interface LayoutInput {
@@ -37,6 +54,13 @@ export interface LayoutResult {
3754

3855
export type LayoutDirection = "DOWN" | "RIGHT" | "UP" | "LEFT";
3956

57+
/**
58+
* Supported elkjs algorithms. `layered` is the default Sugiyama-style
59+
* flow layout for DAGs. `rectpacking` packs edgeless or near-edgeless
60+
* inputs into a rectangle — used for moodboard-style clusters.
61+
*/
62+
export type LayoutAlgorithm = "layered" | "rectpacking";
63+
4064
export interface LayoutSpacing {
4165
/** Space between sibling nodes in the same layer. */
4266
nodeNode?: number;
@@ -58,4 +82,14 @@ export interface LayoutOptions {
5882
spacing?: LayoutSpacing;
5983
/** Padding applied around every group's members. Default 40 (60 on top for label room). */
6084
groupPadding?: number;
85+
/**
86+
* Root-level ELK algorithm. Default `"layered"`. Use `"rectpacking"` when
87+
* the input has no (or few) edges and should be packed into a rectangle.
88+
*/
89+
algorithm?: LayoutAlgorithm;
90+
/**
91+
* Target aspect ratio (width / height) forwarded as `elk.aspectRatio` when
92+
* using `rectpacking`. Ignored for `layered`.
93+
*/
94+
aspectRatio?: number;
6195
}

0 commit comments

Comments
 (0)