☰
JointJS 蛇形布局(Serpentine Layout)实战:用 zigzag 算法自动排布时序图表元素
2026/10/6 2:34:49 网站建设 项目流程
  • 前端
  • UI组件

【免费下载链接】joint

A proven SVG-based JavaScript diagramming library powering exceptional UIs

项目地址:https://gitcode.com/gh_mirrors/jo/joint
点击查看免费下载

导读

本指南围绕 JointJS 官方示例serpentine-layout-js展开,讲解如何在 JointJS 中实现一套自定义的「蛇形布局」(Serpentine Layout)算法:元素按照从左到右、再从右到左的锯齿(zigzag)方式逐行排布,每一行恰好适配给定的可用宽度,并用曲线连线把序列首尾相连。读完本文,你将掌握蛇形布局算法的完整实现思路(含逐行换行、锚点自动切换、跨行连线调整等细节)、如何在 JointJS 中初始化画布并批量创建元素与连线,以及如何监听窗口尺寸变化并自适应地重新布局。

一、什么是蛇形布局

在时间线、谱系图、流程链等一维有序数据场景中,元素往往需要按先后顺序排列。如果内容很多、画布宽度有限,最简单的做法是让元素一直向右延伸,但这会造成横向滚动过长、视觉割裂。

蛇形布局(Serpentine Layout)的解法是:按顺序将元素逐行放入固定宽度内,第一行从左到右填充,第二行从右到左填充,第三行再从左到右……如此往复形成 zigzag 路径,既保证了元素在顺序上的连续可读性,又让整体区域紧凑地收拢在给定宽度之内。

事实依据:本示例 README.md 开篇即定义了这一布局的语义——"a custom layout where the elements are arranged in a zigzag pattern, where the rows are filled alternately from left to right and right to left, and where the rows fit the given width"(元素按锯齿模式排布,行从左到右与从右到左交替填充,且每行适配给定宽度)。

二、示例项目的结构与运行

本示例位于 examples/serpentine-layout-js,是一个基于 Vite 的纯前端 demo,核心文件如下:

文件作用
src/main.js示例全部逻辑:画布初始化、布局算法、元素/连线创建
src/styles.css画布容器与 Logo 的样式
index.html页面入口,挂载#paper-container并引入模块脚本
package.json依赖与脚本(@joint/core、vite)

1. 安装依赖

在 monorepo 根目录执行(来自原文档):

yarn install yarn run build

由于package.json中通过"@joint/core": "workspace:^"引用仓库内的 JointJS 核心包,因此需先在仓库根目录完成整体安装与构建,示例才能正确解析到本地@joint/core。

2. 开发模式

在示例目录下启动开发服务器:

yarn dev

然后打开终端打印出的 URL(通常是http://localhost:5173)。该脚本在 package.json 中对应vite命令。

3. 生产构建

yarn build

该脚本为tsc && vite build,产物输出到dist/目录。

4. 本地预览构建产物

yarn preview

使用vite preview在本机预览生产构建的结果。

三、画布(Paper)初始化

main.js首先创建一个空图(Graph)与画布(Paper),并挂载到页面容器中:

const graph = new dia.Graph({}, { cellNamespace: shapes }); const paper = new dia.Paper({ model: graph, cellViewNamespace: shapes, width: '100%', gridSize: 20, async: true, sorting: dia.Paper.sorting.APPROX, defaultConnector: { name: 'curve' }, defaultConnectionPoint: { name: 'anchor' }, background: { color: '#fff' } }); paperContainer.appendChild(paper.el);

关键配置说明:

  • cellNamespace/cellViewNamespace:将标准形状集合(shapes.standard.Rectangle、shapes.standard.Link等)注册到图与画布的命名空间,保证模型与视图可正确解析。
  • gridSize: 20:网格步长 20px,配合后面的布局计算可形成规整的排布效果。
  • defaultConnector: { name: 'curve' }:连线默认使用曲线连接器;JointJS 的 Paper 默认连接器其实是{ name: 'normal' }(见 Paper.mjs),这里显式改为 curve 以获得更柔和的视觉。
  • defaultConnectionPoint: { name: 'anchor' }:默认连接点改为anchor(默认值为boundary,见 Paper.mjs)。connection point 决定连线落在元素边界上的哪个点,anchor表示直接使用锚点所在位置,使曲线从元素左右侧锚点精确出发。
  • sorting: dia.Paper.sorting.APPROX:开启近似排序,异步渲染下仍能保持合理的 z 顺序。
  • async: true:开启异步渲染,元素较多时界面响应更流畅。

四、核心算法:serpentineLayout

布局函数签名如下(来自 src/main.js):

function serpentineLayout(graph, elements, options = {}) { const { gap = 20, // 同排元素之间的水平间距 width = 1000, // 每行可用宽度 rowHeight = 100, // 相邻两行之间的垂直间距 x = 0, // 布局区域左上角 x y = 0, // 布局区域左上角 y alignRowLastElement = false // 是否将每行末尾元素对齐到行边界 } = options; // ... 详见下文 return currentY; // 返回布局结束时的最底端 y 坐标 }

1. 提取有序连线

算法假设传入的elements数组本身就是有序序列(例如按时间先后排列的历史人物)。首先找出连接相邻两个元素的连线:

const links = []; elements.forEach((el, i) => { const nextEl = elements[i + 1]; if (!nextEl) return; const link = graph.getConnectedLinks(el, { outbound: true }) .find(l => l.target().id === nextEl.id); if (link) links.push(link); });

这里用到 Graph 提供的getConnectedLinks(el, { outbound: true }):只取以el为起点的出边(实现见 Graph.mjs),再从中挑选目标正好是下一个元素的连线。这样布局时只调整序列内部的链,避免误伤其他连线。

2. 逐行排布与换行判定

算法用leftToRight标志记录当前行的填充方向,currentX/currentY维护当前游标位置:

let currentX = x; let currentY = y + rowHeight / 2; let leftToRight = true;

向右填充时,若currentX + size.width > x + width说明放不下当前元素,于是:

currentX = x + width; // 游标移到行尾 currentY += rowHeight; // 换到下一行 leftToRight = false; // 反向填充

向左填充时,若currentX - size.width < x说明已越出左边界,则:

currentX = x; currentY += rowHeight; leftToRight = true;

被换行"挤掉"的元素不立即放置,而是在下一轮循环中以新的方向重新计算位置——这正是锯齿效果的来源。

3. 行内位置与方向记录

确定方向后,元素位置被写入elementProps:

elementProps[index] = { position: { y: currentY - size.height / 2 }, // 垂直居中于行 leftToRight }; if (leftToRight) { elementProps[index].position.x = currentX; currentX += size.width + gap; } else { elementProps[index].position.x = Math.max(currentX - size.width, x); currentX -= size.width + gap; }

注意反向行中position.x使用Math.max(currentX - size.width, x)做下限保护,避免元素被推出左边界。

4. 行尾元素的锚点切换

当一行放不下而换行时,上一行的最后一个元素到本行第一个元素之间的连线需要跨行拐弯。算法为这条特殊连线切换锚点:

linkProps[index - 1] = { source: { anchor: { name: 'right' }}, // 上一行末尾元素:从右侧出线 target: { anchor: { name: 'right' }}, // 下一行反向行的首个元素:也从右侧出线 };
  • 向左换行时(leftToRight由 true 变为 false),两个端点都锚定在元素右侧,连线向右绕行到下一行;
  • 向右换行时(leftToRight由 false 变为 true),两个端点都锚定在元素左侧。

而同一行内的相邻连线则使用"出线端右侧、入线端左侧"(正向行)或相反的锚点组合(反向行):

if (leftToRight) { linkProps[index] = { source: { anchor: { name: 'right' }}, target: { anchor: { name: 'left' }}, }; } else { linkProps[index] = { source: { anchor: { name: 'left' }}, target: { anchor: { name: 'right' }}, }; }

Anchor 是 JointJS 中决定连线端点吸附在元素哪个位置的原生机制,示例通过动态切换right/left锚点,让曲线自然贴合蛇形路径。

5. 行末对齐(可选)

若开启alignRowLastElement,换行时还会微调上一行最后一个元素,使其贴齐行边界,让整体轮廓更整齐:

if (alignRowLastElement) { // 反向行开始前:把上一行末尾元素贴齐右边界 elementProps[elementProps.length - 1].position.x = Math.max( x + width - elements[elementProps.length - 1].size().width, x ); } // 正向行开始前:把上一行末尾元素贴齐左边界 elementProps[elementProps.length - 1].position.x = x;

6. 批量应用与返回值

所有计算完成后,一次性写入模型,并返回最底部的 y 坐标供外层调整画布高度:

elementProps.forEach((props, i) => { elements[i].prop(props); }); linkProps.forEach((props, i) => { if (links[i]) links[i].prop(props); }); return currentY;

使用prop()批量设置属性(而非逐个 set),配合 JointJS 的批量变更机制可以显著减少重复渲染。

五、创建元素与连线

示例用shapes.standard.Rectangle创建 150×40 的节点,用shapes.standard.Link创建曲线连线:

function createElement(text) { return new shapes.standard.Rectangle({ size: { width: 150, height: 40 }, attrs: { body: { fill: '#fffae2', stroke: '#ffc7b0', rx: 5, ry: 5 }, label: { text, fill: '#ff9580', fontSize: 14, fontWeight: 'bold' } } }); } function createLink(source, target) { return new shapes.standard.Link({ source: { id: source.id }, target: { id: target.id }, attrs: { line: { stroke: '#80eaff', strokeWidth: 2, targetMarker: { 'type': 'path', 'd': 'M 10 -5 0 0 10 5 z', 'fill': '#b6ffff', 'stroke-width': 2 } } } }); }

随后以 30 位按年代排序的欧洲历史人物(Louis XIV → Louis-Charles)为序列,生成 30 个元素与 29 条连线并一次性加入图:

graph.addCells([...elements, ...links]);

addCells批量添加既能保证顺序,也比逐个addCell更高效。

六、自适应布局与窗口缩放

布局初始化及窗口 resize 时的重排逻辑:

function layout() { const x0 = 100; const y0 = 50; const yMax = serpentineLayout(graph, elements, { gap: 20, rowHeight: 60, x: x0, y: y0, width: window.innerWidth - 2 * x0, // 行宽随视口自适应 }); paper.setDimensions('100%', yMax + y0 + 50); // 按内容高度调整画布 } layout(); window.addEventListener('resize', util.debounce(layout, 100));

要点:

  • 行宽width取自视口宽度减去左右边距,因此拖拽窗口时布局会自动重新计算,行数随之增减;
  • serpentineLayout返回的currentY是内容最底端坐标,paper.setDimensions('100%', yMax + y0 + 50)将画布高度撑到内容高度,内容过宽时由 styles.css 中#paper-container { overflow: auto; }提供横向滚动条;
  • resize 监听通过util.debounce(layout, 100)防抖(JointJS 的 debounce 实现位于 utilHelpers.mjs),避免窗口拖动过程中频繁执行昂贵的布局计算。

七、布局参数速查

参数默认值说明
gap20同一行内相邻元素之间的水平间距
width1000每行可用的宽度,超宽则换行
rowHeight100两行之间的垂直行距
x0布局区域的左边界
y0布局区域的顶边界
alignRowLastElementfalse为 true 时把每行最后一个元素贴齐到行边界,使轮廓对齐

示例实际调用时使用的参数为gap: 20, rowHeight: 60, x: 100, y: 50, width: window.innerWidth - 2 * 100,其中rowHeight小于默认值,让整体更紧凑。

八、小结

本示例展示了 JointJS「布局算法与渲染解耦」的典型用法:布局逻辑完全由自定义函数负责,最终只通过prop()写回模型的position与连线的source/target anchor,再由 Paper 的 curve 连接器完成曲线绘制。这套蛇形布局可以直接复用到时间线、编年史、谱系链等任何"一维有序 + 定宽换行"的图表场景:

  1. 组织好有序的元素数组与相邻连线;
  2. 用getConnectedLinks筛选序列内连线;
  3. 按leftToRight交替填充并维护游标,配合行尾锚点切换;
  4. 返回内容底部坐标,据此调整画布尺寸;
  5. 监听 resize 并以 debounce 方式重跑布局,即可获得完全自适应的蛇形图表。
  • 前端
  • UI组件

【免费下载链接】joint

A proven SVG-based JavaScript diagramming library powering exceptional UIs

项目地址:https://gitcode.com/gh_mirrors/jo/joint
点击查看免费下载

相关推荐

上一篇:一个 40KB 的 ZIP,凭什么让上万名中文玩家的 MASA 模组界面秒变中文?保姆级安装与进阶指南
下一篇:FckSignups slugify 算法解析:工具名如何变成 URL 友好 ID(完整指南)

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询