chore: import upstream snapshot with attribution
CI / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:51 +08:00
commit 0232b4e2bb
3528 changed files with 291404 additions and 0 deletions
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { definePluginCreator } from '@flowgram.ai/core';
import { AutoLayoutOptions } from './type';
import { AutoLayoutService } from './services';
/**
* Auto layout plugin - 自动布局插件
* https://flowgram.ai/guide/plugin/free-auto-layout-plugin.html
*/
export const createFreeAutoLayoutPlugin = definePluginCreator<AutoLayoutOptions>({
onBind: ({ bind }) => {
bind(AutoLayoutService).toSelf().inSingletonScope();
},
onInit: (ctx, opts) => {
ctx.get(AutoLayoutService).init(opts);
},
singleton: true,
});
@@ -0,0 +1,129 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { LayoutGraph } from './graph';
/**
* DFS去环算法
* @param graph 布局图实例
* @returns 反馈弧集(需要反转的边的ID数组)
*/
const dfsFAS = (graph: LayoutGraph): string[] => {
const visited: { [key: string]: boolean } = {};
const stack: { [key: string]: boolean } = {};
const fas: string[] = [];
/**
* DFS遍历
* @param nodeId 当前节点ID
*/
const dfs = (nodeId: string): void => {
visited[nodeId] = true;
stack[nodeId] = true;
const outEdges = graph.edges.filter((edge) => edge.from === nodeId);
outEdges.forEach((edge) => {
if (!visited[edge.to]) {
dfs(edge.to);
} else if (stack[edge.to]) {
// 发现环,将该边添加到反馈弧集
fas.push(edge.id);
}
});
stack[nodeId] = false;
};
// 对每个未访问的节点进行DFS
graph.nodes.forEach((node) => {
if (!visited[node.id]) {
dfs(node.id);
}
});
return fas;
};
/**
* 贪心去环算法
* @param graph 布局图实例
* @returns 反馈弧集(需要反转的边的ID数组)
*/
const greedyFAS = (graph: LayoutGraph): string[] => {
const fas: string[] = [];
const nodeOrder: string[] = [];
// 计算节点的入度和出度
const inDegree: { [key: string]: number } = {};
const outDegree: { [key: string]: number } = {};
graph.nodes.forEach((node) => {
inDegree[node.id] = 0;
outDegree[node.id] = 0;
});
graph.edges.forEach((edge) => {
inDegree[edge.to]++;
outDegree[edge.from]++;
});
// 贪心选择节点
while (nodeOrder.length < graph.nodes.length) {
let maxDiff = -Infinity;
let bestNode: string | null = null;
graph.nodes.forEach((node) => {
if (!nodeOrder.includes(node.id)) {
const diff = outDegree[node.id] - inDegree[node.id];
if (diff > maxDiff) {
maxDiff = diff;
bestNode = node.id;
}
}
});
if (bestNode) {
nodeOrder.push(bestNode);
// 更新相邻节点的入度和出度
graph.edges.forEach((edge) => {
if (edge.from === bestNode) {
inDegree[edge.to]--;
}
if (edge.to === bestNode) {
outDegree[edge.from]--;
}
});
}
}
// 根据节点顺序确定需要反转的边
graph.edges.forEach((edge) => {
if (nodeOrder.indexOf(edge.from) > nodeOrder.indexOf(edge.to)) {
fas.push(edge.id);
}
});
return fas;
};
/**
* 去环
*/
export const acyclic = (graph: LayoutGraph, acyclicer: 'dfs' | 'greedy' = 'dfs'): LayoutGraph => {
// 使用DFS或贪心算法获取反馈弧集
const fas = acyclicer === 'dfs' ? dfsFAS(graph) : greedyFAS(graph);
// 反转反馈弧集中的边
fas.forEach((edgeId) => {
const edge = graph.edges.find((e) => e.id === edgeId);
if (edge) {
const { from, to } = edge;
graph.removeEdge(edgeId);
graph.addLayoutEdge({ id: edgeId, from: to, to: from });
}
});
return graph;
};
@@ -0,0 +1,71 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { WorkflowLineEntity, WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
import { TransformData } from '@flowgram.ai/core';
import type { ILayoutGraph, LayoutEdge, LayoutNode } from './type';
export class LayoutGraph implements ILayoutGraph {
public readonly store: {
nodes: Map<string, LayoutNode>;
edges: Map<string, LayoutEdge>;
} = {
nodes: new Map(),
edges: new Map(),
};
public get nodes(): LayoutNode[] {
return Array.from(this.store.nodes.values());
}
public get edges(): LayoutEdge[] {
return Array.from(this.store.edges.values());
}
public getNode(id: string): LayoutNode | undefined {
return this.store.nodes.get(id);
}
public hasNode(id: string): boolean {
return this.store.nodes.has(id);
}
public addNode(nodeEntity: WorkflowNodeEntity): LayoutNode {
const transform = nodeEntity.getData(TransformData);
const layoutNode: LayoutNode = {
id: nodeEntity.id,
node: nodeEntity,
rank: -1,
order: -1,
position: { x: transform.position.x, y: transform.position.y },
size: { width: transform.bounds.width, height: transform.bounds.height },
};
this.store.nodes.set(layoutNode.id, layoutNode);
return layoutNode;
}
public addLayoutNode(layoutNode: LayoutNode): void {
this.store.nodes.set(layoutNode.id, layoutNode);
}
public addEdge(edgeEntity: WorkflowLineEntity): LayoutEdge {
const layoutEdge: LayoutEdge = {
id: edgeEntity.id,
from: edgeEntity.from.id,
to: edgeEntity.to!.id,
};
this.store.edges.set(layoutEdge.id, layoutEdge);
return layoutEdge;
}
public addLayoutEdge(layoutEdge: LayoutEdge): void {
this.store.edges.set(layoutEdge.id, layoutEdge);
}
public removeEdge(id: string): void {
this.store.edges.delete(id);
}
}
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './acyclic';
export * from './rank';
export * from './order';
export * from './layout';
@@ -0,0 +1,151 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
WorkflowLineEntity,
WorkflowNodeEntity,
WorkflowNodeLinesData,
} from '@flowgram.ai/free-layout-core';
import { TransformData } from '@flowgram.ai/core';
import { LayoutNode } from './type';
import { LayoutGraph } from './graph';
import { acyclic, feasibleTree, longestPath, networkSimplex, normalizeRanks, order } from './';
/**
* 布局算法
* 参考 dagre.js 的实现 https://github.com/dagrejs/dagre
*/
export namespace DagreLayout {
const getNextEdges = (node: WorkflowNodeEntity): WorkflowLineEntity[] => {
const linesData = node.getData<WorkflowNodeLinesData>(WorkflowNodeLinesData);
return linesData.outputLines.filter((line) => line.from && line.to);
};
const getPrevEdges = (node: WorkflowNodeEntity): WorkflowLineEntity[] => {
const linesData = node.getData<WorkflowNodeLinesData>(WorkflowNodeLinesData);
return linesData.inputLines.filter((line) => line.from && line.to);
};
/** 添加节点 */
const createData = (params: {
node: WorkflowNodeEntity;
depth: number;
graph: LayoutGraph;
}): LayoutGraph => {
const { node, depth, graph } = params;
if (graph.hasNode(node.id)) {
return graph;
}
graph.addNode(node);
const prevEdges = getPrevEdges(node);
const nextEdges = getNextEdges(node);
prevEdges.forEach((prevEdge) => {
graph.addEdge(prevEdge);
createData({ node: prevEdge.from, depth: depth - 1, graph });
});
nextEdges.forEach((nextEdge) => {
graph.addEdge(nextEdge);
createData({ node: nextEdge.to!, depth: depth + 1, graph });
});
return graph;
};
// 定义一些常量
const NODE_SPACING = 100; // 同层级节点之间的垂直间距
const RANK_SPACING = 100; // 层级之间的水平间距
/** 计算图中所有节点的坐标 */
const calcCoordinates = (graph: LayoutGraph): LayoutGraph => {
// 按rank对节点进行分组
const rankGroups = groupNodesByRank(graph.nodes);
// 计算每个rank的最大高度
const rankHeights = calculateRankHeights(rankGroups);
// 计算每个节点的坐标
let currentX = 0;
rankGroups.forEach((nodesInRank, rank) => {
const rankHeight = rankHeights[rank];
nodesInRank.forEach((node) => {
// 计算X坐标
node.position.x = currentX + node.size.width / 2;
// 计算Y坐标
const totalHeightOfRank = nodesInRank.reduce((sum, n) => sum + n.size.height, 0);
const totalSpacing = (nodesInRank.length - 1) * NODE_SPACING;
const startY = (rankHeight - totalHeightOfRank - totalSpacing) / 2;
let currentY = startY;
for (let i = 0; i < node.order; i++) {
currentY += nodesInRank[i].size.height + NODE_SPACING;
}
node.position.y = currentY + node.size.height / 2;
});
// 更新X坐标为下一个rank的起始位置
currentX += rankHeight + RANK_SPACING;
});
return graph;
};
/** 按rank对节点进行分组 */
const groupNodesByRank = (nodes: LayoutNode[]): LayoutNode[][] => {
const groups: LayoutNode[][] = [];
nodes.forEach((node) => {
if (!groups[node.rank]) {
groups[node.rank] = [];
}
groups[node.rank].push(node);
});
return groups;
};
/** 计算每个rank的最大高度 */
const calculateRankHeights = (rankGroups: LayoutNode[][]): number[] =>
rankGroups.map((nodesInRank) => Math.max(...nodesInRank.map((node) => node.size.width)));
const positioning = (graph: LayoutGraph): LayoutGraph => {
graph.nodes.forEach((node) => {
const transform = node.node.getData(TransformData);
transform.update({
position: node.position,
});
});
return graph;
};
const rank = (
graph: LayoutGraph,
ranker: 'longest-path' | 'network-simplex' | 'tight-tree' = 'network-simplex'
): LayoutGraph => {
if (ranker === 'longest-path') {
longestPath(graph);
} else if (ranker === 'network-simplex') {
networkSimplex(graph);
} else if (ranker === 'tight-tree') {
feasibleTree(graph);
}
return graph;
};
export const applyLayout = (graph: LayoutGraph): void => {
acyclic(graph); // 去环
rank(graph); // 分层
normalizeRanks(graph); // 归一化 rank 值
order(graph); // 重心法对同层级节点进行排序
calcCoordinates(graph); // 分配坐标
positioning(graph); // 应用布局
};
/** 创建布局图 */
export const createGraph = (node: WorkflowNodeEntity): LayoutGraph => {
const graph = new LayoutGraph();
createData({ node, depth: 0, graph });
applyLayout(graph);
return graph;
};
}
@@ -0,0 +1,261 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { LayoutGraph } from './graph';
// 辅助函数:获取图中的最大rank
const getMaxRank = (graph: LayoutGraph): number =>
Math.max(...graph.nodes.map((node) => node.rank));
// 辅助函数:根据rank构建层级图
const buildLayerGraph = (
graph: LayoutGraph,
rank: number,
edgeType: 'inEdges' | 'outEdges'
): LayoutGraph => {
const layerGraph = new LayoutGraph();
graph.nodes
.filter((node) => node.rank === rank)
.forEach((node) => {
layerGraph.addLayoutNode(node);
});
graph.edges.forEach((edge) => {
const sourceNode = graph.getNode(edge.from);
const targetNode = graph.getNode(edge.to);
if (!sourceNode || !targetNode) return;
if (edgeType === 'inEdges' && targetNode.rank === rank) {
layerGraph.addLayoutEdge(edge);
} else if (edgeType === 'outEdges' && sourceNode.rank === rank) {
layerGraph.addLayoutEdge(edge);
}
});
return layerGraph;
};
// 辅助函数:初始化order
const initOrder = (graph: LayoutGraph): { [key: number]: string[] } => {
const layering: { [key: number]: string[] } = {};
graph.nodes.forEach((node) => {
if (!layering[node.rank]) {
layering[node.rank] = [];
}
layering[node.rank].push(node.id);
});
return layering;
};
// 辅助函数:分配order
const assignOrder = (graph: LayoutGraph, layering: { [key: number]: string[] }): void => {
Object.entries(layering).forEach(([rank, layer]) => {
layer.forEach((nodeId, index) => {
const node = graph.getNode(nodeId);
if (node) {
node.order = index;
}
});
});
};
// 辅助函数:计算交叉数
const crossCount = (graph: LayoutGraph, layering: { [key: number]: string[] }): number => {
let cc = 0;
const layers = Object.values(layering);
for (let i = 1; i < layers.length; i++) {
const northLayer = layers[i - 1];
const southLayer = layers[i];
for (let j = 0; j < northLayer.length; j++) {
for (let k = j + 1; k < northLayer.length; k++) {
const v = graph.getNode(northLayer[j]);
const w = graph.getNode(northLayer[k]);
if (!v || !w) continue;
// 获取v和w的南向邻居
const vNeighbors = graph.edges
.filter((e) => e.from === v.id)
.map((e) => graph.getNode(e.to));
const wNeighbors = graph.edges
.filter((e) => e.from === w.id)
.map((e) => graph.getNode(e.to));
for (const vNeighbor of vNeighbors) {
for (const wNeighbor of wNeighbors) {
if (!vNeighbor || !wNeighbor) continue;
if (southLayer.indexOf(vNeighbor.id) > southLayer.indexOf(wNeighbor.id)) {
cc++;
}
}
}
}
}
}
return cc;
};
// 辅助函数:构建复合图
const buildCompoundGraph = (): LayoutGraph => new LayoutGraph();
// 辅助函数:添加子图约束
const addSubgraphConstraints = (layerGraph: LayoutGraph, cg: LayoutGraph, vs: string[]): void => {
const prev: { [key: string]: string } = {};
let root = layerGraph.nodes[0]?.id;
vs.forEach((v) => {
let prevV = prev[root];
if (prevV) {
cg.addLayoutEdge({ id: `${prevV}-${v}`, from: prevV, to: v, weight: 0 });
}
prev[root] = v;
});
};
// 辅助函数:对子图进行排序
const sortSubgraph = (
layerGraph: LayoutGraph,
root: string,
cg: LayoutGraph,
biasRight: boolean
): { vs: string[] } => {
const vs: string[] = [];
const visited = new Set<string>();
const nodeData = new Map<string, { barycenter: number; weight: number }>();
const dfs = (v: string) => {
if (visited.has(v)) return;
visited.add(v);
let barycenter = 0;
let weight = 0;
const node = layerGraph.getNode(v);
if (node) {
const edges = biasRight
? layerGraph.edges.filter((e) => e.to === v)
: layerGraph.edges.filter((e) => e.from === v);
edges.forEach((edge) => {
const w = biasRight ? edge.from : edge.to;
const otherNode = layerGraph.getNode(w);
if (otherNode) {
const edgeWeight = edge.weight || 1;
weight += edgeWeight;
barycenter += (otherNode.order || 0) * edgeWeight;
}
});
if (weight > 0) {
barycenter /= weight;
}
}
nodeData.set(v, { barycenter, weight });
vs.push(v);
const neighbors = layerGraph.edges
.filter((e) => e.from === v || e.to === v)
.map((e) => (e.from === v ? e.to : e.from));
neighbors.sort((a, b) => {
const nodeA = layerGraph.getNode(a);
const nodeB = layerGraph.getNode(b);
return (nodeA?.order || 0) - (nodeB?.order || 0);
});
neighbors.forEach(dfs);
};
dfs(root);
// 根据重心值和权重排序
vs.sort((a, b) => {
const aData = nodeData.get(a);
const bData = nodeData.get(b);
if (aData && bData) {
if (Math.abs(aData.barycenter - bData.barycenter) < 0.001) {
return bData.weight - aData.weight;
}
return aData.barycenter - bData.barycenter;
}
return 0;
});
return { vs };
};
// 新增:局部搜索优化
const localSearch = (graph: LayoutGraph, layering: { [key: number]: string[] }): void => {
const ranks = Object.keys(layering).map(Number);
ranks.forEach((rank) => {
const layer = layering[rank];
for (let i = 0; i < layer.length - 1; i++) {
for (let j = i + 1; j < layer.length; j++) {
const currentCC = crossCount(graph, layering);
// 交换两个节点的位置
[layer[i], layer[j]] = [layer[j], layer[i]];
const newCC = crossCount(graph, layering);
// 如果交叉数增加,则恢复交换
if (newCC > currentCC) {
[layer[i], layer[j]] = [layer[j], layer[i]];
}
}
}
});
};
// 优化:sweepLayerGraphs 函数
const sweepLayerGraphs = (layerGraphs: LayoutGraph[], biasRight: boolean): void => {
const cg = buildCompoundGraph();
layerGraphs.forEach((lg) => {
const root = lg.nodes[0]?.id;
if (root) {
const sorted = sortSubgraph(lg, root, cg, biasRight);
sorted.vs.forEach((v, i) => {
const node = lg.getNode(v);
if (node) {
node.order = i;
}
});
addSubgraphConstraints(lg, cg, sorted.vs);
}
});
};
// 更新主函数 order
export const order = (graph: LayoutGraph): LayoutGraph => {
const maxRank = getMaxRank(graph);
const downLayerGraphs = Array.from({ length: maxRank + 1 }, (_, i) =>
buildLayerGraph(graph, i, 'inEdges')
);
const upLayerGraphs = Array.from({ length: maxRank + 1 }, (_, i) =>
buildLayerGraph(graph, maxRank - i, 'outEdges')
);
let layering = initOrder(graph);
assignOrder(graph, layering);
let bestCC = Number.POSITIVE_INFINITY;
let bestLayering = layering;
// 增加迭代次数
for (let i = 0, lastBest = 0; lastBest < 8; ++i, ++lastBest) {
sweepLayerGraphs(i % 2 ? downLayerGraphs : upLayerGraphs, i % 4 >= 2);
layering = initOrder(graph);
localSearch(graph, layering); // 应用局部搜索
const cc = crossCount(graph, layering);
if (cc < bestCC) {
lastBest = 0;
bestLayering = JSON.parse(JSON.stringify(layering));
bestCC = cc;
}
}
assignOrder(graph, bestLayering);
return graph;
};
@@ -0,0 +1,102 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { LayoutEdge } from '../type';
import { LayoutGraph } from '../graph';
/**
* 计算边的松弛度
*/
const calculateSlack = (graph: LayoutGraph, edge: LayoutEdge): number => {
const sourceNode = graph.getNode(edge.from);
const targetNode = graph.getNode(edge.to);
if (!sourceNode || !targetNode) {
return Number.POSITIVE_INFINITY;
}
return targetNode.rank - sourceNode.rank - (edge.minlen || 1);
};
/**
* 深度优先搜索构建紧致树
*/
const dfs = (graph: LayoutGraph, tightTree: LayoutGraph, nodeId: string): void => {
const edges = graph.edges.filter((e) => e.from === nodeId || e.to === nodeId);
edges.forEach((edge) => {
const neighborId = edge.from === nodeId ? edge.to : edge.from;
if (!tightTree.hasNode(neighborId) && calculateSlack(graph, edge) === 0) {
const neighborNode = graph.getNode(neighborId);
if (neighborNode) {
tightTree.addLayoutNode({ ...neighborNode });
tightTree.addLayoutEdge({ ...edge });
dfs(graph, tightTree, neighborId);
}
}
});
};
/**
* 构建最大紧致树
*/
const buildTightTree = (graph: LayoutGraph, tightTree: LayoutGraph): number => {
tightTree.nodes.forEach((node) => dfs(graph, tightTree, node.id));
return tightTree.nodes.length;
};
/**
* 找到具有最小松弛度的边
*/
const findMinSlackEdge = (graph: LayoutGraph, tightTree: LayoutGraph): LayoutEdge | undefined => {
let minSlack = Number.POSITIVE_INFINITY;
let minSlackEdge: LayoutEdge | undefined;
graph.edges.forEach((edge) => {
const hasSource = tightTree.hasNode(edge.from);
const hasTarget = tightTree.hasNode(edge.to);
if (hasSource !== hasTarget) {
const slack = calculateSlack(graph, edge);
if (slack < minSlack) {
minSlack = slack;
minSlackEdge = edge;
}
}
});
return minSlackEdge;
};
/**
* 调整rank值
*/
const shiftRanks = (graph: LayoutGraph, tightTree: LayoutGraph, delta: number): void => {
tightTree.nodes.forEach((node) => {
const graphNode = graph.getNode(node.id);
if (graphNode) {
graphNode.rank += delta;
}
});
};
/**
* 构建紧致生成树
*/
export const feasibleTree = (graph: LayoutGraph): LayoutGraph => {
const tightTree = new LayoutGraph();
// 选择任意节点作为起始节点
const startNode = graph.nodes[0];
tightTree.addLayoutNode({ ...startNode });
while (buildTightTree(graph, tightTree) < graph.nodes.length) {
const minSlackEdge = findMinSlackEdge(graph, tightTree);
if (minSlackEdge) {
const delta = tightTree.hasNode(minSlackEdge.from)
? calculateSlack(graph, minSlackEdge)
: -calculateSlack(graph, minSlackEdge);
shiftRanks(graph, tightTree, delta);
}
}
return tightTree;
};
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { feasibleTree } from './feasible-tree';
export { longestPath } from './longest-path';
export { networkSimplex } from './network-simplex';
export { normalizeRanks } from './normalize-ranks';
@@ -0,0 +1,79 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { LayoutGraph } from '../graph';
/**
* 计算图中节点的最长路径
*/
export const longestPath = (graph: LayoutGraph): LayoutGraph => {
// 用于记录已访问的节点
const visited: Record<string, boolean> = {};
/**
* 深度优先搜索计算节点的层级
* @param nodeId 当前节点ID
* @returns 计算得到的层级
*/
const dfs = (nodeId: string): number => {
const node = graph.getNode(nodeId);
// 如果节点不存在,返回 -1
if (!node) {
return -1;
}
// 如果节点已访问且已经计算过rank,直接返回其rank
if (visited[nodeId] && node.rank !== -1) {
return node.rank;
}
// 标记节点为已访问
visited[nodeId] = true;
// 获取所有以当前节点为起点的边
const outgoingEdges = graph.edges.filter((edge) => edge.from === nodeId);
// 如果没有出边,说明是叶子节点,rank为0
if (outgoingEdges.length === 0) {
node.rank = 0;
return 0;
}
// 计算所有子节点的最大rank
let maxChildRank = -1;
outgoingEdges.forEach((edge) => {
const childRank = dfs(edge.to);
const minlen = edge.minlen || 1; // 使用默认最小长度1,如果未指定
maxChildRank = Math.max(maxChildRank, childRank + minlen);
});
// 当前节点的rank为子节点最大rank + 1
node.rank = maxChildRank;
return node.rank;
};
// 从每个没有入边的节点(源节点)开始DFS
const sourceNodes = graph.nodes.filter(
(node) => !graph.edges.some((edge) => edge.to === node.id)
);
sourceNodes.forEach((node) => dfs(node.id));
// 确保所有节点都被访问到
graph.nodes.forEach((node) => {
if (node.rank === -1) {
dfs(node.id);
}
});
// 反转rank值,使得源节点的rank最小
const maxRank = Math.max(...graph.nodes.map((node) => node.rank));
graph.nodes.forEach((node) => {
node.rank = maxRank - node.rank;
});
return graph;
};
@@ -0,0 +1,235 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { LayoutEdge, LayoutNode } from '../type';
import { LayoutGraph } from '../graph';
import { longestPath } from './longest-path';
import { feasibleTree } from './feasible-tree';
/**
* 网络单纯形
* 参考 https://github.com/dagrejs/dagre/blob/master/lib/rank/network-simplex.js
*/
export const networkSimplex = (g: LayoutGraph): LayoutGraph => {
longestPath(g); // 初始化层级
const t = feasibleTree(g); // 构建紧致生成树
initLowLimValues(t);
initCutValues(t, g);
let e: LayoutEdge | undefined;
while ((e = leaveEdge(t))) {
const f = enterEdge(t, g, e);
if (f) {
exchangeEdges(t, g, e, f);
}
}
return g;
};
const initLowLimValues = (t: LayoutGraph): void => {
const root = t.nodes[0];
if (root) {
dfsAssignLowLim(t, new Map<string, boolean>(), { nextLim: 1 }, root.id);
}
};
const dfsAssignLowLim = (
t: LayoutGraph,
visited: Map<string, boolean>,
state: { nextLim: number },
v: string,
parent?: string
): void => {
const node = t.getNode(v);
if (!node) return;
const low = state.nextLim;
visited.set(v, true);
t.edges
.filter((e) => e.from === v || e.to === v)
.forEach((e) => {
const w = e.from === v ? e.to : e.from;
if (!visited.get(w)) {
dfsAssignLowLim(t, visited, state, w, v);
}
});
node.low = low;
node.lim = state.nextLim++;
if (parent) {
node.parent = parent;
} else {
delete node.parent;
}
};
const initCutValues = (t: LayoutGraph, g: LayoutGraph): void => {
const vs = postorder(t);
vs.slice(0, -1).forEach((v) => assignCutValue(t, g, v));
};
const postorder = (t: LayoutGraph): string[] => {
const visited = new Set<string>();
const result: string[] = [];
const dfs = (nodeId: string): void => {
const node = t.getNode(nodeId);
if (!node || visited.has(nodeId)) return;
visited.add(nodeId);
t.edges
.filter((e) => e.from === nodeId || e.to === nodeId)
.forEach((e) => {
const neighborId = e.from === nodeId ? e.to : e.from;
dfs(neighborId);
});
result.push(nodeId);
};
t.nodes.forEach((node) => dfs(node.id));
return result;
};
const assignCutValue = (t: LayoutGraph, g: LayoutGraph, childId: string): void => {
const child = t.getNode(childId);
if (!child || !child.parent) return;
const edge = t.edges.find((e) => e.from === childId && e.to === child.parent);
if (edge) {
edge.cutvalue = calcCutValue(t, g, childId);
}
};
const calcCutValue = (t: LayoutGraph, g: LayoutGraph, childId: string): number => {
const child = t.getNode(childId);
if (!child || !child.parent) return 0;
let cutValue = 0;
const graphEdge = g.edges.find(
(e) =>
(e.from === childId && e.to === child.parent) || (e.from === child.parent && e.to === childId)
);
if (graphEdge) {
cutValue = graphEdge.weight || 0;
}
g.edges
.filter((e) => e.from === childId || e.to === childId)
.forEach((e) => {
const otherId = e.from === childId ? e.to : e.from;
if (otherId !== child.parent) {
const otherWeight = e.weight || 0;
cutValue += e.from === childId ? otherWeight : -otherWeight;
if (isTreeEdge(t, childId, otherId)) {
const treeEdge = t.edges.find(
(te) =>
(te.from === childId && te.to === otherId) ||
(te.from === otherId && te.to === childId)
);
if (treeEdge && treeEdge.cutvalue !== undefined) {
cutValue += e.from === childId ? -treeEdge.cutvalue : treeEdge.cutvalue;
}
}
}
});
return cutValue;
};
const isTreeEdge = (t: LayoutGraph, u: string, v: string): boolean =>
t.edges.some((e) => (e.from === u && e.to === v) || (e.from === v && e.to === u));
const leaveEdge = (t: LayoutGraph): LayoutEdge | undefined =>
t.edges.find((e) => (e.cutvalue || 0) < 0);
const enterEdge = (t: LayoutGraph, g: LayoutGraph, edge: LayoutEdge): LayoutEdge | undefined => {
const vLabel = t.getNode(edge.from);
const wLabel = t.getNode(edge.to);
if (!vLabel || !wLabel) return undefined;
const tailLabel = vLabel.lim! > wLabel.lim! ? wLabel : vLabel;
const flip = tailLabel === wLabel;
const candidates = g.edges.filter((e) => {
const vNode = t.getNode(e.from);
const wNode = t.getNode(e.to);
return (
vNode &&
wNode &&
flip === isDescendant(t, vNode, tailLabel) &&
flip !== isDescendant(t, wNode, tailLabel)
);
});
return candidates.reduce((acc, e) => {
if (slack(g, e) < slack(g, acc)) {
return e;
}
return acc;
});
};
const isDescendant = (t: LayoutGraph, vLabel: LayoutNode, rootLabel: LayoutNode): boolean =>
(rootLabel.low || 0) <= (vLabel.lim || 0) && (vLabel.lim || 0) <= (rootLabel.lim || 0);
const slack = (g: LayoutGraph, edge: LayoutEdge): number => {
const source = g.getNode(edge.from);
const target = g.getNode(edge.to);
if (!source || !target) return Number.POSITIVE_INFINITY;
return Math.abs(target.rank - source.rank) - (edge.minlen || 1);
};
const exchangeEdges = (t: LayoutGraph, g: LayoutGraph, e: LayoutEdge, f: LayoutEdge): void => {
t.removeEdge(e.id);
t.addLayoutEdge(f);
initLowLimValues(t);
initCutValues(t, g);
updateRanks(t, g);
};
const updateRanks = (t: LayoutGraph, g: LayoutGraph): void => {
const root = t.nodes.find((v) => !v.parent);
if (!root) return;
const vs = preorder(t, root.id);
vs.slice(1).forEach((v) => {
const node = t.getNode(v);
const parent = t.getNode(node?.parent || '');
if (!node || !parent) return;
const edge = g.edges.find(
(e) => (e.from === v && e.to === node.parent) || (e.from === node.parent && e.to === v)
);
if (!edge) return;
const flipped = edge.from === node.parent;
node.rank = parent.rank + (flipped ? edge.minlen || 1 : -(edge.minlen || 1));
});
};
const preorder = (t: LayoutGraph, root: string): string[] => {
const result: string[] = [];
const visited = new Set<string>();
const dfs = (nodeId: string): void => {
if (visited.has(nodeId)) return;
visited.add(nodeId);
result.push(nodeId);
t.edges
.filter((e) => e.from === nodeId || e.to === nodeId)
.forEach((e) => {
const neighborId = e.from === nodeId ? e.to : e.from;
dfs(neighborId);
});
};
dfs(root);
return result;
};
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { LayoutGraph } from '../graph';
export const normalizeRanks = (graph: LayoutGraph): LayoutGraph => {
// 获取所有节点的 rank 值
const nodeRanks: number[] = graph.nodes.map((node) => {
const rank: number = node.rank;
return rank === -1 ? Number.MAX_VALUE : rank;
});
// 找出最小的 rank 值
const minRank: number = Math.min(...nodeRanks);
// 调整所有节点的 rank 值
graph.nodes.forEach((node) => {
if (node.rank !== -1) {
node.rank -= minRank;
}
});
return graph;
};
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
export interface LayoutNode {
id: string;
node: WorkflowNodeEntity;
/** 层级 */
rank: number;
/** 同层级索引 */
order: number;
/** 位置 */
position: {
x: number;
y: number;
};
/** 宽高 */
size: {
width: number;
height: number;
};
low?: number;
lim?: number;
parent?: string;
}
export interface LayoutEdge {
id: string;
from: string;
to: string;
cutvalue?: number;
minlen?: number;
weight?: number;
}
export interface ILayoutGraph {
nodes: LayoutNode[];
edges: LayoutEdge[];
}
@@ -0,0 +1,72 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import greedyFAS from './greedy-fas';
import { uniqueId } from './util';
export const acyclic = {
run,
undo,
};
export default acyclic;
function run(g) {
let fas = g.graph().acyclicer === 'greedy' ? greedyFAS(g, weightFn(g)) : dfsFAS(g);
fas.forEach((e) => {
let label = g.edge(e);
g.removeEdge(e);
label.forwardName = e.name;
label.reversed = true;
g.setEdge(e.w, e.v, label, uniqueId('rev'));
});
function weightFn(g) {
return (e) => {
return g.edge(e).weight;
};
}
}
function dfsFAS(g) {
let fas = [];
let stack = {};
let visited = {};
function dfs(v) {
if (Object.hasOwn(visited, v)) {
return;
}
visited[v] = true;
stack[v] = true;
g.outEdges(v).forEach((e) => {
if (Object.hasOwn(stack, e.w)) {
fas.push(e);
} else {
dfs(e.w);
}
});
delete stack[v];
}
g.nodes().forEach(dfs);
return fas;
}
function undo(g) {
g.edges().forEach((e) => {
let label = g.edge(e);
if (label.reversed) {
g.removeEdge(e);
let forwardName = label.forwardName;
delete label.reversed;
delete label.forwardName;
g.setEdge(e.w, e.v, label, forwardName);
}
});
}
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { util } from './util';
export { addBorderSegments };
export default addBorderSegments;
function addBorderSegments(g) {
function dfs(v) {
let children = g.children(v);
let node = g.node(v);
if (children.length) {
children.forEach(dfs);
}
if (Object.hasOwn(node, 'minRank')) {
node.borderLeft = [];
node.borderRight = [];
for (let rank = node.minRank, maxRank = node.maxRank + 1; rank < maxRank; ++rank) {
addBorderNode(g, 'borderLeft', '_bl', v, node, rank);
addBorderNode(g, 'borderRight', '_br', v, node, rank);
}
}
}
g.children().forEach(dfs);
}
function addBorderNode(g, prop, prefix, sg, sgNode, rank) {
let label = { width: 0, height: 0, rank: rank, borderType: prop };
let prev = sgNode[prop][rank - 1];
let curr = util.addDummyNode(g, 'border', label, prefix);
sgNode[prop][rank] = curr;
g.setParent(curr, sg);
if (prev) {
g.setEdge(prev, curr, { weight: 1 });
}
}
@@ -0,0 +1,77 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
export const coordinateSystem = {
adjust,
undo,
};
export default coordinateSystem;
function adjust(g) {
let rankDir = g.graph().rankdir.toLowerCase();
if (rankDir === 'lr' || rankDir === 'rl') {
swapWidthHeight(g);
}
}
function undo(g) {
let rankDir = g.graph().rankdir.toLowerCase();
if (rankDir === 'bt' || rankDir === 'rl') {
reverseY(g);
}
if (rankDir === 'lr' || rankDir === 'rl') {
swapXY(g);
swapWidthHeight(g);
}
}
function swapWidthHeight(g) {
g.nodes().forEach((v) => swapWidthHeightOne(g.node(v)));
g.edges().forEach((e) => swapWidthHeightOne(g.edge(e)));
}
function swapWidthHeightOne(attrs) {
let w = attrs.width;
attrs.width = attrs.height;
attrs.height = w;
}
function reverseY(g) {
g.nodes().forEach((v) => reverseYOne(g.node(v)));
g.edges().forEach((e) => {
let edge = g.edge(e);
edge.points.forEach(reverseYOne);
if (Object.hasOwn(edge, 'y')) {
reverseYOne(edge);
}
});
}
function reverseYOne(attrs) {
attrs.y = -attrs.y;
}
function swapXY(g) {
g.nodes().forEach((v) => swapXYOne(g.node(v)));
g.edges().forEach((e) => {
let edge = g.edge(e);
edge.points.forEach(swapXYOne);
if (Object.hasOwn(edge, 'x')) {
swapXYOne(edge);
}
});
}
function swapXYOne(attrs) {
let x = attrs.x;
attrs.x = attrs.y;
attrs.y = x;
}
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/*
* Simple doubly linked list implementation derived from Cormen, et al.,
* "Introduction to Algorithms".
*/
class List {
constructor() {
let sentinel = {};
sentinel._next = sentinel._prev = sentinel;
this._sentinel = sentinel;
}
dequeue() {
let sentinel = this._sentinel;
let entry = sentinel._prev;
if (entry !== sentinel) {
unlink(entry);
return entry;
}
}
enqueue(entry) {
let sentinel = this._sentinel;
if (entry._prev && entry._next) {
unlink(entry);
}
entry._next = sentinel._next;
sentinel._next._prev = entry;
sentinel._next = entry;
entry._prev = sentinel;
}
toString() {
let strs = [];
let sentinel = this._sentinel;
let curr = sentinel._prev;
while (curr !== sentinel) {
strs.push(JSON.stringify(curr, filterOutLinks));
curr = curr._prev;
}
return '[' + strs.join(', ') + ']';
}
}
function unlink(entry) {
entry._prev._next = entry._next;
entry._next._prev = entry._prev;
delete entry._next;
delete entry._prev;
}
function filterOutLinks(k, v) {
if (k !== '_next' && k !== '_prev') {
return v;
}
}
export default List;
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { util } from './util';
import { Graph } from '@dagrejs/graphlib';
export { debugOrdering };
/* istanbul ignore next */
function debugOrdering(g) {
let layerMatrix = util.buildLayerMatrix(g);
let h = new Graph({ compound: true, multigraph: true }).setGraph({});
g.nodes().forEach((v) => {
h.setNode(v, { label: v });
h.setParent(v, 'layer' + g.node(v).rank);
});
g.edges().forEach((e) => h.setEdge(e.v, e.w, {}, e.name));
layerMatrix.forEach((layer, i) => {
let layerV = 'layer' + i;
h.setNode(layerV, { rank: 'same' });
layer.reduce((u, v) => {
h.setEdge(u, v, { style: 'invis' });
return v;
});
});
return h;
}
@@ -0,0 +1,134 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Graph } from '@dagrejs/graphlib';
import List from './data/list';
/*
* A greedy heuristic for finding a feedback arc set for a graph. A feedback
* arc set is a set of edges that can be removed to make a graph acyclic.
* The algorithm comes from: P. Eades, X. Lin, and W. F. Smyth, "A fast and
* effective heuristic for the feedback arc set problem." This implementation
* adjusts that from the paper to allow for weighted edges.
*/
export { greedyFAS };
export default greedyFAS;
let DEFAULT_WEIGHT_FN = () => 1;
function greedyFAS(g, weightFn) {
if (g.nodeCount() <= 1) {
return [];
}
let state = buildState(g, weightFn || DEFAULT_WEIGHT_FN);
let results = doGreedyFAS(state.graph, state.buckets, state.zeroIdx);
// Expand multi-edges
return results.flatMap((e) => g.outEdges(e.v, e.w));
}
function doGreedyFAS(g, buckets, zeroIdx) {
let results = [];
let sources = buckets[buckets.length - 1];
let sinks = buckets[0];
let entry;
while (g.nodeCount()) {
while ((entry = sinks.dequeue())) {
removeNode(g, buckets, zeroIdx, entry);
}
while ((entry = sources.dequeue())) {
removeNode(g, buckets, zeroIdx, entry);
}
if (g.nodeCount()) {
for (let i = buckets.length - 2; i > 0; --i) {
entry = buckets[i].dequeue();
if (entry) {
results = results.concat(removeNode(g, buckets, zeroIdx, entry, true));
break;
}
}
}
}
return results;
}
function removeNode(g, buckets, zeroIdx, entry, collectPredecessors) {
let results = collectPredecessors ? [] : undefined;
g.inEdges(entry.v).forEach((edge) => {
let weight = g.edge(edge);
let uEntry = g.node(edge.v);
if (collectPredecessors) {
results.push({ v: edge.v, w: edge.w });
}
uEntry.out -= weight;
assignBucket(buckets, zeroIdx, uEntry);
});
g.outEdges(entry.v).forEach((edge) => {
let weight = g.edge(edge);
let w = edge.w;
let wEntry = g.node(w);
wEntry['in'] -= weight;
assignBucket(buckets, zeroIdx, wEntry);
});
g.removeNode(entry.v);
return results;
}
function buildState(g, weightFn) {
let fasGraph = new Graph();
let maxIn = 0;
let maxOut = 0;
g.nodes().forEach((v) => {
fasGraph.setNode(v, { v: v, in: 0, out: 0 });
});
// Aggregate weights on nodes, but also sum the weights across multi-edges
// into a single edge for the fasGraph.
g.edges().forEach((e) => {
let prevWeight = fasGraph.edge(e.v, e.w) || 0;
let weight = weightFn(e);
let edgeWeight = prevWeight + weight;
fasGraph.setEdge(e.v, e.w, edgeWeight);
maxOut = Math.max(maxOut, (fasGraph.node(e.v).out += weight));
maxIn = Math.max(maxIn, (fasGraph.node(e.w)['in'] += weight));
});
let buckets = range(maxOut + maxIn + 3).map(() => new List());
let zeroIdx = maxIn + 1;
fasGraph.nodes().forEach((v) => {
assignBucket(buckets, zeroIdx, fasGraph.node(v));
});
return { graph: fasGraph, buckets: buckets, zeroIdx: zeroIdx };
}
function assignBucket(buckets, zeroIdx, entry) {
if (!entry.out) {
buckets[0].enqueue(entry);
} else if (!entry['in']) {
buckets[buckets.length - 1].enqueue(entry);
} else {
buckets[entry.out - entry['in'] + zeroIdx].enqueue(entry);
}
}
function range(limit) {
const range = [];
for (let i = 0; i < limit; i++) {
range.push(i);
}
return range;
}
@@ -0,0 +1,75 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
/**
* Dagre DAG 布局库
* 开源协议 - MIT
* 源码 - https://github.com/dagrejs/dagre
* 论文 - https://graphviz.org/documentation/TSE93.pdf
*/
import acyclic from './acyclic';
import normalize from './normalize';
import rank from './rank';
import { normalizeRanks, removeEmptyRanks } from './util';
import parentDummyChains from './parent-dummy-chains';
import nestingGraph from './nesting-graph';
import addBorderSegments from './add-border-segments';
import coordinateSystem from './coordinate-system';
import order from './order';
import position from './position';
import { util } from './util';
import {
layout,
buildLayoutGraph,
updateInputGraph,
makeSpaceForEdgeLabels,
removeSelfEdges,
injectEdgeLabelProxies,
assignRankMinMax,
removeEdgeLabelProxies,
insertSelfEdges,
positionSelfEdges,
removeBorderNodes,
fixupEdgeLabelCoords,
translateGraph,
assignNodeIntersects,
reversePointsForReversedEdges,
} from './layout';
const dagreLib = {
layout,
buildLayoutGraph,
updateInputGraph,
makeSpaceForEdgeLabels,
removeSelfEdges,
acyclic,
nestingGraph,
rank,
util,
injectEdgeLabelProxies,
removeEmptyRanks,
normalizeRanks,
assignRankMinMax,
removeEdgeLabelProxies,
normalize,
parentDummyChains,
addBorderSegments,
order,
insertSelfEdges,
coordinateSystem,
position,
positionSelfEdges,
removeBorderNodes,
fixupEdgeLabelCoords,
translateGraph,
assignNodeIntersects,
reversePointsForReversedEdges,
};
export { dagreLib };
@@ -0,0 +1,449 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import acyclic from './acyclic';
import normalize from './normalize';
import rank from './rank';
import { normalizeRanks, removeEmptyRanks, util } from './util';
import parentDummyChains from './parent-dummy-chains';
import nestingGraph from './nesting-graph';
import addBorderSegments from './add-border-segments';
import coordinateSystem from './coordinate-system';
import order from './order';
import position from './position';
import { Graph } from '@dagrejs/graphlib';
export {
layout,
buildLayoutGraph,
updateInputGraph,
makeSpaceForEdgeLabels,
removeSelfEdges,
injectEdgeLabelProxies,
assignRankMinMax,
removeEdgeLabelProxies,
insertSelfEdges,
positionSelfEdges,
removeBorderNodes,
fixupEdgeLabelCoords,
translateGraph,
assignNodeIntersects,
reversePointsForReversedEdges,
};
function layout(g, opts) {
let time = opts && opts.debugTiming ? util.time : util.notime;
time('layout', () => {
let layoutGraph = time(' buildLayoutGraph', () => buildLayoutGraph(g));
time(' runLayout', () => runLayout(layoutGraph, time, opts));
time(' updateInputGraph', () => updateInputGraph(g, layoutGraph));
});
}
function runLayout(g, time, opts) {
time(' makeSpaceForEdgeLabels', () => makeSpaceForEdgeLabels(g));
time(' removeSelfEdges', () => removeSelfEdges(g));
time(' acyclic', () => acyclic.run(g));
time(' nestingGraph.run', () => nestingGraph.run(g));
time(' rank', () => rank(util.asNonCompoundGraph(g)));
time(' injectEdgeLabelProxies', () => injectEdgeLabelProxies(g));
time(' removeEmptyRanks', () => removeEmptyRanks(g));
time(' nestingGraph.cleanup', () => nestingGraph.cleanup(g));
time(' normalizeRanks', () => normalizeRanks(g));
time(' assignRankMinMax', () => assignRankMinMax(g));
time(' removeEdgeLabelProxies', () => removeEdgeLabelProxies(g));
time(' normalize.run', () => normalize.run(g));
time(' parentDummyChains', () => parentDummyChains(g));
time(' addBorderSegments', () => addBorderSegments(g));
time(' order', () => order(g, opts));
time(' insertSelfEdges', () => insertSelfEdges(g));
time(' adjustCoordinateSystem', () => coordinateSystem.adjust(g));
time(' position', () => position(g));
time(' positionSelfEdges', () => positionSelfEdges(g));
time(' removeBorderNodes', () => removeBorderNodes(g));
time(' normalize.undo', () => normalize.undo(g));
time(' fixupEdgeLabelCoords', () => fixupEdgeLabelCoords(g));
time(' undoCoordinateSystem', () => coordinateSystem.undo(g));
time(' translateGraph', () => translateGraph(g));
time(' assignNodeIntersects', () => assignNodeIntersects(g));
time(' reversePoints', () => reversePointsForReversedEdges(g));
time(' acyclic.undo', () => acyclic.undo(g));
}
/*
* Copies final layout information from the layout graph back to the input
* graph. This process only copies whitelisted attributes from the layout graph
* to the input graph, so it serves as a good place to determine what
* attributes can influence layout.
*/
function updateInputGraph(inputGraph, layoutGraph) {
inputGraph.nodes().forEach((v) => {
let inputLabel = inputGraph.node(v);
let layoutLabel = layoutGraph.node(v);
if (inputLabel) {
inputLabel.x = layoutLabel.x;
inputLabel.y = layoutLabel.y;
inputLabel.rank = layoutLabel.rank;
if (layoutGraph.children(v).length) {
inputLabel.width = layoutLabel.width;
inputLabel.height = layoutLabel.height;
}
}
});
inputGraph.edges().forEach((e) => {
let inputLabel = inputGraph.edge(e);
let layoutLabel = layoutGraph.edge(e);
inputLabel.points = layoutLabel.points;
if (Object.hasOwn(layoutLabel, 'x')) {
inputLabel.x = layoutLabel.x;
inputLabel.y = layoutLabel.y;
}
});
inputGraph.graph().width = layoutGraph.graph().width;
inputGraph.graph().height = layoutGraph.graph().height;
}
let graphNumAttrs = ['nodesep', 'edgesep', 'ranksep', 'marginx', 'marginy'];
let graphDefaults = { ranksep: 50, edgesep: 20, nodesep: 50, rankdir: 'tb' };
let graphAttrs = ['acyclicer', 'ranker', 'rankdir', 'align'];
let nodeNumAttrs = ['width', 'height'];
let nodeDefaults = { width: 0, height: 0 };
let edgeNumAttrs = ['minlen', 'weight', 'width', 'height', 'labeloffset'];
let edgeDefaults = {
minlen: 1,
weight: 1,
width: 0,
height: 0,
labeloffset: 10,
labelpos: 'r',
};
let edgeAttrs = ['labelpos'];
/*
* Constructs a new graph from the input graph, which can be used for layout.
* This process copies only whitelisted attributes from the input graph to the
* layout graph. Thus this function serves as a good place to determine what
* attributes can influence layout.
*/
function buildLayoutGraph(inputGraph) {
let g = new Graph({ multigraph: true, compound: true });
let graph = canonicalize(inputGraph.graph());
g.setGraph(
Object.assign(
{},
graphDefaults,
selectNumberAttrs(graph, graphNumAttrs),
util.pick(graph, graphAttrs)
)
);
inputGraph.nodes().forEach((v) => {
let node = canonicalize(inputGraph.node(v));
const newNode = selectNumberAttrs(node, nodeNumAttrs);
Object.keys(nodeDefaults).forEach((k) => {
if (newNode[k] === undefined) {
newNode[k] = nodeDefaults[k];
}
});
g.setNode(v, newNode);
g.setParent(v, inputGraph.parent(v));
});
inputGraph.edges().forEach((e) => {
let edge = canonicalize(inputGraph.edge(e));
g.setEdge(
e,
Object.assign(
{},
edgeDefaults,
selectNumberAttrs(edge, edgeNumAttrs),
util.pick(edge, edgeAttrs)
)
);
});
return g;
}
/*
* This idea comes from the Gansner paper: to account for edge labels in our
* layout we split each rank in half by doubling minlen and halving ranksep.
* Then we can place labels at these mid-points between nodes.
*
* We also add some minimal padding to the width to push the label for the edge
* away from the edge itself a bit.
*/
function makeSpaceForEdgeLabels(g) {
let graph = g.graph();
graph.ranksep /= 2;
g.edges().forEach((e) => {
let edge = g.edge(e);
edge.minlen *= 2;
if (edge.labelpos.toLowerCase() !== 'c') {
if (graph.rankdir === 'TB' || graph.rankdir === 'BT') {
edge.width += edge.labeloffset;
} else {
edge.height += edge.labeloffset;
}
}
});
}
/*
* Creates temporary dummy nodes that capture the rank in which each edge's
* label is going to, if it has one of non-zero width and height. We do this
* so that we can safely remove empty ranks while preserving balance for the
* label's position.
*/
function injectEdgeLabelProxies(g) {
g.edges().forEach((e) => {
let edge = g.edge(e);
if (edge.width && edge.height) {
let v = g.node(e.v);
let w = g.node(e.w);
let label = { rank: (w.rank - v.rank) / 2 + v.rank, e: e };
util.addDummyNode(g, 'edge-proxy', label, '_ep');
}
});
}
function assignRankMinMax(g) {
let maxRank = 0;
g.nodes().forEach((v) => {
let node = g.node(v);
if (node.borderTop) {
node.minRank = g.node(node.borderTop).rank;
node.maxRank = g.node(node.borderBottom).rank;
maxRank = Math.max(maxRank, node.maxRank);
}
});
g.graph().maxRank = maxRank;
}
function removeEdgeLabelProxies(g) {
g.nodes().forEach((v) => {
let node = g.node(v);
if (node.dummy === 'edge-proxy') {
g.edge(node.e).labelRank = node.rank;
g.removeNode(v);
}
});
}
function translateGraph(g) {
let minX = Number.POSITIVE_INFINITY;
let maxX = 0;
let minY = Number.POSITIVE_INFINITY;
let maxY = 0;
let graphLabel = g.graph();
let marginX = graphLabel.marginx || 0;
let marginY = graphLabel.marginy || 0;
function getExtremes(attrs) {
let x = attrs.x;
let y = attrs.y;
let w = attrs.width;
let h = attrs.height;
minX = Math.min(minX, x - w / 2);
maxX = Math.max(maxX, x + w / 2);
minY = Math.min(minY, y - h / 2);
maxY = Math.max(maxY, y + h / 2);
}
g.nodes().forEach((v) => getExtremes(g.node(v)));
g.edges().forEach((e) => {
let edge = g.edge(e);
if (Object.hasOwn(edge, 'x')) {
getExtremes(edge);
}
});
minX -= marginX;
minY -= marginY;
g.nodes().forEach((v) => {
let node = g.node(v);
node.x -= minX;
node.y -= minY;
});
g.edges().forEach((e) => {
let edge = g.edge(e);
edge.points.forEach((p) => {
p.x -= minX;
p.y -= minY;
});
if (Object.hasOwn(edge, 'x')) {
edge.x -= minX;
}
if (Object.hasOwn(edge, 'y')) {
edge.y -= minY;
}
});
graphLabel.width = maxX - minX + marginX;
graphLabel.height = maxY - minY + marginY;
}
function assignNodeIntersects(g) {
g.edges().forEach((e) => {
let edge = g.edge(e);
let nodeV = g.node(e.v);
let nodeW = g.node(e.w);
let p1, p2;
if (!edge.points) {
edge.points = [];
p1 = nodeW;
p2 = nodeV;
} else {
p1 = edge.points[0];
p2 = edge.points[edge.points.length - 1];
}
edge.points.unshift(util.intersectRect(nodeV, p1));
edge.points.push(util.intersectRect(nodeW, p2));
});
}
function fixupEdgeLabelCoords(g) {
g.edges().forEach((e) => {
let edge = g.edge(e);
if (Object.hasOwn(edge, 'x')) {
if (edge.labelpos === 'l' || edge.labelpos === 'r') {
edge.width -= edge.labeloffset;
}
switch (edge.labelpos) {
case 'l':
edge.x -= edge.width / 2 + edge.labeloffset;
break;
case 'r':
edge.x += edge.width / 2 + edge.labeloffset;
break;
}
}
});
}
function reversePointsForReversedEdges(g) {
g.edges().forEach((e) => {
let edge = g.edge(e);
if (edge.reversed) {
edge.points.reverse();
}
});
}
function removeBorderNodes(g) {
g.nodes().forEach((v) => {
if (g.children(v).length) {
let node = g.node(v);
let t = g.node(node.borderTop);
let b = g.node(node.borderBottom);
let l = g.node(node.borderLeft[node.borderLeft.length - 1]);
let r = g.node(node.borderRight[node.borderRight.length - 1]);
node.width = Math.abs(r.x - l.x);
node.height = Math.abs(b.y - t.y);
node.x = l.x + node.width / 2;
node.y = t.y + node.height / 2;
}
});
g.nodes().forEach((v) => {
if (g.node(v).dummy === 'border') {
g.removeNode(v);
}
});
}
function removeSelfEdges(g) {
g.edges().forEach((e) => {
if (e.v === e.w) {
var node = g.node(e.v);
if (!node.selfEdges) {
node.selfEdges = [];
}
node.selfEdges.push({ e: e, label: g.edge(e) });
g.removeEdge(e);
}
});
}
function insertSelfEdges(g) {
var layers = util.buildLayerMatrix(g);
layers.forEach((layer) => {
var orderShift = 0;
layer.forEach((v, i) => {
var node = g.node(v);
node.order = i + orderShift;
(node.selfEdges || []).forEach((selfEdge) => {
util.addDummyNode(
g,
'selfedge',
{
width: selfEdge.label.width,
height: selfEdge.label.height,
rank: node.rank,
order: i + ++orderShift,
e: selfEdge.e,
label: selfEdge.label,
},
'_se'
);
});
delete node.selfEdges;
});
});
}
function positionSelfEdges(g) {
g.nodes().forEach((v) => {
var node = g.node(v);
if (node.dummy === 'selfedge') {
var selfNode = g.node(node.e.v);
var x = selfNode.x + selfNode.width / 2;
var y = selfNode.y;
var dx = node.x - x;
var dy = selfNode.height / 2;
g.setEdge(node.e, node.label);
g.removeNode(v);
node.label.points = [
{ x: x + (2 * dx) / 3, y: y - dy },
{ x: x + (5 * dx) / 6, y: y - dy },
{ x: x + dx, y: y },
{ x: x + (5 * dx) / 6, y: y + dy },
{ x: x + (2 * dx) / 3, y: y + dy },
];
node.label.x = node.x;
node.label.y = node.y;
}
});
}
function selectNumberAttrs(obj, attrs) {
return util.mapValues(util.pick(obj, attrs), Number);
}
function canonicalize(attrs) {
var newAttrs = {};
if (attrs) {
Object.entries(attrs).forEach(([k, v]) => {
if (typeof k === 'string') {
k = k.toLowerCase();
}
newAttrs[k] = v;
});
}
return newAttrs;
}
@@ -0,0 +1,133 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { util } from './util';
export const nestingGraph = {
run,
cleanup,
};
export default nestingGraph;
/*
* A nesting graph creates dummy nodes for the tops and bottoms of subgraphs,
* adds appropriate edges to ensure that all cluster nodes are placed between
* these boundaries, and ensures that the graph is connected.
*
* In addition we ensure, through the use of the minlen property, that nodes
* and subgraph border nodes to not end up on the same rank.
*
* Preconditions:
*
* 1. Input graph is a DAG
* 2. Nodes in the input graph has a minlen attribute
*
* Postconditions:
*
* 1. Input graph is connected.
* 2. Dummy nodes are added for the tops and bottoms of subgraphs.
* 3. The minlen attribute for nodes is adjusted to ensure nodes do not
* get placed on the same rank as subgraph border nodes.
*
* The nesting graph idea comes from Sander, "Layout of Compound Directed
* Graphs."
*/
function run(g) {
let root = util.addDummyNode(g, 'root', {}, '_root');
let depths = treeDepths(g);
let depthsArr = Object.values(depths);
let height = util.applyWithChunking(Math.max, depthsArr) - 1; // Note: depths is an Object not an array
let nodeSep = 2 * height + 1;
g.graph().nestingRoot = root;
// Multiply minlen by nodeSep to align nodes on non-border ranks.
g.edges().forEach((e) => (g.edge(e).minlen *= nodeSep));
// Calculate a weight that is sufficient to keep subgraphs vertically compact
let weight = sumWeights(g) + 1;
// Create border nodes and link them up
g.children().forEach((child) => dfs(g, root, nodeSep, weight, height, depths, child));
// Save the multiplier for node layers for later removal of empty border
// layers.
g.graph().nodeRankFactor = nodeSep;
}
function dfs(g, root, nodeSep, weight, height, depths, v) {
let children = g.children(v);
if (!children.length) {
if (v !== root) {
g.setEdge(root, v, { weight: 0, minlen: nodeSep });
}
return;
}
let top = util.addBorderNode(g, '_bt');
let bottom = util.addBorderNode(g, '_bb');
let label = g.node(v);
g.setParent(top, v);
label.borderTop = top;
g.setParent(bottom, v);
label.borderBottom = bottom;
children.forEach((child) => {
dfs(g, root, nodeSep, weight, height, depths, child);
let childNode = g.node(child);
let childTop = childNode.borderTop ? childNode.borderTop : child;
let childBottom = childNode.borderBottom ? childNode.borderBottom : child;
let thisWeight = childNode.borderTop ? weight : 2 * weight;
let minlen = childTop !== childBottom ? 1 : height - depths[v] + 1;
g.setEdge(top, childTop, {
weight: thisWeight,
minlen: minlen,
nestingEdge: true,
});
g.setEdge(childBottom, bottom, {
weight: thisWeight,
minlen: minlen,
nestingEdge: true,
});
});
if (!g.parent(v)) {
g.setEdge(root, top, { weight: 0, minlen: height + depths[v] });
}
}
function treeDepths(g) {
var depths = {};
function dfs(v, depth) {
var children = g.children(v);
if (children && children.length) {
children.forEach((child) => dfs(child, depth + 1));
}
depths[v] = depth;
}
g.children().forEach((v) => dfs(v, 1));
return depths;
}
function sumWeights(g) {
return g.edges().reduce((acc, e) => acc + g.edge(e).weight, 0);
}
function cleanup(g) {
var graphLabel = g.graph();
g.removeNode(graphLabel.nestingRoot);
delete graphLabel.nestingRoot;
g.edges().forEach((e) => {
var edge = g.edge(e);
if (edge.nestingEdge) {
g.removeEdge(e);
}
});
}
@@ -0,0 +1,98 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import { util } from './util';
export const normalize = {
run,
undo,
};
export default normalize;
/*
* Breaks any long edges in the graph into short segments that span 1 layer
* each. This operation is undoable with the denormalize function.
*
* Pre-conditions:
*
* 1. The input graph is a DAG.
* 2. Each node in the graph has a "rank" property.
*
* Post-condition:
*
* 1. All edges in the graph have a length of 1.
* 2. Dummy nodes are added where edges have been split into segments.
* 3. The graph is augmented with a "dummyChains" attribute which contains
* the first dummy in each chain of dummy nodes produced.
*/
function run(g) {
g.graph().dummyChains = [];
g.edges().forEach((edge) => normalizeEdge(g, edge));
}
function normalizeEdge(g, e) {
let v = e.v;
let vRank = g.node(v).rank;
let w = e.w;
let wRank = g.node(w).rank;
let name = e.name;
let edgeLabel = g.edge(e);
let labelRank = edgeLabel.labelRank;
if (wRank === vRank + 1) return;
g.removeEdge(e);
let dummy, attrs, i;
for (i = 0, ++vRank; vRank < wRank; ++i, ++vRank) {
edgeLabel.points = [];
attrs = {
width: 0,
height: 0,
edgeLabel: edgeLabel,
edgeObj: e,
rank: vRank,
};
dummy = util.addDummyNode(g, 'edge', attrs, '_d');
if (vRank === labelRank) {
attrs.width = edgeLabel.width;
attrs.height = edgeLabel.height;
attrs.dummy = 'edge-label';
attrs.labelpos = edgeLabel.labelpos;
}
g.setEdge(v, dummy, { weight: edgeLabel.weight }, name);
if (i === 0) {
g.graph().dummyChains.push(dummy);
}
v = dummy;
}
g.setEdge(v, w, { weight: edgeLabel.weight }, name);
}
function undo(g) {
g.graph().dummyChains.forEach((v) => {
let node = g.node(v);
let origLabel = node.edgeLabel;
let w;
g.setEdge(node.edgeObj, origLabel);
while (node.dummy) {
w = g.successors(v)[0];
g.removeNode(v);
origLabel.points.push({ x: node.x, y: node.y });
if (node.dummy === 'edge-label') {
origLabel.x = node.x;
origLabel.y = node.y;
origLabel.width = node.width;
origLabel.height = node.height;
}
v = w;
node = g.node(v);
}
});
}
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { addSubgraphConstraints };
export default addSubgraphConstraints;
function addSubgraphConstraints(g, cg, vs) {
let prev = {},
rootPrev;
vs.forEach((v) => {
let child = g.parent(v),
parent,
prevChild;
while (child) {
parent = g.parent(child);
if (parent) {
prevChild = prev[parent];
prev[parent] = child;
} else {
prevChild = rootPrev;
rootPrev = child;
}
if (prevChild && prevChild !== child) {
cg.setEdge(prevChild, child);
return;
}
child = parent;
}
});
/*
function dfs(v) {
var children = v ? g.children(v) : g.children();
if (children.length) {
var min = Number.POSITIVE_INFINITY,
subgraphs = [];
children.forEach(function(child) {
var childMin = dfs(child);
if (g.children(child).length) {
subgraphs.push({ v: child, order: childMin });
}
min = Math.min(min, childMin);
});
_.sortBy(subgraphs, "order").reduce(function(prev, curr) {
cg.setEdge(prev.v, curr.v);
return curr;
});
return min;
}
return g.node(v).order;
}
dfs(undefined);
*/
}
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { barycenter };
export default barycenter;
function barycenter(g, movable = []) {
return movable.map((v) => {
let inV = g.inEdges(v);
if (!inV.length) {
return { v: v };
} else {
let result = inV.reduce(
(acc, e) => {
let edge = g.edge(e),
nodeU = g.node(e.v);
return {
sum: acc.sum + edge.weight * nodeU.order,
weight: acc.weight + edge.weight,
};
},
{ sum: 0, weight: 0 }
);
return {
v: v,
barycenter: result.sum / result.weight,
weight: result.weight,
};
}
});
}
@@ -0,0 +1,80 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Graph } from '@dagrejs/graphlib';
import { util } from '../util';
export { buildLayerGraph };
export default buildLayerGraph;
/*
* Constructs a graph that can be used to sort a layer of nodes. The graph will
* contain all base and subgraph nodes from the request layer in their original
* hierarchy and any edges that are incident on these nodes and are of the type
* requested by the "relationship" parameter.
*
* Nodes from the requested rank that do not have parents are assigned a root
* node in the output graph, which is set in the root graph attribute. This
* makes it easy to walk the hierarchy of movable nodes during ordering.
*
* Pre-conditions:
*
* 1. Input graph is a DAG
* 2. Base nodes in the input graph have a rank attribute
* 3. Subgraph nodes in the input graph has minRank and maxRank attributes
* 4. Edges have an assigned weight
*
* Post-conditions:
*
* 1. Output graph has all nodes in the movable rank with preserved
* hierarchy.
* 2. Root nodes in the movable layer are made children of the node
* indicated by the root attribute of the graph.
* 3. Non-movable nodes incident on movable nodes, selected by the
* relationship parameter, are included in the graph (without hierarchy).
* 4. Edges incident on movable nodes, selected by the relationship
* parameter, are added to the output graph.
* 5. The weights for copied edges are aggregated as need, since the output
* graph is not a multi-graph.
*/
function buildLayerGraph(g, rank, relationship) {
let root = createRootNode(g),
result = new Graph({ compound: true })
.setGraph({ root: root })
.setDefaultNodeLabel((v) => g.node(v));
g.nodes().forEach((v) => {
let node = g.node(v),
parent = g.parent(v);
if (node.rank === rank || (node.minRank <= rank && rank <= node.maxRank)) {
result.setNode(v);
result.setParent(v, parent || root);
// This assumes we have only short edges!
g[relationship](v).forEach((e) => {
let u = e.v === v ? e.w : e.v,
edge = result.edge(u, v),
weight = edge !== undefined ? edge.weight : 0;
result.setEdge(u, v, { weight: g.edge(e).weight + weight });
});
if (Object.hasOwn(node, 'minRank')) {
result.setNode(v, {
borderLeft: node.borderLeft[rank],
borderRight: node.borderRight[rank],
});
}
}
});
return result;
}
function createRootNode(g) {
var v;
while (g.hasNode((v = util.uniqueId('_root'))));
return v;
}
@@ -0,0 +1,78 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import { zipObject } from '../util';
export { crossCount };
export default crossCount;
/*
* A function that takes a layering (an array of layers, each with an array of
* ordererd nodes) and a graph and returns a weighted crossing count.
*
* Pre-conditions:
*
* 1. Input graph must be simple (not a multigraph), directed, and include
* only simple edges.
* 2. Edges in the input graph must have assigned weights.
*
* Post-conditions:
*
* 1. The graph and layering matrix are left unchanged.
*
* This algorithm is derived from Barth, et al., "Bilayer Cross Counting."
*/
function crossCount(g, layering) {
let cc = 0;
for (let i = 1; i < layering.length; ++i) {
cc += twoLayerCrossCount(g, layering[i - 1], layering[i]);
}
return cc;
}
function twoLayerCrossCount(g, northLayer, southLayer) {
// Sort all of the edges between the north and south layers by their position
// in the north layer and then the south. Map these edges to the position of
// their head in the south layer.
let southPos = zipObject(
southLayer,
southLayer.map((v, i) => i)
);
let southEntries = northLayer.flatMap((v) => {
return g
.outEdges(v)
.map((e) => {
return { pos: southPos[e.w], weight: g.edge(e).weight };
})
.sort((a, b) => a.pos - b.pos);
});
// Build the accumulator tree
let firstIndex = 1;
while (firstIndex < southLayer.length) firstIndex <<= 1;
let treeSize = 2 * firstIndex - 1;
firstIndex -= 1;
let tree = new Array(treeSize).fill(0);
// Calculate the weighted crossings
let cc = 0;
southEntries.forEach((entry) => {
let index = entry.pos + firstIndex;
tree[index] += entry.weight;
let weightSum = 0;
while (index > 0) {
if (index % 2) {
weightSum += tree[index + 1];
}
index = (index - 1) >> 1;
tree[index] += entry.weight;
}
cc += entry.weight * weightSum;
});
return cc;
}
@@ -0,0 +1,86 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import initOrder from './init-order';
import crossCount from './cross-count';
import sortSubgraph from './sort-subgraph';
import buildLayerGraph from './build-layer-graph';
import addSubgraphConstraints from './add-subgraph-constraints';
import { Graph } from '@dagrejs/graphlib';
import { util } from '../util';
export default order;
/*
* Applies heuristics to minimize edge crossings in the graph and sets the best
* order solution as an order attribute on each node.
*
* Pre-conditions:
*
* 1. Graph must be DAG
* 2. Graph nodes must be objects with a "rank" attribute
* 3. Graph edges must have the "weight" attribute
*
* Post-conditions:
*
* 1. Graph nodes will have an "order" attribute based on the results of the
* algorithm.
*/
function order(g, opts) {
if (opts && typeof opts.customOrder === 'function') {
opts.customOrder(g, order);
return;
}
let maxRank = util.maxRank(g),
downLayerGraphs = buildLayerGraphs(g, util.range(1, maxRank + 1), 'inEdges'),
upLayerGraphs = buildLayerGraphs(g, util.range(maxRank - 1, -1, -1), 'outEdges');
let layering = initOrder(g);
assignOrder(g, layering);
if (opts && opts.disableOptimalOrderHeuristic) {
return;
}
let bestCC = Number.POSITIVE_INFINITY,
best;
for (let i = 0, lastBest = 0; lastBest < 4; ++i, ++lastBest) {
sweepLayerGraphs(i % 2 ? downLayerGraphs : upLayerGraphs, i % 4 >= 2);
layering = util.buildLayerMatrix(g);
let cc = crossCount(g, layering);
if (cc < bestCC) {
lastBest = 0;
best = Object.assign({}, layering);
bestCC = cc;
}
}
assignOrder(g, best);
}
function buildLayerGraphs(g, ranks, relationship) {
return ranks.map(function (rank) {
return buildLayerGraph(g, rank, relationship);
});
}
function sweepLayerGraphs(layerGraphs, biasRight) {
let cg = new Graph();
layerGraphs.forEach(function (lg) {
let root = lg.graph().root;
let sorted = sortSubgraph(lg, root, cg, biasRight);
sorted.vs.forEach((v, i) => (lg.node(v).order = i));
addSubgraphConstraints(lg, cg, sorted.vs);
});
}
function assignOrder(g, layering) {
Object.values(layering).forEach((layer) => layer.forEach((v, i) => (g.node(v).order = i)));
}
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import { util } from '../util';
export { initOrder };
export default initOrder;
/*
* Assigns an initial order value for each node by performing a DFS search
* starting from nodes in the first rank. Nodes are assigned an order in their
* rank as they are first visited.
*
* This approach comes from Gansner, et al., "A Technique for Drawing Directed
* Graphs."
*
* Returns a layering matrix with an array per layer and each layer sorted by
* the order of its nodes.
*/
function initOrder(g) {
let visited = {};
let simpleNodes = g.nodes().filter((v) => !g.children(v).length);
let simpleNodesRanks = simpleNodes.map((v) => g.node(v).rank);
let maxRank = util.applyWithChunking(Math.max, simpleNodesRanks);
let layers = util.range(maxRank + 1).map(() => []);
function dfs(v) {
if (visited[v]) return;
visited[v] = true;
let node = g.node(v);
layers[node.rank].push(v);
g.successors(v).forEach(dfs);
}
let orderedVs = simpleNodes.sort((a, b) => g.node(a).rank - g.node(b).rank);
orderedVs.forEach(dfs);
return layers;
}
@@ -0,0 +1,128 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import util from '../util';
export { resolveConflicts };
export default resolveConflicts;
/*
* Given a list of entries of the form {v, barycenter, weight} and a
* constraint graph this function will resolve any conflicts between the
* constraint graph and the barycenters for the entries. If the barycenters for
* an entry would violate a constraint in the constraint graph then we coalesce
* the nodes in the conflict into a new node that respects the contraint and
* aggregates barycenter and weight information.
*
* This implementation is based on the description in Forster, "A Fast and
* Simple Hueristic for Constrained Two-Level Crossing Reduction," thought it
* differs in some specific details.
*
* Pre-conditions:
*
* 1. Each entry has the form {v, barycenter, weight}, or if the node has
* no barycenter, then {v}.
*
* Returns:
*
* A new list of entries of the form {vs, i, barycenter, weight}. The list
* `vs` may either be a singleton or it may be an aggregation of nodes
* ordered such that they do not violate constraints from the constraint
* graph. The property `i` is the lowest original index of any of the
* elements in `vs`.
*/
function resolveConflicts(entries, cg) {
let mappedEntries = {};
entries.forEach((entry, i) => {
let tmp = (mappedEntries[entry.v] = {
indegree: 0,
in: [],
out: [],
vs: [entry.v],
i: i,
});
if (entry.barycenter !== undefined) {
tmp.barycenter = entry.barycenter;
tmp.weight = entry.weight;
}
});
cg.edges().forEach((e) => {
let entryV = mappedEntries[e.v];
let entryW = mappedEntries[e.w];
if (entryV !== undefined && entryW !== undefined) {
entryW.indegree++;
entryV.out.push(mappedEntries[e.w]);
}
});
let sourceSet = Object.values(mappedEntries).filter((entry) => !entry.indegree);
return doResolveConflicts(sourceSet);
}
function doResolveConflicts(sourceSet) {
let entries = [];
function handleIn(vEntry) {
return (uEntry) => {
if (uEntry.merged) {
return;
}
if (
uEntry.barycenter === undefined ||
vEntry.barycenter === undefined ||
uEntry.barycenter >= vEntry.barycenter
) {
mergeEntries(vEntry, uEntry);
}
};
}
function handleOut(vEntry) {
return (wEntry) => {
wEntry['in'].push(vEntry);
if (--wEntry.indegree === 0) {
sourceSet.push(wEntry);
}
};
}
while (sourceSet.length) {
let entry = sourceSet.pop();
entries.push(entry);
entry['in'].reverse().forEach(handleIn(entry));
entry.out.forEach(handleOut(entry));
}
return entries
.filter((entry) => !entry.merged)
.map((entry) => {
return util.pick(entry, ['vs', 'i', 'barycenter', 'weight']);
});
}
function mergeEntries(target, source) {
let sum = 0;
let weight = 0;
if (target.weight) {
sum += target.barycenter * target.weight;
weight += target.weight;
}
if (source.weight) {
sum += source.barycenter * source.weight;
weight += source.weight;
}
target.vs = source.vs.concat(target.vs);
target.barycenter = sum / weight;
target.weight = weight;
target.i = Math.min(source.i, target.i);
source.merged = true;
}
@@ -0,0 +1,79 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import barycenter from './barycenter';
import resolveConflicts from './resolve-conflicts';
import sort from './sort';
export { sortSubgraph };
export default sortSubgraph;
function sortSubgraph(g, v, cg, biasRight) {
let movable = g.children(v);
let node = g.node(v);
let bl = node ? node.borderLeft : undefined;
let br = node ? node.borderRight : undefined;
let subgraphs = {};
if (bl) {
movable = movable.filter((w) => w !== bl && w !== br);
}
let barycenters = barycenter(g, movable);
barycenters.forEach((entry) => {
if (g.children(entry.v).length) {
let subgraphResult = sortSubgraph(g, entry.v, cg, biasRight);
subgraphs[entry.v] = subgraphResult;
if (Object.hasOwn(subgraphResult, 'barycenter')) {
mergeBarycenters(entry, subgraphResult);
}
}
});
let entries = resolveConflicts(barycenters, cg);
expandSubgraphs(entries, subgraphs);
let result = sort(entries, biasRight);
if (bl) {
result.vs = [bl, result.vs, br].flat(true);
if (g.predecessors(bl).length) {
let blPred = g.node(g.predecessors(bl)[0]),
brPred = g.node(g.predecessors(br)[0]);
if (!Object.hasOwn(result, 'barycenter')) {
result.barycenter = 0;
result.weight = 0;
}
result.barycenter =
(result.barycenter * result.weight + blPred.order + brPred.order) / (result.weight + 2);
result.weight += 2;
}
}
return result;
}
function expandSubgraphs(entries, subgraphs) {
entries.forEach((entry) => {
entry.vs = entry.vs.flatMap((v) => {
if (subgraphs[v]) {
return subgraphs[v].vs;
}
return v;
});
});
}
function mergeBarycenters(target, other) {
if (target.barycenter !== undefined) {
target.barycenter =
(target.barycenter * target.weight + other.barycenter * other.weight) /
(target.weight + other.weight);
target.weight += other.weight;
} else {
target.barycenter = other.barycenter;
target.weight = other.weight;
}
}
@@ -0,0 +1,62 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import util from '../util';
export { sort };
export default sort;
function sort(entries, biasRight) {
let parts = util.partition(entries, (entry) => {
return Object.hasOwn(entry, 'barycenter');
});
let sortable = parts.lhs,
unsortable = parts.rhs.sort((a, b) => b.i - a.i),
vs = [],
sum = 0,
weight = 0,
vsIndex = 0;
sortable.sort(compareWithBias(!!biasRight));
vsIndex = consumeUnsortable(vs, unsortable, vsIndex);
sortable.forEach((entry) => {
vsIndex += entry.vs.length;
vs.push(entry.vs);
sum += entry.barycenter * entry.weight;
weight += entry.weight;
vsIndex = consumeUnsortable(vs, unsortable, vsIndex);
});
let result = { vs: vs.flat(true) };
if (weight) {
result.barycenter = sum / weight;
result.weight = weight;
}
return result;
}
function consumeUnsortable(vs, unsortable, index) {
let last;
while (unsortable.length && (last = unsortable[unsortable.length - 1]).i <= index) {
unsortable.pop();
vs.push(last.vs);
index++;
}
return index;
}
function compareWithBias(bias) {
return (entryV, entryW) => {
if (entryV.barycenter < entryW.barycenter) {
return -1;
} else if (entryV.barycenter > entryW.barycenter) {
return 1;
}
return !bias ? entryV.i - entryW.i : entryW.i - entryV.i;
};
}
@@ -0,0 +1,90 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { parentDummyChains };
export default parentDummyChains;
function parentDummyChains(g) {
let postorderNums = postorder(g);
g.graph().dummyChains.forEach((v) => {
let node = g.node(v);
let edgeObj = node.edgeObj;
let pathData = findPath(g, postorderNums, edgeObj.v, edgeObj.w);
let path = pathData.path;
let lca = pathData.lca;
let pathIdx = 0;
let pathV = path[pathIdx];
let ascending = true;
while (v !== edgeObj.w) {
node = g.node(v);
if (ascending) {
while ((pathV = path[pathIdx]) !== lca && g.node(pathV).maxRank < node.rank) {
pathIdx++;
}
if (pathV === lca) {
ascending = false;
}
}
if (!ascending) {
while (
pathIdx < path.length - 1 &&
g.node((pathV = path[pathIdx + 1])).minRank <= node.rank
) {
pathIdx++;
}
pathV = path[pathIdx];
}
g.setParent(v, pathV);
v = g.successors(v)[0];
}
});
}
// Find a path from v to w through the lowest common ancestor (LCA). Return the
// full path and the LCA.
function findPath(g, postorderNums, v, w) {
let vPath = [];
let wPath = [];
let low = Math.min(postorderNums[v].low, postorderNums[w].low);
let lim = Math.max(postorderNums[v].lim, postorderNums[w].lim);
let parent;
let lca;
// Traverse up from v to find the LCA
parent = v;
do {
parent = g.parent(parent);
vPath.push(parent);
} while (parent && (postorderNums[parent].low > low || lim > postorderNums[parent].lim));
lca = parent;
// Traverse from w to LCA
parent = w;
while ((parent = g.parent(parent)) !== lca) {
wPath.push(parent);
}
return { path: vPath.concat(wPath.reverse()), lca: lca };
}
function postorder(g) {
let result = {};
let lim = 0;
function dfs(v) {
let low = lim;
g.children(v).forEach(dfs);
result[v] = { low: low, lim: lim++ };
}
g.children().forEach(dfs);
return result;
}
@@ -0,0 +1,431 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import { Graph } from '@dagrejs/graphlib';
import { util } from '../util';
/*
* This module provides coordinate assignment based on Brandes and Köpf, "Fast
* and Simple Horizontal Coordinate Assignment."
*/
export {
positionX,
findType1Conflicts,
findType2Conflicts,
addConflict,
hasConflict,
verticalAlignment,
horizontalCompaction,
alignCoordinates,
findSmallestWidthAlignment,
balance,
};
/*
* Marks all edges in the graph with a type-1 conflict with the "type1Conflict"
* property. A type-1 conflict is one where a non-inner segment crosses an
* inner segment. An inner segment is an edge with both incident nodes marked
* with the "dummy" property.
*
* This algorithm scans layer by layer, starting with the second, for type-1
* conflicts between the current layer and the previous layer. For each layer
* it scans the nodes from left to right until it reaches one that is incident
* on an inner segment. It then scans predecessors to determine if they have
* edges that cross that inner segment. At the end a final scan is done for all
* nodes on the current rank to see if they cross the last visited inner
* segment.
*
* This algorithm (safely) assumes that a dummy node will only be incident on a
* single node in the layers being scanned.
*/
function findType1Conflicts(g, layering) {
let conflicts = {};
function visitLayer(prevLayer, layer) {
let // last visited node in the previous layer that is incident on an inner
// segment.
k0 = 0,
// Tracks the last node in this layer scanned for crossings with a type-1
// segment.
scanPos = 0,
prevLayerLength = prevLayer.length,
lastNode = layer[layer.length - 1];
layer.forEach((v, i) => {
let w = findOtherInnerSegmentNode(g, v),
k1 = w ? g.node(w).order : prevLayerLength;
if (w || v === lastNode) {
layer.slice(scanPos, i + 1).forEach((scanNode) => {
g.predecessors(scanNode).forEach((u) => {
let uLabel = g.node(u),
uPos = uLabel.order;
if ((uPos < k0 || k1 < uPos) && !(uLabel.dummy && g.node(scanNode).dummy)) {
addConflict(conflicts, u, scanNode);
}
});
});
scanPos = i + 1;
k0 = k1;
}
});
return layer;
}
layering.length && layering.reduce(visitLayer);
return conflicts;
}
function findType2Conflicts(g, layering) {
let conflicts = {};
function scan(south, southPos, southEnd, prevNorthBorder, nextNorthBorder) {
let v;
util.range(southPos, southEnd).forEach((i) => {
v = south[i];
if (g.node(v).dummy) {
g.predecessors(v).forEach((u) => {
let uNode = g.node(u);
if (uNode.dummy && (uNode.order < prevNorthBorder || uNode.order > nextNorthBorder)) {
addConflict(conflicts, u, v);
}
});
}
});
}
function visitLayer(north, south) {
let prevNorthPos = -1,
nextNorthPos,
southPos = 0;
south.forEach((v, southLookahead) => {
if (g.node(v).dummy === 'border') {
let predecessors = g.predecessors(v);
if (predecessors.length) {
nextNorthPos = g.node(predecessors[0]).order;
scan(south, southPos, southLookahead, prevNorthPos, nextNorthPos);
southPos = southLookahead;
prevNorthPos = nextNorthPos;
}
}
scan(south, southPos, south.length, nextNorthPos, north.length);
});
return south;
}
layering.length && layering.reduce(visitLayer);
return conflicts;
}
function findOtherInnerSegmentNode(g, v) {
if (g.node(v).dummy) {
return g.predecessors(v).find((u) => g.node(u).dummy);
}
}
function addConflict(conflicts, v, w) {
if (v > w) {
let tmp = v;
v = w;
w = tmp;
}
let conflictsV = conflicts[v];
if (!conflictsV) {
conflicts[v] = conflictsV = {};
}
conflictsV[w] = true;
}
function hasConflict(conflicts, v, w) {
if (v > w) {
let tmp = v;
v = w;
w = tmp;
}
return !!conflicts[v] && Object.hasOwn(conflicts[v], w);
}
/*
* Try to align nodes into vertical "blocks" where possible. This algorithm
* attempts to align a node with one of its median neighbors. If the edge
* connecting a neighbor is a type-1 conflict then we ignore that possibility.
* If a previous node has already formed a block with a node after the node
* we're trying to form a block with, we also ignore that possibility - our
* blocks would be split in that scenario.
*/
function verticalAlignment(g, layering, conflicts, neighborFn) {
let root = {},
align = {},
pos = {};
// We cache the position here based on the layering because the graph and
// layering may be out of sync. The layering matrix is manipulated to
// generate different extreme alignments.
layering.forEach((layer) => {
layer.forEach((v, order) => {
root[v] = v;
align[v] = v;
pos[v] = order;
});
});
layering.forEach((layer) => {
let prevIdx = -1;
layer.forEach((v) => {
let ws = neighborFn(v);
if (ws.length) {
ws = ws.sort((a, b) => pos[a] - pos[b]);
let mp = (ws.length - 1) / 2;
for (let i = Math.floor(mp), il = Math.ceil(mp); i <= il; ++i) {
let w = ws[i];
if (align[v] === v && prevIdx < pos[w] && !hasConflict(conflicts, v, w)) {
align[w] = v;
align[v] = root[v] = root[w];
prevIdx = pos[w];
}
}
}
});
});
return { root: root, align: align };
}
function horizontalCompaction(g, layering, root, align, reverseSep) {
// This portion of the algorithm differs from BK due to a number of problems.
// Instead of their algorithm we construct a new block graph and do two
// sweeps. The first sweep places blocks with the smallest possible
// coordinates. The second sweep removes unused space by moving blocks to the
// greatest coordinates without violating separation.
let xs = {},
blockG = buildBlockGraph(g, layering, root, reverseSep),
borderType = reverseSep ? 'borderLeft' : 'borderRight';
function iterate(setXsFunc, nextNodesFunc) {
let stack = blockG.nodes();
let elem = stack.pop();
let visited = {};
while (elem) {
if (visited[elem]) {
setXsFunc(elem);
} else {
visited[elem] = true;
stack.push(elem);
stack = stack.concat(nextNodesFunc(elem));
}
elem = stack.pop();
}
}
// First pass, assign smallest coordinates
function pass1(elem) {
xs[elem] = blockG.inEdges(elem).reduce((acc, e) => {
return Math.max(acc, xs[e.v] + blockG.edge(e));
}, 0);
}
// Second pass, assign greatest coordinates
function pass2(elem) {
let min = blockG.outEdges(elem).reduce((acc, e) => {
return Math.min(acc, xs[e.w] - blockG.edge(e));
}, Number.POSITIVE_INFINITY);
let node = g.node(elem);
if (min !== Number.POSITIVE_INFINITY && node.borderType !== borderType) {
xs[elem] = Math.max(xs[elem], min);
}
}
iterate(pass1, blockG.predecessors.bind(blockG));
iterate(pass2, blockG.successors.bind(blockG));
// Assign x coordinates to all nodes
Object.keys(align).forEach((v) => (xs[v] = xs[root[v]]));
return xs;
}
function buildBlockGraph(g, layering, root, reverseSep) {
let blockGraph = new Graph(),
graphLabel = g.graph(),
sepFn = sep(graphLabel.nodesep, graphLabel.edgesep, reverseSep);
layering.forEach((layer) => {
let u;
layer.forEach((v) => {
let vRoot = root[v];
blockGraph.setNode(vRoot);
if (u) {
var uRoot = root[u],
prevMax = blockGraph.edge(uRoot, vRoot);
blockGraph.setEdge(uRoot, vRoot, Math.max(sepFn(g, v, u), prevMax || 0));
}
u = v;
});
});
return blockGraph;
}
/*
* Returns the alignment that has the smallest width of the given alignments.
*/
function findSmallestWidthAlignment(g, xss) {
return Object.values(xss).reduce(
(currentMinAndXs, xs) => {
let max = Number.NEGATIVE_INFINITY;
let min = Number.POSITIVE_INFINITY;
Object.entries(xs).forEach(([v, x]) => {
let halfWidth = width(g, v) / 2;
max = Math.max(x + halfWidth, max);
min = Math.min(x - halfWidth, min);
});
const newMin = max - min;
if (newMin < currentMinAndXs[0]) {
currentMinAndXs = [newMin, xs];
}
return currentMinAndXs;
},
[Number.POSITIVE_INFINITY, null]
)[1];
}
/*
* Align the coordinates of each of the layout alignments such that
* left-biased alignments have their minimum coordinate at the same point as
* the minimum coordinate of the smallest width alignment and right-biased
* alignments have their maximum coordinate at the same point as the maximum
* coordinate of the smallest width alignment.
*/
function alignCoordinates(xss, alignTo) {
let alignToVals = Object.values(alignTo),
alignToMin = util.applyWithChunking(Math.min, alignToVals),
alignToMax = util.applyWithChunking(Math.max, alignToVals);
['u', 'd'].forEach((vert) => {
['l', 'r'].forEach((horiz) => {
let alignment = vert + horiz,
xs = xss[alignment];
if (xs === alignTo) return;
let xsVals = Object.values(xs);
let delta = alignToMin - util.applyWithChunking(Math.min, xsVals);
if (horiz !== 'l') {
delta = alignToMax - util.applyWithChunking(Math.max, xsVals);
}
if (delta) {
xss[alignment] = util.mapValues(xs, (x) => x + delta);
}
});
});
}
function balance(xss, align) {
return util.mapValues(xss.ul, (num, v) => {
if (align) {
return xss[align.toLowerCase()][v];
} else {
let xs = Object.values(xss)
.map((xs) => xs[v])
.sort((a, b) => a - b);
return (xs[1] + xs[2]) / 2;
}
});
}
function positionX(g) {
let layering = util.buildLayerMatrix(g);
let conflicts = Object.assign(findType1Conflicts(g, layering), findType2Conflicts(g, layering));
let xss = {};
let adjustedLayering;
['u', 'd'].forEach((vert) => {
adjustedLayering = vert === 'u' ? layering : Object.values(layering).reverse();
['l', 'r'].forEach((horiz) => {
if (horiz === 'r') {
adjustedLayering = adjustedLayering.map((inner) => {
return Object.values(inner).reverse();
});
}
let neighborFn = (vert === 'u' ? g.predecessors : g.successors).bind(g);
let align = verticalAlignment(g, adjustedLayering, conflicts, neighborFn);
let xs = horizontalCompaction(g, adjustedLayering, align.root, align.align, horiz === 'r');
if (horiz === 'r') {
xs = util.mapValues(xs, (x) => -x);
}
xss[vert + horiz] = xs;
});
});
let smallestWidth = findSmallestWidthAlignment(g, xss);
alignCoordinates(xss, smallestWidth);
return balance(xss, g.graph().align);
}
function sep(nodeSep, edgeSep, reverseSep) {
return (g, v, w) => {
let vLabel = g.node(v);
let wLabel = g.node(w);
let sum = 0;
let delta;
sum += vLabel.width / 2;
if (Object.hasOwn(vLabel, 'labelpos')) {
switch (vLabel.labelpos.toLowerCase()) {
case 'l':
delta = -vLabel.width / 2;
break;
case 'r':
delta = vLabel.width / 2;
break;
}
}
if (delta) {
sum += reverseSep ? delta : -delta;
}
delta = 0;
sum += (vLabel.dummy ? edgeSep : nodeSep) / 2;
sum += (wLabel.dummy ? edgeSep : nodeSep) / 2;
sum += wLabel.width / 2;
if (Object.hasOwn(wLabel, 'labelpos')) {
switch (wLabel.labelpos.toLowerCase()) {
case 'l':
delta = wLabel.width / 2;
break;
case 'r':
delta = -wLabel.width / 2;
break;
}
}
if (delta) {
sum += reverseSep ? delta : -delta;
}
delta = 0;
return sum;
};
}
function width(g, v) {
return g.node(v).width;
}
@@ -0,0 +1,37 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import util from '../util';
import { positionX } from './bk';
export { position };
export default position;
function position(g) {
g = util.asNonCompoundGraph(g);
positionY(g);
Object.entries(positionX(g)).forEach(([v, x]) => (g.node(v).x = x));
}
function positionY(g) {
let layering = util.buildLayerMatrix(g);
let rankSep = g.graph().ranksep;
let prevY = 0;
layering.forEach((layer) => {
const maxHeight = layer.reduce((acc, v) => {
const height = g.node(v).height;
if (acc > height) {
return acc;
} else {
return height;
}
}, 0);
layer.forEach((v) => (g.node(v).y = prevY + maxHeight / 2));
prevY += maxHeight + rankSep;
});
}
@@ -0,0 +1,104 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import { Graph } from '@dagrejs/graphlib';
import { slack } from './util';
export { feasibleTree };
export default feasibleTree;
/*
* Constructs a spanning tree with tight edges and adjusted the input node's
* ranks to achieve this. A tight edge is one that is has a length that matches
* its "minlen" attribute.
*
* The basic structure for this function is derived from Gansner, et al., "A
* Technique for Drawing Directed Graphs."
*
* Pre-conditions:
*
* 1. Graph must be a DAG.
* 2. Graph must be connected.
* 3. Graph must have at least one node.
* 5. Graph nodes must have been previously assigned a "rank" property that
* respects the "minlen" property of incident edges.
* 6. Graph edges must have a "minlen" property.
*
* Post-conditions:
*
* - Graph nodes will have their rank adjusted to ensure that all edges are
* tight.
*
* Returns a tree (undirected graph) that is constructed using only "tight"
* edges.
*/
function feasibleTree(g) {
var t = new Graph({ directed: false });
// Choose arbitrary node from which to start our tree
var start = g.nodes()[0];
var size = g.nodeCount();
t.setNode(start, {});
var edge, delta;
while (tightTree(t, g) < size) {
edge = findMinSlackEdge(t, g);
delta = t.hasNode(edge.v) ? slack(g, edge) : -slack(g, edge);
shiftRanks(t, g, delta);
}
return t;
}
/*
* Finds a maximal tree of tight edges and returns the number of nodes in the
* tree.
*/
function tightTree(t, g) {
function dfs(v) {
g.nodeEdges(v).forEach((e) => {
var edgeV = e.v,
w = v === edgeV ? e.w : edgeV;
if (!t.hasNode(w) && !slack(g, e)) {
t.setNode(w, {});
t.setEdge(v, w, {});
dfs(w);
}
});
}
t.nodes().forEach(dfs);
return t.nodeCount();
}
/*
* Finds the edge with the smallest slack that is incident on tree and returns
* it.
*/
function findMinSlackEdge(t, g) {
const edges = g.edges();
return edges.reduce(
(acc, edge) => {
let edgeSlack = Number.POSITIVE_INFINITY;
if (t.hasNode(edge.v) !== t.hasNode(edge.w)) {
edgeSlack = slack(g, edge);
}
if (edgeSlack < acc[0]) {
return [edgeSlack, edge];
}
return acc;
},
[Number.POSITIVE_INFINITY, null]
)[1];
}
function shiftRanks(t, g, delta) {
t.nodes().forEach((v) => (g.node(v).rank += delta));
}
@@ -0,0 +1,61 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import rankUtil from './util';
import { longestPath } from './util';
import feasibleTree from './feasible-tree';
import networkSimplex from './network-simplex';
export { rank };
export default rank;
/*
* Assigns a rank to each node in the input graph that respects the "minlen"
* constraint specified on edges between nodes.
*
* This basic structure is derived from Gansner, et al., "A Technique for
* Drawing Directed Graphs."
*
* Pre-conditions:
*
* 1. Graph must be a connected DAG
* 2. Graph nodes must be objects
* 3. Graph edges must have "weight" and "minlen" attributes
*
* Post-conditions:
*
* 1. Graph nodes will have a "rank" attribute based on the results of the
* algorithm. Ranks can start at any index (including negative), we'll
* fix them up later.
*/
function rank(g) {
switch (g.graph().ranker) {
case 'network-simplex':
networkSimplexRanker(g);
break;
case 'tight-tree':
tightTreeRanker(g);
break;
case 'longest-path':
longestPathRanker(g);
break;
default:
networkSimplexRanker(g);
}
}
// A fast and simple ranker, but results are far from optimal.
var longestPathRanker = longestPath;
function tightTreeRanker(g) {
longestPath(g);
feasibleTree(g);
}
function networkSimplexRanker(g) {
networkSimplex(g);
}
@@ -0,0 +1,243 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import { alg } from '@dagrejs/graphlib';
import { feasibleTree } from './feasible-tree';
import { slack, longestPath as initRank } from './util';
import { simplify } from '../util';
const { preorder, postorder } = alg;
export { networkSimplex };
export default networkSimplex;
// Expose some internals for testing purposes
networkSimplex.initLowLimValues = initLowLimValues;
networkSimplex.initCutValues = initCutValues;
networkSimplex.calcCutValue = calcCutValue;
networkSimplex.leaveEdge = leaveEdge;
networkSimplex.enterEdge = enterEdge;
networkSimplex.exchangeEdges = exchangeEdges;
/*
* The network simplex algorithm assigns ranks to each node in the input graph
* and iteratively improves the ranking to reduce the length of edges.
*
* Preconditions:
*
* 1. The input graph must be a DAG.
* 2. All nodes in the graph must have an object value.
* 3. All edges in the graph must have "minlen" and "weight" attributes.
*
* Postconditions:
*
* 1. All nodes in the graph will have an assigned "rank" attribute that has
* been optimized by the network simplex algorithm. Ranks start at 0.
*
*
* A rough sketch of the algorithm is as follows:
*
* 1. Assign initial ranks to each node. We use the longest path algorithm,
* which assigns ranks to the lowest position possible. In general this
* leads to very wide bottom ranks and unnecessarily long edges.
* 2. Construct a feasible tight tree. A tight tree is one such that all
* edges in the tree have no slack (difference between length of edge
* and minlen for the edge). This by itself greatly improves the assigned
* rankings by shorting edges.
* 3. Iteratively find edges that have negative cut values. Generally a
* negative cut value indicates that the edge could be removed and a new
* tree edge could be added to produce a more compact graph.
*
* Much of the algorithms here are derived from Gansner, et al., "A Technique
* for Drawing Directed Graphs." The structure of the file roughly follows the
* structure of the overall algorithm.
*/
function networkSimplex(g) {
g = simplify(g);
initRank(g);
var t = feasibleTree(g);
initLowLimValues(t);
initCutValues(t, g);
var e, f;
while ((e = leaveEdge(t))) {
f = enterEdge(t, g, e);
exchangeEdges(t, g, e, f);
}
}
/*
* Initializes cut values for all edges in the tree.
*/
function initCutValues(t, g) {
var vs = postorder(t, t.nodes());
vs = vs.slice(0, vs.length - 1);
vs.forEach((v) => assignCutValue(t, g, v));
}
function assignCutValue(t, g, child) {
var childLab = t.node(child);
var parent = childLab.parent;
t.edge(child, parent).cutvalue = calcCutValue(t, g, child);
}
/*
* Given the tight tree, its graph, and a child in the graph calculate and
* return the cut value for the edge between the child and its parent.
*/
function calcCutValue(t, g, child) {
var childLab = t.node(child);
var parent = childLab.parent;
// True if the child is on the tail end of the edge in the directed graph
var childIsTail = true;
// The graph's view of the tree edge we're inspecting
var graphEdge = g.edge(child, parent);
// The accumulated cut value for the edge between this node and its parent
var cutValue = 0;
if (!graphEdge) {
childIsTail = false;
graphEdge = g.edge(parent, child);
}
cutValue = graphEdge.weight;
g.nodeEdges(child).forEach((e) => {
var isOutEdge = e.v === child,
other = isOutEdge ? e.w : e.v;
if (other !== parent) {
var pointsToHead = isOutEdge === childIsTail,
otherWeight = g.edge(e).weight;
cutValue += pointsToHead ? otherWeight : -otherWeight;
if (isTreeEdge(t, child, other)) {
var otherCutValue = t.edge(child, other).cutvalue;
cutValue += pointsToHead ? -otherCutValue : otherCutValue;
}
}
});
return cutValue;
}
function initLowLimValues(tree, root) {
if (arguments.length < 2) {
root = tree.nodes()[0];
}
dfsAssignLowLim(tree, {}, 1, root);
}
function dfsAssignLowLim(tree, visited, nextLim, v, parent) {
var low = nextLim;
var label = tree.node(v);
visited[v] = true;
tree.neighbors(v).forEach((w) => {
if (!Object.hasOwn(visited, w)) {
nextLim = dfsAssignLowLim(tree, visited, nextLim, w, v);
}
});
label.low = low;
label.lim = nextLim++;
if (parent) {
label.parent = parent;
} else {
// TODO should be able to remove this when we incrementally update low lim
delete label.parent;
}
return nextLim;
}
function leaveEdge(tree) {
return tree.edges().find((e) => tree.edge(e).cutvalue < 0);
}
function enterEdge(t, g, edge) {
var v = edge.v;
var w = edge.w;
// For the rest of this function we assume that v is the tail and w is the
// head, so if we don't have this edge in the graph we should flip it to
// match the correct orientation.
if (!g.hasEdge(v, w)) {
v = edge.w;
w = edge.v;
}
var vLabel = t.node(v);
var wLabel = t.node(w);
var tailLabel = vLabel;
var flip = false;
// If the root is in the tail of the edge then we need to flip the logic that
// checks for the head and tail nodes in the candidates function below.
if (vLabel.lim > wLabel.lim) {
tailLabel = wLabel;
flip = true;
}
var candidates = g.edges().filter((edge) => {
return (
flip === isDescendant(t, t.node(edge.v), tailLabel) &&
flip !== isDescendant(t, t.node(edge.w), tailLabel)
);
});
return candidates.reduce((acc, edge) => {
if (slack(g, edge) < slack(g, acc)) {
return edge;
}
return acc;
});
}
function exchangeEdges(t, g, e, f) {
var v = e.v;
var w = e.w;
t.removeEdge(v, w);
t.setEdge(f.v, f.w, {});
initLowLimValues(t);
initCutValues(t, g);
updateRanks(t, g);
}
function updateRanks(t, g) {
var root = t.nodes().find((v) => !g.node(v).parent);
var vs = preorder(t, root);
vs = vs.slice(1);
vs.forEach((v) => {
var parent = t.node(v).parent,
edge = g.edge(v, parent),
flipped = false;
if (!edge) {
edge = g.edge(parent, v);
flipped = true;
}
g.node(v).rank = g.node(parent).rank + (flipped ? edge.minlen : -edge.minlen);
});
}
/*
* Returns true if the edge is in the tree.
*/
function isTreeEdge(tree, u, v) {
return tree.hasEdge(u, v);
}
/*
* Returns true if the specified node is descendant of the root node per the
* assigned low and lim attributes in the tree.
*/
function isDescendant(tree, vLabel, rootLabel) {
return rootLabel.low <= vLabel.lim && vLabel.lim <= rootLabel.lim;
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
'use strict';
import { applyWithChunking } from '../util';
export { longestPath, slack };
export default { longestPath, slack };
/*
* Initializes ranks for the input graph using the longest path algorithm. This
* algorithm scales well and is fast in practice, it yields rather poor
* solutions. Nodes are pushed to the lowest layer possible, leaving the bottom
* ranks wide and leaving edges longer than necessary. However, due to its
* speed, this algorithm is good for getting an initial ranking that can be fed
* into other algorithms.
*
* This algorithm does not normalize layers because it will be used by other
* algorithms in most cases. If using this algorithm directly, be sure to
* run normalize at the end.
*
* Pre-conditions:
*
* 1. Input graph is a DAG.
* 2. Input graph node labels can be assigned properties.
*
* Post-conditions:
*
* 1. Each node will be assign an (unnormalized) "rank" property.
*/
function longestPath(g) {
var visited = {};
function dfs(v) {
var label = g.node(v);
if (Object.hasOwn(visited, v)) {
return label.rank;
}
visited[v] = true;
let outEdgesMinLens = g.outEdges(v).map((e) => {
if (e == null) {
return Number.POSITIVE_INFINITY;
}
return dfs(e.w) - g.edge(e).minlen;
});
var rank = applyWithChunking(Math.min, outEdgesMinLens);
if (rank === Number.POSITIVE_INFINITY) {
rank = 0;
}
return (label.rank = rank);
}
g.sources().forEach(dfs);
}
/*
* Returns the amount of slack for the given edge. The slack is defined as the
* difference between the length of the edge and its minimum length.
*/
function slack(g, e) {
return g.node(e.w).rank - g.node(e.v).rank - g.edge(e).minlen;
}
@@ -0,0 +1,365 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint "no-console": off */
'use strict';
import { Graph } from '@dagrejs/graphlib';
const util = {
addBorderNode,
addDummyNode,
applyWithChunking,
asNonCompoundGraph,
buildLayerMatrix,
intersectRect,
mapValues,
maxRank,
normalizeRanks,
notime,
partition,
pick,
predecessorWeights,
range,
removeEmptyRanks,
simplify,
successorWeights,
time,
uniqueId,
zipObject,
};
export {
util,
addBorderNode,
addDummyNode,
applyWithChunking,
asNonCompoundGraph,
buildLayerMatrix,
intersectRect,
mapValues,
maxRank,
normalizeRanks,
notime,
partition,
pick,
predecessorWeights,
range,
removeEmptyRanks,
simplify,
successorWeights,
time,
uniqueId,
zipObject,
};
export default util;
/*
* Adds a dummy node to the graph and return v.
*/
function addDummyNode(g, type, attrs, name) {
let v;
do {
v = uniqueId(name);
} while (g.hasNode(v));
attrs.dummy = type;
g.setNode(v, attrs);
return v;
}
/*
* Returns a new graph with only simple edges. Handles aggregation of data
* associated with multi-edges.
*/
function simplify(g) {
let simplified = new Graph().setGraph(g.graph());
g.nodes().forEach((v) => simplified.setNode(v, g.node(v)));
g.edges().forEach((e) => {
let simpleLabel = simplified.edge(e.v, e.w) || { weight: 0, minlen: 1 };
let label = g.edge(e);
simplified.setEdge(e.v, e.w, {
weight: simpleLabel.weight + label.weight,
minlen: Math.max(simpleLabel.minlen, label.minlen),
});
});
return simplified;
}
function asNonCompoundGraph(g) {
let simplified = new Graph({ multigraph: g.isMultigraph() }).setGraph(g.graph());
g.nodes().forEach((v) => {
if (!g.children(v).length) {
simplified.setNode(v, g.node(v));
}
});
g.edges().forEach((e) => {
simplified.setEdge(e, g.edge(e));
});
return simplified;
}
function successorWeights(g) {
let weightMap = g.nodes().map((v) => {
let sucs = {};
g.outEdges(v).forEach((e) => {
sucs[e.w] = (sucs[e.w] || 0) + g.edge(e).weight;
});
return sucs;
});
return zipObject(g.nodes(), weightMap);
}
function predecessorWeights(g) {
let weightMap = g.nodes().map((v) => {
let preds = {};
g.inEdges(v).forEach((e) => {
preds[e.v] = (preds[e.v] || 0) + g.edge(e).weight;
});
return preds;
});
return zipObject(g.nodes(), weightMap);
}
/*
* Finds where a line starting at point ({x, y}) would intersect a rectangle
* ({x, y, width, height}) if it were pointing at the rectangle's center.
*/
function intersectRect(rect, point) {
let x = rect.x;
let y = rect.y;
// Rectangle intersection algorithm from:
// http://math.stackexchange.com/questions/108113/find-edge-between-two-boxes
let dx = point.x - x;
let dy = point.y - y;
let w = rect.width / 2;
let h = rect.height / 2;
if (!dx && !dy) {
throw new Error('Not possible to find intersection inside of the rectangle');
}
let sx, sy;
if (Math.abs(dy) * w > Math.abs(dx) * h) {
// Intersection is top or bottom of rect.
if (dy < 0) {
h = -h;
}
sx = (h * dx) / dy;
sy = h;
} else {
// Intersection is left or right of rect.
if (dx < 0) {
w = -w;
}
sx = w;
sy = (w * dy) / dx;
}
return { x: x + sx, y: y + sy };
}
/*
* Given a DAG with each node assigned "rank" and "order" properties, this
* function will produce a matrix with the ids of each node.
*/
function buildLayerMatrix(g) {
let layering = range(maxRank(g) + 1).map(() => []);
g.nodes().forEach((v) => {
let node = g.node(v);
let rank = node.rank;
if (rank !== undefined) {
layering[rank][node.order] = v;
}
});
return layering;
}
/*
* Adjusts the ranks for all nodes in the graph such that all nodes v have
* rank(v) >= 0 and at least one node w has rank(w) = 0.
*/
function normalizeRanks(g) {
let nodeRanks = g.nodes().map((v) => {
let rank = g.node(v).rank;
if (rank === undefined) {
return Number.MAX_VALUE;
}
return rank;
});
let min = applyWithChunking(Math.min, nodeRanks);
g.nodes().forEach((v) => {
let node = g.node(v);
if (Object.hasOwn(node, 'rank')) {
node.rank -= min;
}
});
}
function removeEmptyRanks(g) {
// Ranks may not start at 0, so we need to offset them
let nodeRanks = g.nodes().map((v) => g.node(v).rank);
let offset = applyWithChunking(Math.min, nodeRanks);
let layers = [];
g.nodes().forEach((v) => {
let rank = g.node(v).rank - offset;
if (!layers[rank]) {
layers[rank] = [];
}
layers[rank].push(v);
});
let delta = 0;
let nodeRankFactor = g.graph().nodeRankFactor;
Array.from(layers).forEach((vs, i) => {
if (vs === undefined && i % nodeRankFactor !== 0) {
--delta;
} else if (vs !== undefined && delta) {
vs.forEach((v) => (g.node(v).rank += delta));
}
});
}
function addBorderNode(g, prefix, rank, order) {
let node = {
width: 0,
height: 0,
};
if (arguments.length >= 4) {
node.rank = rank;
node.order = order;
}
return addDummyNode(g, 'border', node, prefix);
}
function splitToChunks(array, chunkSize = CHUNKING_THRESHOLD) {
const chunks = [];
for (let i = 0; i < array.length; i += chunkSize) {
const chunk = array.slice(i, i + chunkSize);
chunks.push(chunk);
}
return chunks;
}
const CHUNKING_THRESHOLD = 65535;
function applyWithChunking(fn, argsArray) {
if (argsArray.length > CHUNKING_THRESHOLD) {
const chunks = splitToChunks(argsArray);
return fn.apply(
null,
chunks.map((chunk) => fn.apply(null, chunk))
);
} else {
return fn.apply(null, argsArray);
}
}
function maxRank(g) {
const nodes = g.nodes();
const nodeRanks = nodes.map((v) => {
let rank = g.node(v).rank;
if (rank === undefined) {
return Number.MIN_VALUE;
}
return rank;
});
return applyWithChunking(Math.max, nodeRanks);
}
/*
* Partition a collection into two groups: `lhs` and `rhs`. If the supplied
* function returns true for an entry it goes into `lhs`. Otherwise it goes
* into `rhs.
*/
function partition(collection, fn) {
let result = { lhs: [], rhs: [] };
collection.forEach((value) => {
if (fn(value)) {
result.lhs.push(value);
} else {
result.rhs.push(value);
}
});
return result;
}
/*
* Returns a new function that wraps `fn` with a timer. The wrapper logs the
* time it takes to execute the function.
*/
function time(name, fn) {
let start = Date.now();
try {
return fn();
} finally {
console.log(name + ' time: ' + (Date.now() - start) + 'ms');
}
}
function notime(name, fn) {
return fn();
}
let idCounter = 0;
function uniqueId(prefix) {
var id = ++idCounter;
return toString(prefix) + id;
}
function range(start, limit, step = 1) {
if (limit == null) {
limit = start;
start = 0;
}
let endCon = (i) => i < limit;
if (step < 0) {
endCon = (i) => limit < i;
}
const range = [];
for (let i = start; endCon(i); i += step) {
range.push(i);
}
return range;
}
function pick(source, keys) {
const dest = {};
for (const key of keys) {
if (source[key] !== undefined) {
dest[key] = source[key];
}
}
return dest;
}
function mapValues(obj, funcOrProp) {
let func = funcOrProp;
if (typeof funcOrProp === 'string') {
func = (val) => val[funcOrProp];
}
return Object.entries(obj).reduce((acc, [k, v]) => {
acc[k] = func(v, k);
return acc;
}, {});
}
function zipObject(props, values) {
return props.reduce((acc, key, i) => {
acc[key] = values[i];
return acc;
}, {});
}
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export default '1.1.5-pre';
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { dagreLib } from './dagre-lib';
export { createFreeAutoLayoutPlugin } from './create-auto-layout-plugin';
export { AutoLayoutService } from './services';
export { Graph as DagreGraph } from '@dagrejs/graphlib';
export * from './layout';
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { LayoutConfig, LayoutOptions } from './type';
export const DefaultLayoutConfig: LayoutConfig = {
rankdir: 'LR',
align: undefined,
nodesep: 100,
edgesep: 10,
ranksep: 100,
marginx: 0,
marginy: 0,
acyclicer: undefined,
ranker: 'network-simplex',
};
export const DefaultLayoutOptions: LayoutOptions = {
filterNode: undefined,
getFollowNode: undefined,
disableFitView: false,
enableAnimation: false,
animationDuration: 300,
};
@@ -0,0 +1,245 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Graph as DagreGraph } from '@dagrejs/graphlib';
import { dagreLib } from '../dagre-lib/index';
import { DagreNode, LayoutNode } from './type';
import { LayoutStore } from './store';
export class DagreLayout {
private readonly graph: DagreGraph;
constructor(private readonly store: LayoutStore) {
this.graph = this.createGraph();
}
public layout(): void {
this.graphSetData();
this.dagreLayout();
this.alignTopEdgeIfNeeded();
this.layoutSetPosition();
}
private dagreLayout(): void {
let layoutGraph = dagreLib.buildLayoutGraph(this.graph);
this.runLayout(layoutGraph);
dagreLib.updateInputGraph(this.graph, layoutGraph);
}
private runLayout(graph: DagreGraph): void {
dagreLib.makeSpaceForEdgeLabels(graph);
dagreLib.removeSelfEdges(graph);
dagreLib.acyclic.run(graph);
dagreLib.nestingGraph.run(graph);
dagreLib.rank(dagreLib.util.asNonCompoundGraph(graph));
dagreLib.injectEdgeLabelProxies(graph);
dagreLib.removeEmptyRanks(graph);
dagreLib.nestingGraph.cleanup(graph);
dagreLib.normalizeRanks(graph);
dagreLib.assignRankMinMax(graph);
dagreLib.removeEdgeLabelProxies(graph);
dagreLib.normalize.run(graph);
dagreLib.parentDummyChains(graph);
dagreLib.addBorderSegments(graph);
dagreLib.order(graph);
this.setOrderAndRank(graph);
dagreLib.insertSelfEdges(graph);
dagreLib.coordinateSystem.adjust(graph);
dagreLib.position(graph);
dagreLib.positionSelfEdges(graph);
dagreLib.removeBorderNodes(graph);
dagreLib.normalize.undo(graph);
dagreLib.fixupEdgeLabelCoords(graph);
dagreLib.coordinateSystem.undo(graph);
dagreLib.translateGraph(graph);
dagreLib.assignNodeIntersects(graph);
dagreLib.reversePointsForReversedEdges(graph);
dagreLib.acyclic.undo(graph);
}
private createGraph(): DagreGraph {
const graph = new DagreGraph({ multigraph: true });
graph.setDefaultEdgeLabel(() => ({}));
graph.setGraph(this.store.config);
return graph;
}
private graphSetData(): void {
const { nodes, edges } = this.store;
nodes.forEach((layoutNode) => {
this.graph.setNode(layoutNode.index, {
originID: layoutNode.id,
width: layoutNode.size.width,
height: layoutNode.size.height,
});
});
edges
.sort((next, prev) => {
if (next.fromIndex === prev.fromIndex) {
return next.toIndex! < prev.toIndex! ? -1 : 1;
}
return next.fromIndex < prev.fromIndex ? -1 : 1;
})
.forEach((layoutEdge) => {
this.graph.setEdge({
v: layoutEdge.fromIndex,
w: layoutEdge.toIndex,
name: layoutEdge.name,
});
});
}
private layoutSetPosition(): void {
this.store.nodes.forEach((layoutNode) => {
const offsetX = this.getOffsetX(layoutNode);
const graphNode = this.graph.node(layoutNode.index);
if (!graphNode) {
// 异常兜底,一般不会出现
layoutNode.rank = -1;
layoutNode.position = {
x: layoutNode.position.x + offsetX,
y: layoutNode.position.y,
};
return;
}
layoutNode.rank = graphNode.rank ?? -1;
layoutNode.position = {
x: this.normalizeNumber(graphNode.x) + offsetX,
y: this.normalizeNumber(graphNode.y),
};
});
}
private alignTopEdgeIfNeeded(): void {
const { alignTopEdge } = this.store.options;
const { rankdir, marginy = 0 } = this.store.config;
if (!alignTopEdge || (rankdir !== 'LR' && rankdir !== 'RL')) {
return;
}
const rankGroups = this.rankGroup(this.graph);
rankGroups.forEach((indexSet) => {
const graphNodes = Array.from(indexSet)
.map((id) => this.graph.node(id) as DagreNode | undefined)
.filter(Boolean) as DagreNode[];
if (graphNodes.length === 0) {
return;
}
const minTop = Math.min(...graphNodes.map((node) => node.y - node.height / 2));
const deltaY = marginy - minTop;
if (deltaY === 0) {
return;
}
graphNodes.forEach((node) => {
node.y += deltaY;
});
});
}
private normalizeNumber(number: number): number {
// NaN 转为 0,异常兜底,一般不会出现
return Number.isNaN(number) ? 0 : number;
}
private getOffsetX(layoutNode: LayoutNode): number {
if (layoutNode.layoutNodes.length === 0) {
return 0;
}
// 存在子节点才需计算padding带来的偏移
const { padding } = layoutNode;
const leftOffset = -layoutNode.size.width / 2 + padding.left;
return leftOffset;
}
private setOrderAndRank(g: DagreGraph): DagreGraph {
// 跟随调整
this.followAdjust(g);
// 重新排序
this.normalizeOrder(g);
return g;
}
/** 跟随调整 */
private followAdjust(g: DagreGraph): void {
const rankGroup = this.rankGroup(g);
g.nodes().forEach((i) => {
const graphNode: DagreNode = g.node(i);
const layoutNode = this.store.getNodeByIndex(i);
// 没有跟随节点,则不调整
if (!graphNode || !layoutNode?.followedBy) return;
const { followedBy } = layoutNode;
const { rank: targetRank, order: targetOrder } = graphNode;
// 跟随节点索引
const followIndexes = followedBy
.map((id) => this.store.getNode(id)?.index)
.filter(Boolean) as string[];
const followSet = new Set(followIndexes);
// 目标节点之后的节点
const rankIndexes = rankGroup.get(targetRank);
if (!rankIndexes) return;
const afterIndexes = Array.from(rankIndexes).filter((index) => {
if (followSet.has(index)) return false;
const graphNode = g.node(index);
return graphNode.order > targetOrder;
});
// 目标节点之后的节点 order 增加跟随节点数量
afterIndexes.forEach((index) => {
const graphNode = g.node(index);
graphNode.order = graphNode.order + followedBy.length;
});
// 跟随节点 order 增加
followIndexes.forEach((followIndex, index) => {
const graphNode = g.node(followIndex);
graphNode.order = targetOrder + index + 1;
// 更新 rank 分组缓存
const originRank = graphNode.rank;
graphNode.rank = targetRank;
rankGroup.get(originRank)?.delete(followIndex);
rankGroup.get(targetRank)?.add(followIndex);
});
});
}
/** rank 内 order 可能不连续,需要重新排序 */
private normalizeOrder(g: DagreGraph): void {
const rankGroup = this.rankGroup(g);
rankGroup.forEach((indexSet, rank) => {
const graphNodes: DagreNode[] = Array.from(indexSet).map((id) => g.node(id));
graphNodes.sort((a, b) => a.order - b.order);
graphNodes.forEach((node, index) => {
node.order = index;
});
});
}
/** 获取 rank 分组 */
private rankGroup(g: DagreGraph): Map<number, Set<string>> {
const rankGroup = new Map<number, Set<string>>();
g.nodes().forEach((i) => {
const graphNode = g.node(i) as DagreNode | undefined;
if (!graphNode || typeof graphNode.rank !== 'number') {
return;
}
const rank = graphNode.rank;
if (!rankGroup.has(rank)) {
rankGroup.set(rank, new Set());
}
rankGroup.get(rank)?.add(i);
});
return rankGroup;
}
}
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { Layout } from './layout';
export type { LayoutNode, LayoutEdge, GetFollowNode, LayoutOptions } from './type';
export type { LayoutStore } from './store';
export { DefaultLayoutConfig } from './constant';
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { ILayout, LayoutConfig, LayoutOptions, LayoutParams } from './type';
import { LayoutStore } from './store';
import { LayoutPosition } from './position';
import { DagreLayout } from './dagre';
export class Layout implements ILayout {
private readonly _store: LayoutStore;
private readonly _layout: DagreLayout;
private readonly _position: LayoutPosition;
constructor(config: LayoutConfig) {
this._store = new LayoutStore(config);
this._layout = new DagreLayout(this._store);
this._position = new LayoutPosition(this._store);
}
public init(params: LayoutParams, options: LayoutOptions): void {
this._store.create(params, options);
}
public layout(): void {
if (!this._store.initialized) {
return;
}
this._layout.layout();
}
public async position(): Promise<void> {
if (!this._store.initialized) {
return;
}
return await this._position.position();
}
}
@@ -0,0 +1,72 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
import { PositionSchema, startTween } from '@flowgram.ai/core';
import { LayoutNode } from './type';
import { LayoutStore } from './store';
export class LayoutPosition {
constructor(private readonly store: LayoutStore) {}
public async position(): Promise<void> {
if (this.store.options.enableAnimation) {
return this.positionWithAnimation();
}
return this.positionDirectly();
}
private positionDirectly(): void {
this.store.nodes.forEach((layoutNode) => {
this.updateNodePosition({ layoutNode, step: 100 });
});
}
private async positionWithAnimation(): Promise<void> {
return new Promise((resolve) => {
startTween({
from: { d: 0 },
to: { d: 100 },
duration: this.store.options.animationDuration ?? 0,
onUpdate: (v) => {
this.store.nodes.forEach((layoutNode) => {
this.updateNodePosition({ layoutNode, step: v.d });
});
},
onComplete: () => {
resolve();
},
});
});
}
private updateNodePosition(params: { layoutNode: LayoutNode; step: number }): void {
const { layoutNode, step } = params;
const { transform } = layoutNode.entity.transform;
const centerToTopEdgeOffset =
(layoutNode.size.height - layoutNode.padding.top - layoutNode.padding.bottom) / 2;
const layoutPosition: PositionSchema = {
x: layoutNode.position.x + layoutNode.offset.x,
y: layoutNode.position.y + layoutNode.offset.y - centerToTopEdgeOffset,
};
const deltaX = ((layoutPosition.x - transform.position.x) * step) / 100;
const deltaY = ((layoutPosition.y - transform.position.y) * step) / 100;
const position = {
x: transform.position.x + deltaX,
y: transform.position.y + deltaY,
};
transform.update({
position,
});
const document = layoutNode.entity.document as WorkflowDocument;
document.layout.updateAffectedTransform(layoutNode.entity);
}
}
@@ -0,0 +1,262 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
import { FlowNodeBaseType } from '@flowgram.ai/document';
import type {
GetFollowNode,
ILayoutStore,
LayoutConfig,
LayoutEdge,
LayoutNode,
LayoutOptions,
LayoutParams,
LayoutStoreData,
} from './type';
export class LayoutStore implements ILayoutStore {
private indexMap: Map<string, string>;
private init: boolean = false;
private store: LayoutStoreData;
public options: LayoutOptions;
public container: LayoutNode;
constructor(public readonly config: LayoutConfig) {}
public get initialized(): boolean {
return this.init;
}
public getNode(id?: string): LayoutNode | undefined {
if (!id) {
return undefined;
}
return this.store.nodes.get(id);
}
public getNodeByIndex(index: string): LayoutNode | undefined {
const id = this.indexMap.get(index);
return id ? this.getNode(id) : undefined;
}
public getEdge(id: string): LayoutEdge | undefined {
return this.store.edges.get(id);
}
public get nodes(): LayoutNode[] {
return Array.from(this.store.nodes.values());
}
public get edges(): LayoutEdge[] {
return Array.from(this.store.edges.values());
}
public create(params: LayoutParams, options: LayoutOptions): void {
this.container = params.container;
this.store = this.createStore(params);
this.indexMap = this.createIndexMap();
this.setOptions(options);
this.init = true;
}
/** 创建布局数据 */
private createStore(params: LayoutParams): LayoutStoreData {
const { layoutNodes, layoutEdges } = params;
const virtualEdges = this.createVirtualEdges(params);
const store = {
nodes: new Map(),
edges: new Map(),
};
layoutNodes.forEach((node) => store.nodes.set(node.id, node));
layoutEdges.concat(virtualEdges).forEach((edge) => store.edges.set(edge.id, edge));
return store;
}
/** 创建虚拟线条数据 */
private createVirtualEdges(params: LayoutParams): LayoutEdge[] {
const { layoutNodes, layoutEdges } = params;
const nodes = layoutNodes.map((layoutNode) => layoutNode.entity);
const edges = layoutEdges.map((layoutEdge) => layoutEdge.entity);
const groupNodes = nodes.filter((n) => n.flowNodeType === FlowNodeBaseType.GROUP);
const virtualEdges = groupNodes
.map((group) => {
const { id: groupId, blocks = [] } = group;
const blockIdSet = new Set(blocks.map((b) => b.id));
const groupFromEdges = edges
.filter((edge) => blockIdSet.has(edge.to?.id ?? ''))
.map((edge) => {
const { from, to } = edge.info;
if (!from || !to) {
return;
}
const id = `virtual_${groupId}_from_${from}_to_${to}`;
const layoutEdge: LayoutEdge = {
id: id,
entity: edge,
from,
to: groupId,
fromIndex: '', // 初始化时,index 未计算
toIndex: '', // 初始化时,index 未计算
name: id,
};
return layoutEdge;
})
.filter(Boolean) as LayoutEdge[];
const groupToEdges = edges
.filter((edge) => blockIdSet.has(edge.from?.id ?? ''))
.map((edge) => {
const { from, to } = edge.info;
if (!from || !to) {
return;
}
const id = `virtual_${groupId}_from_${from}_to_${to}`;
const layoutEdge: LayoutEdge = {
id: id,
entity: edge,
from: groupId,
to,
fromIndex: '', // 初始化时,index 未计算
toIndex: '', // 初始化时,index 未计算
name: id,
};
return layoutEdge;
})
.filter(Boolean) as LayoutEdge[];
return [...groupFromEdges, ...groupToEdges];
})
.flat();
return virtualEdges;
}
/** 创建节点索引映射 */
private createIndexMap(): Map<string, string> {
const nodeIndexes = this.sortNodes();
const nodeToIndex = new Map<string, string>();
// 创建节点索引映射
nodeIndexes.forEach((nodeId, nodeIndex) => {
const node = this.getNode(nodeId);
if (!node) {
return;
}
const graphIndex = String(100000 + nodeIndex);
nodeToIndex.set(node.id, graphIndex);
node.index = graphIndex;
});
// 创建连线索引映射
this.edges.forEach((edge) => {
const fromIndex = nodeToIndex.get(edge.from);
const toIndex = nodeToIndex.get(edge.to);
if (!fromIndex || !toIndex) {
this.store.edges.delete(edge.id);
return;
}
edge.fromIndex = fromIndex;
edge.toIndex = toIndex;
});
// 创建索引到节点的映射
const indexToNode = new Map();
nodeToIndex.forEach((index, id) => {
indexToNode.set(index, id);
});
return indexToNode;
}
/** 节点排序 */
private sortNodes(): Array<string> {
// 节点 id 列表,id 可能重复
const nodeIdList: string[] = [];
// 第1级排序:按照 node 添加顺序排序
this.nodes.forEach((node) => {
nodeIdList.push(node.id);
});
// 第2级排序:被连线节点排序靠后
this.edges.forEach((edge) => {
nodeIdList.push(edge.to);
});
// 第3级排序:按照从开始节点进行遍历排序
const visited = new Set<string>();
const visit = (node?: WorkflowNodeEntity) => {
if (!node || visited.has(node.id)) {
return;
}
visited.add(node.id);
nodeIdList.push(node.id);
// 访问子节点
node.blocks.forEach((child) => {
visit(child);
});
// 访问后续节点
const { outputLines } = node.lines;
const sortedLines = outputLines.sort((a, b) => {
const aNode = this.getNode(a.to?.id);
const bNode = this.getNode(b.to?.id);
const aPort = a.fromPort;
const bPort = b.fromPort;
// 同端口,对比to节点y轴坐标
if (aPort === bPort && aNode && bNode) {
return aNode.position.y - bNode.position.y;
}
// 同from节点的不同端口,对比端口y轴坐标
if (aPort && bPort) {
return aPort.point.y - bPort.point.y;
}
return 0;
});
sortedLines.forEach((line) => {
const { to } = line;
if (!to) {
return;
}
visit(to);
});
};
visit(this.container.entity);
// 使用 reduceRight 去重并保留最后一个出现的节点 id
const uniqueNodeIds: string[] = nodeIdList.reduceRight((acc: string[], nodeId: string) => {
if (!acc.includes(nodeId)) {
acc.unshift(nodeId);
}
return acc;
}, []);
return uniqueNodeIds;
}
/** 记录运行选项 */
private setOptions(options: LayoutOptions): void {
this.options = options;
this.setFollowNode(options.getFollowNode);
}
/** 设置跟随节点配置 */
private setFollowNode(getFollowNode?: GetFollowNode): void {
if (!getFollowNode) return;
const context = { store: this };
this.nodes.forEach((node) => {
const followTo = getFollowNode(node, context)?.followTo;
if (!followTo) return;
const followToNode = this.getNode(followTo);
if (!followToNode) return;
if (!followToNode.followedBy) {
followToNode.followedBy = [];
}
followToNode.followedBy.push(node.id);
node.followTo = followTo;
});
}
}
@@ -0,0 +1,162 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { WorkflowLineEntity, WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
export interface LayoutStoreData {
nodes: Map<string, LayoutNode>;
edges: Map<string, LayoutEdge>;
}
export interface ILayoutStore {
container: LayoutNode;
options: LayoutOptions;
get initialized(): boolean;
getNode(id?: string): LayoutNode | undefined;
getNodeByIndex(index: string): LayoutNode | undefined;
getEdge(id: string): LayoutEdge | undefined;
nodes: LayoutNode[];
edges: LayoutEdge[];
create(params: LayoutParams, options: LayoutOptions): void;
}
export interface ILayout {
init(params: LayoutParams, options: LayoutOptions): void;
layout(): void;
position(): Promise<void>;
}
export interface LayoutSize {
width: number;
height: number;
}
export interface LayoutNode {
id: string;
/** 节点索引 */
index: string;
/** 节点实体 */
entity: WorkflowNodeEntity;
/** 层级 */
rank: number;
/** 顺序 */
order: number;
/** 位置 */
position: {
x: number;
y: number;
};
/** 偏移量 */
offset: {
x: number;
y: number;
};
/** 边距 */
padding: {
top: number;
bottom: number;
left: number;
right: number;
};
/** 宽高 */
size: LayoutSize;
/** 子节点 */
layoutNodes: LayoutNode[];
/** 子线条 */
layoutEdges: LayoutEdge[];
/** 被跟随节点 */
followedBy?: string[];
/** 跟随节点 */
followTo?: string;
}
export interface LayoutEdge {
id: string;
/** 线条实体 */
entity: WorkflowLineEntity;
/** 起点 */
from: string;
/** 终点 */
to: string;
/** 起点索引 */
fromIndex: string;
/** 终点索引 */
toIndex: string;
/** 线条名称 */
name: string;
}
export interface DagreNode {
width: number;
height: number;
x: number;
y: number;
order: number;
rank: number;
}
export interface LayoutParams {
container: LayoutNode;
layoutNodes: LayoutNode[];
layoutEdges: LayoutEdge[];
}
export interface LayoutOptions {
/** Custom layout configuration to override default dagre settings. */
layoutConfig?: Partial<LayoutConfig>;
/** The container node entity used as the root for the layout. */
containerNode?: WorkflowNodeEntity;
/** Custom function to determine follow-node relationships between layout nodes. */
getFollowNode?: GetFollowNode;
/** Whether to animate node movements during layout positioning. */
enableAnimation?: boolean;
/** Duration of the position animation in milliseconds. Only effective when `enableAnimation` is true. */
animationDuration?: number;
/** When true, skips the fit-view step after layout is applied. */
disableFitView?: boolean;
/**
* When true, aligns nodes by their top edge instead of their center point.
* Defaults to false (center-aligned). Set to true to place all nodes' top edges on the same horizontal line.
*/
alignTopEdge?: boolean;
/** Filter function to exclude specific nodes from the layout. Return false to skip a node. */
filterNode?: (params: { node: WorkflowNodeEntity; parent?: WorkflowNodeEntity }) => boolean;
/** Filter function to exclude specific edges from the layout. Return false to skip an edge. */
filterLine?: (params: { line: WorkflowLineEntity }) => boolean;
}
export interface LayoutConfig {
/** Direction for rank nodes. Can be TB, BT, LR, or RL, where T = top, B = bottom, L = left, and R = right. */
rankdir: 'TB' | 'BT' | 'LR' | 'RL';
/** Alignment for rank nodes. Can be UL, UR, DL, or DR, where U = up, D = down, L = left, and R = right. */
align: 'UL' | 'UR' | 'DL' | 'DR' | undefined;
/** Number of pixels that separate nodes horizontally in the layout. */
nodesep: number;
/** Number of pixels that separate edges horizontally in the layout. */
edgesep: number;
/** Number of pixels that separate edges horizontally in the layout. */
ranksep: number;
/** Number of pixels to use as a margin around the left and right of the graph. */
marginx: number;
/** Number of pixels to use as a margin around the top and bottom of the graph. */
marginy: number;
/** If set to greedy, uses a greedy heuristic for finding a feedback arc set for a graph. A feedback arc set is a set of edges that can be removed to make a graph acyclic. */
acyclicer: 'greedy' | undefined;
/** Type of algorithm to assigns a rank to each node in the input graph. Possible values: network-simplex, tight-tree or longest-path */
ranker: 'network-simplex' | 'tight-tree' | 'longest-path';
}
export type GetFollowNode = (
node: LayoutNode,
context: {
store: ILayoutStore;
/** 业务自定义参数 */
[key: string]: any;
}
) =>
| {
followTo?: string;
}
| undefined;
@@ -0,0 +1,174 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { inject, injectable } from 'inversify';
import { Rectangle } from '@flowgram.ai/utils';
import {
WorkflowDocument,
WorkflowLineEntity,
WorkflowNodeEntity,
WorkflowNodeLinesData,
} from '@flowgram.ai/free-layout-core';
import { Playground } from '@flowgram.ai/core';
import { AutoLayoutOptions } from './type';
import { LayoutConfig, LayoutEdge, LayoutNode } from './layout/type';
import { DefaultLayoutOptions } from './layout/constant';
import { DefaultLayoutConfig, Layout, type LayoutOptions } from './layout';
@injectable()
export class AutoLayoutService {
@inject(Playground)
private playground: Playground;
@inject(WorkflowDocument) private readonly document: WorkflowDocument;
private layoutConfig: LayoutConfig = DefaultLayoutConfig;
public init(options: AutoLayoutOptions) {
this.layoutConfig = {
...this.layoutConfig,
...options.layoutConfig,
};
}
public async layout(options: Partial<LayoutOptions> = {}): Promise<void> {
const layoutOptions: LayoutOptions = {
...DefaultLayoutOptions,
...options,
};
const containerNode = layoutOptions.containerNode ?? this.document.root;
const container = this.createLayoutNode(containerNode, options);
const layouts = await this.layoutNode(container, layoutOptions);
const rect = this.getLayoutNodeRect(container);
const positionPromise = layouts.map((layout) => layout.position());
const fitViewPromise = this.fitView(layoutOptions, rect);
await Promise.all([...positionPromise, fitViewPromise]);
}
private async fitView(options: LayoutOptions, rect: Rectangle): Promise<void> {
if (options.disableFitView === true) {
return;
}
// 留出 30 像素的边界
return this.playground.config.fitView(rect, options.enableAnimation, 30);
}
private async layoutNode(container: LayoutNode, options: LayoutOptions): Promise<Layout[]> {
const { layoutNodes, layoutEdges } = container;
if (layoutNodes.length === 0) {
return [];
}
// 触发子节点布局
const childrenLayouts = (
await Promise.all(layoutNodes.map((n) => this.layoutNode(n, options)))
).flat();
const layoutConfig: LayoutConfig = {
...this.layoutConfig,
...options.layoutConfig,
};
const layout = new Layout(layoutConfig);
layout.init({ container, layoutNodes, layoutEdges }, options);
layout.layout();
const rect = this.getLayoutNodeRect(container);
container.size = {
width: rect.width,
height: rect.height,
};
return [...childrenLayouts, layout];
}
private createLayoutNodes(nodes: WorkflowNodeEntity[], options: LayoutOptions): LayoutNode[] {
return nodes.map((node) => this.createLayoutNode(node, options));
}
/** 创建节点布局数据 */
private createLayoutNode(node: WorkflowNodeEntity, options: LayoutOptions): LayoutNode {
const blocks = node.blocks.filter((blockNode) =>
options.filterNode ? options.filterNode?.({ node: blockNode, parent: node.parent }) : true
);
const edges = this.getNodesAllLines(blocks).filter((edge) =>
options.filterLine ? options.filterLine?.({ line: edge }) : true
);
// 创建子布局节点
const layoutNodes = this.createLayoutNodes(blocks, options);
const layoutEdges = this.createLayoutEdges(edges);
const { bounds, padding } = node.transform;
const { width, height, center } = bounds;
const { x, y } = center;
const layoutNode: LayoutNode = {
id: node.id,
entity: node,
index: '', // 初始化时,index 未计算
rank: -1, // 初始化时,节点还未布局,层级为-1
order: -1, // 初始化时,节点还未布局,顺序为-1
position: { x, y },
offset: { x: 0, y: 0 },
padding,
size: { width, height },
layoutNodes,
layoutEdges,
};
return layoutNode;
}
private createLayoutEdges(edges: WorkflowLineEntity[]): LayoutEdge[] {
const layoutEdges = edges
.map((edge) => this.createLayoutEdge(edge))
.filter(Boolean) as LayoutEdge[];
return layoutEdges;
}
/** 创建线条布局数据 */
private createLayoutEdge(edge: WorkflowLineEntity): LayoutEdge | undefined {
const { from, to } = edge.info;
if (!from || !to) {
return;
}
const layoutEdge: LayoutEdge = {
id: edge.id,
entity: edge,
from,
to,
fromIndex: '', // 初始化时,index 未计算
toIndex: '', // 初始化时,index 未计算
name: edge.id,
};
return layoutEdge;
}
private getNodesAllLines(nodes: WorkflowNodeEntity[]): WorkflowLineEntity[] {
const lines = nodes
.map((node) => {
const linesData = node.getData<WorkflowNodeLinesData>(WorkflowNodeLinesData);
const outputLines = linesData.outputLines.filter(Boolean);
const inputLines = linesData.inputLines.filter(Boolean);
return [...outputLines, ...inputLines];
})
.flat();
return lines;
}
private getLayoutNodeRect(layoutNode: LayoutNode): Rectangle {
const rects = layoutNode.layoutNodes.map((node) => this.layoutNodeRect(node));
const rect = Rectangle.enlarge(rects);
const { padding } = layoutNode;
const width = rect.width + padding.left + padding.right;
const height = rect.height + padding.top + padding.bottom;
const x = rect.x - padding.left;
const y = rect.y - padding.top;
return new Rectangle(x, y, width, height);
}
private layoutNodeRect(layoutNode: LayoutNode): Rectangle {
const { width, height } = layoutNode.size;
const x = layoutNode.position.x - width / 2;
const y = layoutNode.position.y - height / 2;
return new Rectangle(x, y, width, height);
}
}
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { LayoutConfig } from './layout/type';
export interface AutoLayoutOptions {
layoutConfig?: Partial<LayoutConfig>;
}