- 前端
- UI组件
【免费下载链接】joint
A proven SVG-based JavaScript diagramming library powering exceptional UIs
导读
本指南围绕 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),避免窗口拖动过程中频繁执行昂贵的布局计算。
七、布局参数速查
| 参数 | 默认值 | 说明 |
|---|---|---|
gap | 20 | 同一行内相邻元素之间的水平间距 |
width | 1000 | 每行可用的宽度,超宽则换行 |
rowHeight | 100 | 两行之间的垂直行距 |
x | 0 | 布局区域的左边界 |
y | 0 | 布局区域的顶边界 |
alignRowLastElement | false | 为 true 时把每行最后一个元素贴齐到行边界,使轮廓对齐 |
示例实际调用时使用的参数为gap: 20, rowHeight: 60, x: 100, y: 50, width: window.innerWidth - 2 * 100,其中rowHeight小于默认值,让整体更紧凑。
八、小结
本示例展示了 JointJS「布局算法与渲染解耦」的典型用法:布局逻辑完全由自定义函数负责,最终只通过prop()写回模型的position与连线的source/target anchor,再由 Paper 的 curve 连接器完成曲线绘制。这套蛇形布局可以直接复用到时间线、编年史、谱系链等任何"一维有序 + 定宽换行"的图表场景:
- 组织好有序的元素数组与相邻连线;
- 用
getConnectedLinks筛选序列内连线; - 按
leftToRight交替填充并维护游标,配合行尾锚点切换; - 返回内容底部坐标,据此调整画布尺寸;
- 监听 resize 并以 debounce 方式重跑布局,即可获得完全自适应的蛇形图表。
- 前端
- UI组件
【免费下载链接】joint
A proven SVG-based JavaScript diagramming library powering exceptional UIs
相关推荐
JointJS自动布局终极指南:ELK和MSAGL算法实战解析
JointJS自动布局终极指南:ELK和MSAGL算法实战解析 在当今数据驱动的时代,图形可视化已成为应用程序不可或缺的一部分。JointJS作为一款强大的SV
前端UI组件G6 Snake Layout 蛇形布局完全指南:原理、配置与实战
G6 Snake Layout 蛇形布局完全指南:原理、配置与实战 蛇形(Snake)布局是 G6 中一种专为长链结构设计的图布局算法,它把呈线性排列的节点折叠
数据可视化前端图表库X6自定义布局:实现复杂图形排列算法
X6自定义布局:实现复杂图形排列算法 引言:图形布局的技术痛点与解决方案 在可视化应用开发中,你是否曾面临以下挑战: 节点重叠导致的视觉混乱 大规模数据下的布局
前端图形学
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考