diff --git a/src/plugins/layered/converters/layeredConverter.ts b/src/plugins/layered/converters/layeredConverter.ts index 67c7c5a4..8d2cc40d 100644 --- a/src/plugins/layered/converters/layeredConverter.ts +++ b/src/plugins/layered/converters/layeredConverter.ts @@ -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[]) { @@ -19,15 +19,15 @@ function buildAdjacency(edges: Edge[]) { function getVirtualNodeCenter( nodePositions: Map, - 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, }; }; } @@ -76,6 +76,9 @@ export type LayeredConverterParams = { /** Map of "sourceId/targetId" -> queue of connection ids (for multiple edges between same pair) */ connectionIdBySourceTarget: Map; blockSizes: Map; + virtualNodeWidth?: number; + virtualNodeHeight?: number; + /** @deprecated Use virtualNodeWidth and virtualNodeHeight instead. */ virtualNodeSize?: number; }; @@ -88,6 +91,8 @@ export function layeredConverter({ layoutResult, connectionIdBySourceTarget, blockSizes, + virtualNodeWidth, + virtualNodeHeight, virtualNodeSize, }: LayeredConverterParams): ConverterResult { const { nodes, edges } = layoutResult; @@ -115,7 +120,11 @@ export function layeredConverter({ const edgesResult: ConverterResult["edges"] = {}; const adjacency = buildAdjacency(edges); const visitedEdges = new Set(); - 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); diff --git a/src/plugins/layered/hooks/useLayeredLayout.integration.test.ts b/src/plugins/layered/hooks/useLayeredLayout.integration.test.ts new file mode 100644 index 00000000..7af82bbe --- /dev/null +++ b/src/plugins/layered/hooks/useLayeredLayout.integration.test.ts @@ -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); + }); +}); diff --git a/src/plugins/layered/hooks/useLayeredLayout.ts b/src/plugins/layered/hooks/useLayeredLayout.ts index 22d15849..606c327c 100644 --- a/src/plugins/layered/hooks/useLayeredLayout.ts +++ b/src/plugins/layered/hooks/useLayeredLayout.ts @@ -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"; @@ -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); diff --git a/src/plugins/layered/layout.test.ts b/src/plugins/layered/layout.test.ts new file mode 100644 index 00000000..83e43212 --- /dev/null +++ b/src/plugins/layered/layout.test.ts @@ -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[] = [ + { id: "wide", level: 0, width: 900, height: 100 }, + { id: "next", level: 1, width: 100, height: 100 }, + ]; + const edges: Edge[] = [{ 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 }, + ]); + }); +}); diff --git a/src/plugins/layered/layout.ts b/src/plugins/layered/layout.ts index acac9ed2..0ad24675 100644 --- a/src/plugins/layered/layout.ts +++ b/src/plugins/layered/layout.ts @@ -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( @@ -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]; @@ -551,10 +555,25 @@ function findSmallestHeightAlignment( ).align; } -function alignCoordinates(xss: Record>, alignTo: Record) { - 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, 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>, + alignTo: Record, + opts: ResolvedLayoutOptions +) { + const alignToBounds = getVerticalBounds(graph, alignTo, opts); (["u", "d"] as const).forEach((vert) => { (["l", "r"] as const).forEach((horiz) => { @@ -564,11 +583,8 @@ function alignCoordinates(xss: Record>, 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) => { @@ -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); } @@ -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; @@ -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>(graph: Graph) { +function prepareResult>(graph: Graph, opts: ResolvedLayoutOptions) { const nodes: T[] = []; const edges: Edge[] = []; Object.values(graph).forEach((graphNode) => { @@ -720,7 +740,13 @@ function prepareResult>(graph: Graph) }); } }); - 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> = { @@ -741,5 +767,5 @@ export async function layoutGraph(graph); + return prepareResult(graph, opts); } diff --git a/src/plugins/layered/types/index.ts b/src/plugins/layered/types/index.ts index 125c84e4..d967318a 100644 --- a/src/plugins/layered/types/index.ts +++ b/src/plugins/layered/types/index.ts @@ -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; diff --git a/src/stories/plugins/layered/config.ts b/src/stories/plugins/layered/config.ts index 804b9de3..43a9e3db 100644 --- a/src/stories/plugins/layered/config.ts +++ b/src/stories/plugins/layered/config.ts @@ -315,3 +315,26 @@ export const layeredConfig: LayeredLayoutInput = { { sourceBlockId: "381", targetBlockId: "379" }, ], }; + +export const variableSizesConfig: LayeredLayoutInput = { + blocks: [ + { id: "180 × 140", level: 0, width: 180, height: 140 }, + { id: "300 × 220", level: 1, width: 300, height: 220 }, + { id: "220 × 160", level: 2, width: 220, height: 160 }, + { id: "260 × 180", level: 3, width: 260, height: 180 }, + { id: "160 × 180", level: 0, width: 160, height: 180 }, + { id: "140 × 340", level: 1, width: 140, height: 340 }, + { id: "220 × 140", level: 1, width: 220, height: 140 }, + { id: "260 × 180 merge", level: 2, width: 260, height: 180 }, + ], + connections: [ + { sourceBlockId: "180 × 140", targetBlockId: "300 × 220" }, + { sourceBlockId: "300 × 220", targetBlockId: "220 × 160" }, + { sourceBlockId: "220 × 160", targetBlockId: "260 × 180" }, + { sourceBlockId: "160 × 180", targetBlockId: "140 × 340" }, + { sourceBlockId: "160 × 180", targetBlockId: "220 × 140" }, + { sourceBlockId: "140 × 340", targetBlockId: "260 × 180 merge" }, + { sourceBlockId: "220 × 140", targetBlockId: "260 × 180 merge" }, + { sourceBlockId: "160 × 180", targetBlockId: "260 × 180 merge" }, + ], +}; diff --git a/src/stories/plugins/layered/layered.stories.tsx b/src/stories/plugins/layered/layered.stories.tsx index 7b33051e..6ba1514c 100644 --- a/src/stories/plugins/layered/layered.stories.tsx +++ b/src/stories/plugins/layered/layered.stories.tsx @@ -6,13 +6,13 @@ import type { Meta, StoryFn } from "@storybook/react-webpack5"; import type { TMultipointConnection } from "../../../components/canvas/connections/types"; import { BezierMultipointConnection, Graph, GraphState, TBlock, TConnection } from "../../../index"; -import type { LayeredLayoutOptions } from "../../../plugins/layered"; +import type { LayeredLayoutInput, LayeredLayoutOptions } from "../../../plugins/layered"; import { useLayeredLayout } from "../../../plugins/layered"; import { GraphCanvas, useGraph, useGraphEvent } from "../../../react-components"; import { useFn } from "../../../react-components/utils/hooks/useFn"; import { BlockStory } from "../../main/Block"; -import { layeredConfig } from "./config"; +import { layeredConfig, variableSizesConfig } from "./config"; import "@gravity-ui/uikit/styles/styles.css"; @@ -24,7 +24,11 @@ export interface GraphAppLayoutArgs { defaultNodeHeight?: number; } -const GraphApp = (args: GraphAppLayoutArgs = {}) => { +type GraphAppProps = GraphAppLayoutArgs & { + config?: LayeredLayoutInput; +}; + +const GraphApp = ({ config = layeredConfig, ...args }: GraphAppProps = {}) => { const layoutOptions = useMemo(() => { const opts: LayeredLayoutOptions = { ...(args.nodeHorizontalGap !== undefined && { nodeHorizontalGap: args.nodeHorizontalGap }), @@ -49,29 +53,30 @@ const GraphApp = (args: GraphAppLayoutArgs = {}) => { }); const { isLoading, result } = useLayeredLayout({ - ...layeredConfig, + ...config, layoutOptions, }); useEffect(() => { if (isLoading || !result) return; - const connections = layeredConfig.connections.reduce< - (TConnection & Pick)[] - >((acc, connection) => { - const id = connection.id ?? `${String(connection.sourceBlockId)}/${String(connection.targetBlockId)}`; - if (id in result.edges) { - acc.push({ - id, - sourceBlockId: connection.sourceBlockId, - targetBlockId: connection.targetBlockId, - ...result.edges[id], - }); - } - return acc; - }, []); - - const blocks = layeredConfig.blocks.map((block) => ({ + const connections = config.connections.reduce<(TConnection & Pick)[]>( + (acc, connection) => { + const id = connection.id ?? `${String(connection.sourceBlockId)}/${String(connection.targetBlockId)}`; + if (id in result.edges) { + acc.push({ + id, + sourceBlockId: connection.sourceBlockId, + targetBlockId: connection.targetBlockId, + ...result.edges[id], + }); + } + return acc; + }, + [] + ); + + const blocks = config.blocks.map((block) => ({ ...block, ...result.blocks[block.id], name: block.id.toString(), @@ -84,7 +89,7 @@ const GraphApp = (args: GraphAppLayoutArgs = {}) => { }); graph.zoomTo("center", { padding: 300 }); - }, [isLoading, result]); + }, [config, graph, isLoading, result, setEntities]); useGraphEvent(graph, "state-change", ({ state }) => { if (state === GraphState.ATTACHED) { @@ -133,8 +138,8 @@ const meta: Meta = { "- `isLoading`: Boolean indicating if the layout computation is in progress\n" + "- `result`: Object with `blocks` (positions by id) and `edges` (points/labels by connection id)\n\n" + "## Layout Options\n\n" + - "- `nodeHorizontalGap`: Horizontal gap between nodes in the same layer (default: 2× defaultNodeWidth)\n" + - "- `nodeVerticalGap`: Vertical gap between adjacent layers (default: 200)\n" + + "- `nodeHorizontalGap`: Horizontal gap between adjacent layers/columns (default: 2× defaultNodeWidth)\n" + + "- `nodeVerticalGap`: Vertical gap between nodes in the same layer/column (default: 200)\n" + "- `defaultNodeWidth` / `defaultNodeHeight`: Default node size when not provided (default: 100)\n" + "- `layerSpacingFactor`: Multiplier for spacing between layers (default: 1.7)\n\n" + "## Example\n\n" + @@ -158,11 +163,11 @@ const meta: Meta = { argTypes: { nodeHorizontalGap: { control: { type: "number", min: 0, max: 300, step: 10 }, - description: "Horizontal gap between nodes in the same layer", + description: "Horizontal gap between adjacent layers/columns", }, nodeVerticalGap: { control: { type: "number", min: 0, max: 400, step: 10 }, - description: "Vertical gap between adjacent layers", + description: "Vertical gap between nodes in the same layer/column", }, layerSpacingFactor: { control: { type: "number", min: 0.5, max: 4, step: 0.1 }, @@ -182,3 +187,5 @@ const meta: Meta = { export default meta; export const Default: StoryFn = (args) => ; + +export const VariableSizes: StoryFn = (args) => ;