JointJS 有向图自动布局实战:DirectedGraph Layout 示例完整解读
JointJS 有向图自动布局实战:DirectedGraph Layout 示例完整解读
本文围绕 JointJS 仓库中 examples/layout-directed-graph 官方示例展开,深入讲解如何基于 @joint/layout-directed-graph 包调用 DirectedGraph.layout() 为有向图自动计算节点坐标与连接线路径,并借助交互式控制面板实时调节 ranker、rankDir、align、节点/边/层级间距等核心参数。读完本文,你将掌握该示例的构建与运行方式、布局参数的完整语义与取值范围、示例源码的实现骨架,以及底层从 JointJS 图到 dagre graphlib 图的转换原理,可直接在自有项目中复刻这套"有向图自动布局 + 实时调参"的工程方案。
一、示例概览:这个 Demo 演示了什么
examples/layout-directed-graph 是一个面向浏览器端的 JointJS 有向图布局演示项目,项目名 @joint/demo-layout-directed-graph,版本与 JointJS 主仓库保持一致(package.json)。它的核心价值在于:
- 以一张内置的邻接表(adjacency list)数据为输入,一键生成节点与连线,交给
DirectedGraph.layout()自动排版; - 页面顶部提供完整的布局控制面板(布局控制区)和边属性控制面板(点击连线后出现),所有参数修改即时触发重新布局;
- 布局完成后自动调用
paper.fitToContent()将画布视口收缩到内容区域。
该示例不依赖任何图片资源,全部交互均由代码生成,因此没有附属的 assets 目录,目录结构非常精简:
examples/layout-directed-graph/
├── README.md # 官方说明文档(本文主体)
├── index.html # 页面骨架与控制面板 DOM
├── index.js # webpack 打包入口
├── package.json # 示例依赖与脚本
├── src/
│ └── directed-graph.mjs # 示例全部逻辑
├── styles.scss # 页面样式
└── webpack.config.js # webpack dev server 配置
二、构建与运行:官方推荐流程
原 README.md 明确指出该示例使用 Yarn 运行,并且要求先构建 JointJS 主库。这是因为整个仓库是一个 Yarn workspaces monorepo(根 package.json 中 workspaces 覆盖 ./packages/* 与 ./examples/*),示例通过 "@joint/core": "workspace:^" 和 "@joint/layout-directed-graph": "workspace:^" 引用本地工作区依赖。
第一步,回到仓库根目录安装依赖并构建 JointJS:
yarn install
yarn run build
其中根目录的 yarn run build 实际等价于 yarn workspaces foreach --all -tvv run build,会依次构建全部 workspace 包(见 package.json),确保 @joint/core 与 @joint/layout-directed-graph 的产物就绪。
第二步,进入示例目录启动 webpack dev server:
yarn start
yarn start 对应 webpack serve --config webpack.config.js(见 package.json)。从 webpack.config.js 可以看到 dev server 默认监听 localhost:8080,可通过环境变量 PORT / HOST 覆盖,并开启了 hot: true 热更新;入口为 ./index.js,CopyPlugin 会把 index.html 一并复制到 dist/ 目录下。
三、代码组织:打包入口与页面骨架
1. 打包入口 index.js
examples/layout-directed-graph/index.js 仅做三件事:引入 core-js/stable 与 regenerator-runtime/runtime 补齐浏览器 polyfill,导入核心逻辑模块 ./src/directed-graph.mjs,以及引入 styles.scss:
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import './src/directed-graph.mjs';
import './styles.scss';
2. 页面骨架 index.html
index.html 包含两个关键部分:
- 布局控制面板(
#layout-controls):用于调节整体布局参数的下拉框与滑块,包括Ranker、RankDir、Align、RankSep、EdgeSep、NodeSep; - 边控制模板(
<template id="link-controls-template">):点击连线后动态插入的边属性面板,包括LabelPos、MinLen、Weight、LabelOffset。
画布容器为 <div id="paper"></div>,最终脚本加载 bundle.js(由 webpack 生成)。
四、核心实现:布局控制的整体骨架
示例的全部逻辑集中在 src/directed-graph.mjs,它围绕三个自研组件组织:自定义元素/连线形状、LayoutControls 视图、LinkControls 视图。
1. 自定义形状
demo.Shape 继承 joint.dia.Element,默认 100×50 的圆角矩形(rx/ry: 10),内部 body 尺寸用 calc(w) / calc(h) 自适应,标签居中显示。demo.Link 继承 joint.dia.Link,采用 rounded 连接器,并预先声明了四个参与布局的关键属性:
z: 1,
weight: 1,
minLen: 1,
labelPosition: 'c',
labelOffset: 10,
labelSize: { width: 50, height: 30 },
这些属性会被 DirectedGraph 在导出阶段读取,直接决定 dagre 算法的边权重、最短层级距离与标签位置(详见第六节)。
2. LayoutControls:整图布局控制视图
LayoutControls 继承 joint.mvc.View(directed-graph.mjs#L133-L225),职责是响应控制面板变化并执行布局。其 layout() 方法完整呈现了"布局一次"的推荐流程:
layout: function() {
var paper = this.options.paper;
var graph = paper.model;
var cells = this.options.cells;
paper.freeze();
DirectedGraph.layout(cells, this.getLayoutOptions());
if (graph.getCells().length === 0) {
// The graph could be empty at the beginning to avoid cells rendering
// and their subsequent update when elements are translated
graph.resetCells(cells);
}
paper.fitToContent({
padding: this.options.padding,
allowNewOrigin: 'any',
useModelGeometry: true
});
paper.unfreeze();
},
这段代码包含三个值得复用的工程细节:
paper.freeze()/paper.unfreeze():布局过程中冻结画布渲染,批量更新节点坐标后一次性解冻,避免逐个节点重绘造成的性能浪费;- 延迟挂载:初始阶段画布内没有任何 cell,先执行布局、再
graph.resetCells(cells)一次性把排版好的结果放入图中,规避元素在平移过程中反复触发更新; paper.fitToContent():布局完成后按padding: 50(options默认值)收拢视口,useModelGeometry: true表示基于模型几何而非渲染几何计算范围,allowNewOrigin: 'any'允许平移画布原点。
此外,init() 中监听 paper.model 的 change 事件,且仅当 opt.layout 为真时才重排(if (opt.layout) this.layout();),这是为第五节的边属性交互预留的钩子。
3. 从邻接表构建图数据
buildGraphFromAdjacencyList()(directed-graph.mjs#L199-L223)把纯数据转化为 JointJS cell 数组:
buildGraphFromAdjacencyList: function(adjacencyList) {
var elements = [];
var links = [];
Object.keys(adjacencyList).forEach(function(parentLabel) {
elements.push(
new Shape({ id: parentLabel }).setText(parentLabel)
);
adjacencyList[parentLabel].forEach(function(childLabel) {
links.push(
new Link()
.connect(parentLabel, childLabel)
.setLabelText(parentLabel + '-' + childLabel)
);
});
});
// Links must be added after all the elements. This is because when the links
// are added to the graph, link source/target
// elements must be in the graph already.
return elements.concat(links);
}
示例内置的邻接表(directed-graph.mjs#L333-L343)为:
adjacencyList: {
a: ['b','c','d'],
b: ['d', 'e'],
c: [],
d: [],
e: ['e'], // 自环边
f: [],
g: ['b','i'],
h: ['f'],
i: ['f','h']
}
注意实现注释强调的顺序约束:elements.concat(links) 保证连线晚于节点进入图,因为连线入图时其 source/target 节点必须已存在于图中。这段逻辑恰好与 packages/joint-layout-directed-graph/test/index.js 中 DirectedGraph.layout 的单元测试用例互为印证——测试同样采用"先建元素、再建连线、graph.resetCells(cells) 一次性加入"的写法。
五、布局参数全解:控制面板背后的完整语义
getLayoutOptions()(directed-graph.mjs#L186-L197)从控制面板 DOM 读取参数并交给 DirectedGraph.layout():
getLayoutOptions: function() {
return {
setVertices: true,
setLabels: true,
ranker: val(this, '#ranker'),
rankDir: val(this, '#rankdir'),
align: val(this, '#align'),
rankSep: parseInt(val(this, '#ranksep'), 10),
edgeSep: parseInt(val(this, '#edgesep'), 10),
nodeSep: parseInt(val(this, '#nodesep'), 10)
};
}
这些参数在 DirectedGraph.mjs 的 layout() 函数(DirectedGraph.mjs#L108-L225)中逐一映射为 dagre 的图级配置(注意 dagre 使用小写形式 rankdir/nodesep/edgesep/ranksep/marginx/marginy)。完整参数说明如下:
| 参数 | 取值 | 默认 | 说明 |
|---|---|---|---|
ranker |
'network-simplex' / 'tight-tree' / 'longest-path' |
'network-simplex' |
为节点分配层级(rank)的算法。network-simplex 侧重最小化总边长(默认且质量最佳);tight-tree 生成较紧凑的树形层次;longest-path 速度最快但布局较松散。控制面板下拉框显示文本为 longer-path,其提交 value 为 longest-path,与 d.ts 定义一致 |
rankDir |
'TB' / 'BT' / 'LR' / 'RL' |
'TB' |
层级排布方向:自上而下(Top-to-Bottom)、自下而上、自左向右、自右向左 |
align |
'UL' / 'UR' / 'DL' / 'DR' |
'UL' |
同级节点的对齐方式(上左、上右、下左、下右),仅在某些 ranker 下生效 |
rankSep |
数字(像素) | dagre 默认 | 相邻两个 rank 之间的垂直/水平间距,示例滑块范围 1–100,默认 50 |
edgeSep |
数字(像素) | dagre 默认 | 同一 rank 内相邻边之间的间距,示例滑块范围 1–100,默认 50 |
nodeSep |
数字(像素) | dagre 默认 | 同一 rank 内相邻节点之间的间距,示例滑块范围 1–100,默认 50 |
除控制面板暴露的参数外,DirectedGraph.LayoutOptions(见 DirectedGraph.d.ts)还支持:
marginX/marginY:布局结果四周的外边距(像素),可传负数实现裁剪效果。layout()返回的g.Rect边界框已按Math.abs(glSize.width - 2 * marginX)折算 margin,且测试用例验证了正负 margin 下返回值与graph.getBBox()的一致性(test/index.js#L277-L317);setVertices/setLabels:布尔值或回调。为true时自动把 dagre 计算出的折线点写回连线vertices(并经g.Polyline.simplify({ threshold: 0.001 })简化后剔除首尾连接点),把标签位置换算为相对连线的distance + offset写入link.label(0)。传入函数则可自定义写入逻辑(见 DirectedGraph.mjs#L63-L106);resizeClusters(默认true)与clusterPadding(默认10,可为数字、{top,right,bottom,left}对象或字符串'default'):控制嵌入容器(cluster)是否随子元素重排而自动调整尺寸。数值/对象模式对顶层 cluster 按逆深度优先调用fitToChildren({ padding, deep: true });'default'模式则交由 dagre 自身处理 cluster 尺寸。测试用例覆盖了嵌套 cluster、clusterPadding为对象、'default'、resizeClusters: false以及"子元素未参与布局"等多种组合(test/index.js#L319-L665);exportElement/exportLink:自定义节点/边导出函数,默认分别调用element.size()与exportLink(见下节),用于向 dagre 提供宽高、minLen 等输入;setPosition:自定义节点位置回写逻辑,默认按{x: glNode.x - width/2, y: glNode.y - height/2}把 dagre 的节点中心坐标换算为 JointJS 的左上角坐标;disableOptimalOrderHeuristic(默认false):禁用 dagre 的节点最优排序启发式;customOrder:函数形式的排序回调,签名(dagreGraph, graph, order) => void,可在 dagre 排序前按自定义策略调用order(dagreGraph)重排节点,测试用例中验证了该回调能拿到joint.dia.Graph与完整 cell 集合(test/index.js#L667-L692);debugTiming:输出 dagre 各阶段耗时到控制台,便于性能排查。
六、边级属性:minLen、weight 与标签位置
点击画布中的任意连线,示例会通过 LinkControls.create 动态插入边控制面板(index.html#L9-L24)。四个滑块的语义由 DirectedGraph.exportLink(DirectedGraph.mjs#L13-L35)揭示,它们直接对应 dagre 的边属性:
| 连线属性 | 面板控件 | 映射的 dagre 边属性 | 语义 |
|---|---|---|---|
minLen |
MinLen(1–5) | minLen |
边在 source 与 target 之间至少占据的 rank 数量(即最短层级距离),默认 1 |
weight |
Weight(1–10) | weight |
边权重,权重越高,该边在排序中越被优先拉短、拉直,默认 1 |
labelPosition |
LabelPos(l/c/r) |
labelpos |
边标签相对边的位置:左、中、右,默认 'c' |
labelOffset |
LabelOffset(1–10) | labeloffset |
标签偏离边的像素数,仅在 labelpos 为 l 或 r 时生效,默认 0 |
此外 link.get('labelSize')(宽高)会被读入边的 width / height,供 dagre 计算标签占位。示例中 LinkControls.updateLink() 在 change/input 事件下执行 this.options.cellView.model.set(this.getModelAttributes(), { layout: true })——layout: true 这个 option 正是第四节中 LayoutControls.init 监听并触发 layout() 的开关,由此实现"拖动滑块 → 连线属性更新 → 全图重排"的闭环。LinkControls 还通过 stroke 高亮器(lightcoral、stroke-width: 4)在选中连线时给出视觉反馈。
七、底层原理:JointJS 图与 dagre graphlib 图的互转
@joint/layout-directed-graph 的本质是对 dagre 的封装:先把 JointJS 图导出为 graphlib 图,运行 dagre 布局,再把结果导回 JointJS 图。这一过程由三个函数完成(DirectedGraph.mjs):
1. toGraphLib(graph, opt)(L259-L297):遍历 graph.getCells(),元素调用 glGraph.setNode(cell.id, label);连线调用 setEdge(source.id, target.id, label, link.id)。其中以连线 id 作为 graphlib 边的 name 是"多图"能力的关键——graphlib 边本身没有 id,JointJS 通过 name 属性保存并取回连线 id(setEdgeName 回调)。同时以 multigraph: true、compound: true、directed: true 初始化 graphlib 图;compound 模式下元素若带 parent 且父节点在图内,则调用 setParent 保留嵌入关系;source/target 仅以坐标终点相连的连线会被忽略(if (!source.id || !target.id) break)。
2. dagre 布局(L186-L190):将第五节全部参数写入 glGraph.setGraph(glLabel),然后调用 dagreUtil.layout(glGraph, { debugTiming, disableOptimalOrderHeuristic, customOrder })。
3. fromGraphLib(glGraph, opt)(L227-L256):依次遍历 graphlib 节点与边,调用 importElement / importLink 回写。importElement 负责把节点中心坐标换算为元素 position,并在 clusterPadding === 'default' 时按 dagre 给出的 cluster 尺寸重设元素 size;importLink 负责把 dagre 的 points 折线简化后写入 vertices,把 glEdge.x/y 标签坐标换算为相对连线的 { distance, offset }(polyline.closestPointLength + pointAtLength 实现)。
layout(graphOrCells, opt) 同时接受 dia.Graph 或 dia.Cell<a href="https://link.gitcode.com/i/384441009837c51b26100a203960330d" target="_blank">] 两种入参:传数组时会内部新建 dia.Graph,以 { dry: true, sort: false } 重置 cell 集合,从而不改变元素 z 序、也不在 cell 上留存图引用——测试用例验证了"只布局部分 cell"与"仅布局嵌入子元素而不含父元素"等场景([test/index.js#L177-L275)。整个布局过程包在 graph.startBatch('layout') / stopBatch('layout') 批处理中,对使用者透明。布局完成后返回带 margin 修正的 g.Rect 边界框。该包还提供已废弃但保留向后兼容的 DirectedGraph.fromGraphLib.call(graph, ...) 调用方式(d.ts 中以 @deprecated 标注)。
八、如何把本示例移植到自己的项目
综合以上分析,在自有项目中落地这套有向图自动布局方案,最小骨架如下:
import * as joint from '@joint/core';
import { DirectedGraph } from '@joint/layout-directed-graph';
const graph = new joint.dia.Graph();
const paper = new joint.dia.Paper({
el: document.getElementById('paper'),
model: graph,
interactive: (cellView) => cellView.model.isElement()
});
const cells = /* 你的元素与连线数组,连线必须在元素之后 */;
paper.freeze();
DirectedGraph.layout(cells, {
setVertices: true,
setLabels: true,
rankDir: 'TB',
ranker: 'network-simplex',
rankSep: 50,
edgeSep: 50,
nodeSep: 50,
marginX: 20,
marginY: 20
});
graph.resetCells(cells);
paper.fitToContent({ padding: 50, allowNewOrigin: 'any', useModelGeometry: true });
paper.unfreeze();
需要依赖两个包:@joint/core(基础图形库)与 @joint/layout-directed-graph(有向图布局模块,内部依赖 @dagrejs/dagre ~1.1.4 与 @dagrejs/graphlib ~2.2.4,见 package.json)。若需用 TypeScript 开发,可直接复用 DirectedGraph.d.ts 中的 LayoutOptions、Edge、Node 等类型声明。
九、测试与质量保障
@joint/layout-directed-graph 自带完整的 QUnit 测试套件(packages/joint-layout-directed-graph/test/index.js,共 766 行),覆盖:
fromGraphLib的图结构还原正确性(节点 id、坐标、尺寸、标签、连线 source/target);layout的基础排版结果、部分 cell 布局、嵌入元素布局、返回边界框与graph.getBBox()的一致性;resizeClusters/clusterPadding在数字、对象、'default'三种模式下的尺寸预期(含嵌套深度优先验证);customOrder回调与 layer(layer1/layer2)场景下布局仍正常执行。
在仓库中运行测试的命令为(位于 packages/joint-layout-directed-graph 目录):yarn run dist(先生成分发文件)与 yarn run test(mocha test/nodejs/nodejs.js && karma start karma.conf.js,见 package.json)。
十、许可证
JointJS 库基于 Mozilla Public License 2.0 开源,仓库内版权声明为 Copyright © 2013-2026 client IO。本示例同样以 MPL-2.0 授权(见 package.json),商用与二次开发请遵循该许可证条款。