JointJS 有向图自动布局实战:DirectedGraph Layout 示例完整解读

原创2026-10-12 14:48:581,450 阅读
文章标签:前端图表库UI组件图形学

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();
},

这段代码包含三个值得复用的工程细节:

  1. paper.freeze() / paper.unfreeze():布局过程中冻结画布渲染,批量更新节点坐标后一次性解冻,避免逐个节点重绘造成的性能浪费;
  2. 延迟挂载:初始阶段画布内没有任何 cell,先执行布局、再 graph.resetCells(cells) 一次性把排版好的结果放入图中,规避元素在平移过程中反复触发更新;
  3. 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),商用与二次开发请遵循该许可证条款。

登录后查看全文
joint