Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions src/plugins/layered/converters/layeredConverter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TPoint } from "../../../utils/types/shapes";
import { DEFAULT_NODE_WIDTH, Edge } from "../layout";
import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH, Edge } from "../layout";
import { ConverterResult } from "../types";

function buildAdjacency(edges: Edge<string>[]) {
Expand All @@ -19,15 +19,15 @@ function buildAdjacency(edges: Edge<string>[]) {

function getVirtualNodeCenter(
nodePositions: Map<string, TPoint>,
virtualNodeSize?: number
virtualNodeWidth: number,
virtualNodeHeight: number
): (id: string) => TPoint | undefined {
return (id: string) => {
const pos = nodePositions.get(id);
if (!pos) return undefined;
const size = virtualNodeSize ?? DEFAULT_NODE_WIDTH;
return {
x: pos.x + size / 2,
y: pos.y + size / 2,
x: pos.x + virtualNodeWidth / 2,
y: pos.y + virtualNodeHeight / 2,
};
};
}
Expand Down Expand Up @@ -76,6 +76,9 @@ export type LayeredConverterParams = {
/** Map of "sourceId/targetId" -> queue of connection ids (for multiple edges between same pair) */
connectionIdBySourceTarget: Map<string, (string | number | symbol)[]>;
blockSizes: Map<string, { width: number; height: number }>;
virtualNodeWidth?: number;
virtualNodeHeight?: number;
/** @deprecated Use virtualNodeWidth and virtualNodeHeight instead. */
virtualNodeSize?: number;
};

Expand All @@ -88,6 +91,8 @@ export function layeredConverter({
layoutResult,
connectionIdBySourceTarget,
blockSizes,
virtualNodeWidth,
virtualNodeHeight,
virtualNodeSize,
}: LayeredConverterParams): ConverterResult {
const { nodes, edges } = layoutResult;
Expand Down Expand Up @@ -115,7 +120,11 @@ export function layeredConverter({
const edgesResult: ConverterResult["edges"] = {};
const adjacency = buildAdjacency(edges);
const visitedEdges = new Set<string>();
const getVirtualCenter = getVirtualNodeCenter(nodePositions, virtualNodeSize);
const getVirtualCenter = getVirtualNodeCenter(
nodePositions,
virtualNodeWidth ?? virtualNodeSize ?? DEFAULT_NODE_WIDTH,
virtualNodeHeight ?? virtualNodeSize ?? DEFAULT_NODE_HEIGHT
);

for (const edge of edges) {
const from = String(edge.from);
Expand Down
61 changes: 61 additions & 0 deletions src/plugins/layered/hooks/useLayeredLayout.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { renderHook, waitFor } from "@testing-library/react";

import { useLayeredLayout } from "./useLayeredLayout";

describe("useLayeredLayout integration", () => {
it("routes a differently sized chain through the block centers", async () => {
const blocks = [
{ id: "a", width: 100, height: 300 },
{ id: "b", width: 180, height: 100 },
{ id: "c", width: 120, height: 200 },
];
const connections = [
{ id: "a-b", sourceBlockId: "a", targetBlockId: "b" },
{ id: "b-c", sourceBlockId: "b", targetBlockId: "c" },
];
const { result } = renderHook(() => useLayeredLayout({ blocks, connections }));

await waitFor(() => expect(result.current.isLoading).toBe(false));

const layout = result.current.result;
expect(layout).not.toBeNull();
if (!layout) return;
const center = layout.blocks.a.y + blocks[0].height / 2;
expect(layout.blocks.b.y + blocks[1].height / 2).toBe(center);
expect(layout.blocks.c.y + blocks[2].height / 2).toBe(center);
expect(layout.edges["a-b"].points?.map((point) => point.y)).toEqual([center, center]);
expect(layout.edges["b-c"].points?.map((point) => point.y)).toEqual([center, center]);
});

it("aligns block and edge centers with custom default virtual-node sizes", async () => {
const blocks = [
{ id: "source", level: 0, width: 80, height: 240 },
{ id: "middle", level: 1, width: 100, height: 100 },
{ id: "target", level: 2, width: 160, height: 40 },
];
const connections = [
{ id: "short", sourceBlockId: "source", targetBlockId: "middle" },
{ id: "long", sourceBlockId: "source", targetBlockId: "target" },
];
const layoutOptions = { defaultNodeWidth: 60, defaultNodeHeight: 30 };
const { result } = renderHook(() =>
useLayeredLayout({
blocks,
connections,
layoutOptions,
})
);

await waitFor(() => expect(result.current.isLoading).toBe(false));

const layout = result.current.result;
expect(layout).not.toBeNull();
if (!layout) return;
const targetCenter = layout.blocks.target.y + blocks[2].height / 2;
const longEdgePoints = layout.edges.long.points;
expect(longEdgePoints).toBeDefined();
if (!longEdgePoints) return;
expect(longEdgePoints[longEdgePoints.length - 1].y).toBe(targetCenter);
expect(longEdgePoints[longEdgePoints.length - 2].y).toBe(targetCenter);
});
});
4 changes: 3 additions & 1 deletion src/plugins/layered/hooks/useLayeredLayout.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";

import { layeredConverter } from "../converters/layeredConverter";
import { Node, layoutGraph } from "../layout";
import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH, Node, layoutGraph } from "../layout";
import type { ConverterResult, LayeredLayoutInput, LayeredLayoutOptions } from "../types";
import { computeLevels } from "../utils/computeLevels";

Expand Down Expand Up @@ -86,6 +86,8 @@ export function useLayeredLayout(params: UseLayeredLayoutParams) {
Array.from(connectionIdBySourceTarget.entries()).map(([k, v]) => [k, [...v]])
),
blockSizes,
virtualNodeWidth: layoutOptions?.defaultNodeWidth ?? DEFAULT_NODE_WIDTH,
virtualNodeHeight: layoutOptions?.defaultNodeHeight ?? DEFAULT_NODE_HEIGHT,
});
setResult(converted);
setIsLoading(false);
Expand Down
73 changes: 73 additions & 0 deletions src/plugins/layered/layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Edge, Node, layoutGraph } from "./layout";

describe("layoutGraph node sizes", () => {
it("keeps differently sized nodes in a chain aligned by their centers", async () => {
const result = await layoutGraph({
nodes: [
{ id: "a", level: 0, width: 100, height: 300 },
{ id: "b", level: 1, width: 100, height: 100 },
{ id: "c", level: 2, width: 100, height: 200 },
],
edges: [
{ from: "a", to: "b" },
{ from: "b", to: "c" },
],
});

const centers = result.nodes.map((node) => (node.y ?? 0) + (node.height ?? 100) / 2);
expect(new Set(centers).size).toBe(1);
});

it.each([0, 40])("keeps a vertical gap of %s between differently sized nodes", async (gap) => {
const result = await layoutGraph({
nodes: [
{ id: "root", level: 0, width: 100, height: 100 },
{ id: "tall", level: 1, width: 100, height: 900 },
{ id: "short", level: 1, width: 100, height: 100 },
],
edges: [
{ from: "root", to: "tall" },
{ from: "root", to: "short" },
],
options: { nodeVerticalGap: gap },
});

const [first, second] = result.nodes.filter((node) => node.level === 1).sort((a, b) => (a.y ?? 0) - (b.y ?? 0));
expect(second.y).toBe((first.y ?? 0) + (first.height ?? 100) + gap);
});

it("leaves enough horizontal space after a wide layer", async () => {
const nodes: Node<string>[] = [
{ id: "wide", level: 0, width: 900, height: 100 },
{ id: "next", level: 1, width: 100, height: 100 },
];
const edges: Edge<string>[] = [{ from: "wide", to: "next" }];
const result = await layoutGraph({
nodes,
edges,
options: { nodeHorizontalGap: 20, layerSpacingFactor: 0.5 },
});

const wide = result.nodes.find((node) => node.id === "wide");
const next = result.nodes.find((node) => node.id === "next");
expect(wide).toBeDefined();
expect(next).toBeDefined();
if (!wide || !next) return;
expect(next.x).toBe((wide.x ?? 0) + (wide.width ?? 100) + 20);
});

it("preserves the previous geometry for standard-sized nodes", async () => {
const result = await layoutGraph({
nodes: [
{ id: "a", level: 0, width: 100, height: 100 },
{ id: "b", level: 1, width: 100, height: 100 },
],
edges: [{ from: "a", to: "b" }],
});

expect(result.nodes).toEqual([
{ id: "a", level: 0, width: 100, height: 100, x: 0, y: 0 },
{ id: "b", level: 1, width: 100, height: 100, x: 510, y: 0 },
]);
});
});
76 changes: 51 additions & 25 deletions src/plugins/layered/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,18 +448,20 @@ function resolveLayoutOptions(options?: LayeredLayoutOptions): ResolvedLayoutOpt

function nodeWidth(graph: Graph | undefined, v: ID | undefined, opts: ResolvedLayoutOptions): number {
if (graph && v && graph[v]) {
const width = graph[v].node.width ?? opts.defaultNodeWidth;
return width + opts.nodeHorizontalGap;
return graph[v].node.width ?? opts.defaultNodeWidth;
}
return opts.defaultNodeWidth + opts.nodeHorizontalGap;
return opts.defaultNodeWidth;
}

function nodeHeight(graph: Graph | undefined, v: ID | undefined, opts: ResolvedLayoutOptions): number {
if (graph && v && graph[v]) {
const height = graph[v].node.height ?? opts.defaultNodeHeight;
return height + opts.nodeVerticalGap;
return graph[v].node.height ?? opts.defaultNodeHeight;
}
return opts.defaultNodeHeight + opts.nodeVerticalGap;
return opts.defaultNodeHeight;
}

function nodeVerticalDistance(graph: Graph, a: ID, b: ID, opts: ResolvedLayoutOptions): number {
return nodeHeight(graph, a, opts) / 2 + opts.nodeVerticalGap + nodeHeight(graph, b, opts) / 2;
}

function horizontalCompaction(
Expand Down Expand Up @@ -493,15 +495,17 @@ function horizontalCompaction(
const rank = reverse ? layering.length - graph[w].rank! - 1 : graph[w].rank!;
const pos = layering[rank].indexOf(w);
if (pos > 0) {
const u = root[layering[rank][pos - 1]];
const previous = layering[rank][pos - 1];
const u = root[previous];
const distance = nodeVerticalDistance(graph, previous, w, opts);
placeBlock(u);
if (sink[v] === v) {
sink[v] = sink[u];
}
if (sink[v] === sink[u]) {
xs[v] = Math.max(xs[v], xs[u] + nodeHeight(graph, v, opts));
xs[v] = Math.max(xs[v], xs[u] + distance);
} else {
shift[sink[u]] = Math.min(shift[sink[u]], xs[v] - xs[u] - nodeHeight(graph, v, opts));
shift[sink[u]] = Math.min(shift[sink[u]], xs[v] - xs[u] - distance);
}
}
w = align[w];
Expand Down Expand Up @@ -551,10 +555,25 @@ function findSmallestHeightAlignment(
).align;
}

function alignCoordinates(xss: Record<string, Record<string, number>>, alignTo: Record<string, number>) {
const alignToVals = Object.values(alignTo);
const alignToMin = alignToVals.reduce((a, b) => Math.min(a, b));
const alignToMax = alignToVals.reduce((a, b) => Math.max(a, b));
function getVerticalBounds(graph: Graph, xs: Record<string, number>, opts: ResolvedLayoutOptions) {
return Object.entries(xs).reduce(
(bounds, [v, center]) => {
const halfHeight = nodeHeight(graph, v as ID, opts) / 2;
bounds.min = Math.min(bounds.min, center - halfHeight);
bounds.max = Math.max(bounds.max, center + halfHeight);
return bounds;
},
{ min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY }
);
}

function alignCoordinates(
graph: Graph,
xss: Record<string, Record<string, number>>,
alignTo: Record<string, number>,
opts: ResolvedLayoutOptions
) {
const alignToBounds = getVerticalBounds(graph, alignTo, opts);

(["u", "d"] as const).forEach((vert) => {
(["l", "r"] as const).forEach((horiz) => {
Expand All @@ -564,11 +583,8 @@ function alignCoordinates(xss: Record<string, Record<string, number>>, alignTo:
return;
}

const xsVals = Object.values(xs);
const delta =
horiz === "l"
? alignToMin - xsVals.reduce((a, b) => Math.min(a, b))
: alignToMax - xsVals.reduce((a, b) => Math.max(a, b));
const bounds = getVerticalBounds(graph, xs, opts);
const delta = horiz === "l" ? alignToBounds.min - bounds.min : alignToBounds.max - bounds.max;

if (delta) {
Object.keys(xs).forEach((key) => {
Expand Down Expand Up @@ -637,7 +653,7 @@ function positionY(graph: Graph, layering: ID[][], opts: ResolvedLayoutOptions):
});
});
const smallestHeight = findSmallestHeightAlignment(graph, xss, opts);
alignCoordinates(xss, smallestHeight);
alignCoordinates(graph, xss, smallestHeight, opts);
return balance(xss);
}

Expand All @@ -648,9 +664,9 @@ function position(graph: Graph, order: ID[][], opts: ResolvedLayoutOptions): voi
});

const valueY = Object.values(ys);
const step =
const baseStep =
Math.max(
nodeWidth(undefined, undefined, opts),
nodeWidth(undefined, undefined, opts) + opts.nodeHorizontalGap,
(valueY.reduce((a, b) => Math.max(a, b)) - valueY.reduce((a, b) => Math.min(a, b))) / order.length
) * opts.layerSpacingFactor;
let x = 0;
Expand All @@ -659,11 +675,15 @@ function position(graph: Graph, order: ID[][], opts: ResolvedLayoutOptions): voi
const node = graph[nodeId].node;
node.x = x;
});
x += step;
const layerWidth = layer.reduce(
(maxWidth, nodeId) => Math.max(maxWidth, nodeWidth(graph, nodeId, opts)),
opts.defaultNodeWidth
);
x += Math.max(baseStep, layerWidth + opts.nodeHorizontalGap);
});
}

function prepareResult<NodeId, T extends Node<NodeId>>(graph: Graph<NodeId, T>) {
function prepareResult<NodeId, T extends Node<NodeId>>(graph: Graph<NodeId, T>, opts: ResolvedLayoutOptions) {
const nodes: T[] = [];
const edges: Edge<NodeId>[] = [];
Object.values(graph).forEach((graphNode) => {
Expand Down Expand Up @@ -720,7 +740,13 @@ function prepareResult<NodeId, T extends Node<NodeId>>(graph: Graph<NodeId, T>)
});
}
});
return { nodes, edges };
return {
nodes: nodes.map((node) => ({
...node,
y: (node.y ?? 0) - (node.height ?? opts.defaultNodeHeight) / 2 + opts.defaultNodeHeight / 2,
})),
edges,
};
}

export type LayoutGraphParams<NodeId extends string | number, T extends Node<NodeId>> = {
Expand All @@ -741,5 +767,5 @@ export async function layoutGraph<NodeId extends string | number, T extends Node
prepareGraph(graph as GraphInternal, layering, levels);
const order = ordering(graph as GraphInternal, layering, enormousGraph);
position(graph as GraphInternal, order, opts);
return prepareResult<NodeId, T>(graph);
return prepareResult<NodeId, T>(graph, opts);
}
4 changes: 2 additions & 2 deletions src/plugins/layered/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ export type LayeredLayoutInput = {
* All fields are optional; defaults match previous hardcoded behavior.
*/
export type LayeredLayoutOptions = {
/** Horizontal gap between nodes in the same layer. Default: defaultNodeWidth * 2 */
/** Horizontal gap between adjacent layers (columns). Default: defaultNodeWidth * 2 */
nodeHorizontalGap?: number;
/** Vertical gap between nodes in adjacent layers. Default: 200 */
/** Vertical gap between nodes in the same layer (column). Default: 200 */
nodeVerticalGap?: number;
/** Default node width when not provided. Default: 100 */
defaultNodeWidth?: number;
Expand Down
Loading
Loading