主题
布局制作指南 🗺️
🌐 The Layout Maker's Guide 🗺️
布局算法决定节点的位置以及边如何从一个节点连到另一个节点。形状、主题、标记和标签已经由共享的渲染代码处理了。
🌐 A layout algorithm decides where nodes sit and how edges get from one to the next. Shapes, themes, markers, and labels are already handled by the shared rendering code.
这意味着你永远不用接触 DOM。你得到的是一个图,它的节点已经知道自己的大小,你只需要返回数字:每个节点的中心点,以及每条边的一系列点。绘制形状、描边、放置箭头、调整 viewBox——这些都是别人根据你的数字来处理的代码。你做的是解决几何问题,不是渲染问题,这也正是为什么布局可以在没有浏览器的情况下进行测试。
🌐 Which means you never touch the DOM. You are handed a graph whose nodes already know how big they are, and you hand back numbers: a center point for every node, a list of points for every edge. Drawing the shapes, painting the strokes, placing the arrowheads, fitting the viewBox — all of that is somebody else's code working from your numbers. You are solving a geometry problem, not a rendering one, which is exactly why a layout can be tested without a browser.
本指南涵盖了位于 Mermaid 包内的布局,就在 dagre 和其他布局的旁边。最后一部分说明了如何将相同的代码作为独立的 npm 包发布。
🌐 This guide covers layouts that live inside the Mermaid package, next to dagre and the others. The last section explains how to ship the same code as a standalone npm package instead.
树中有两个布局值得一并阅读。dagre 是默认布局,也是最老的。swimlanes 是最新的,它遵循这里描述的规范,所以大多数示例都是指向它的。
🌐 Two layouts in the tree are worth reading alongside it. dagre is the default and the oldest. swimlanes is the newest, and it is the one that follows the conventions described here, so most of the examples point at it.
布局接收什么以及它必须产生什么
🌐 What a layout receives and what it must produce
每个布局都会使用相同的结构 LayoutData,不管它是由哪种图类型生成的:
🌐 Every layout is handed the same structure, LayoutData, regardless of which diagram type produced it:
ts
interface LayoutData {
nodes: Node[];
edges: Edge[];
config: MermaidConfig;
diagramId?: string;
}runLayoutCore,承载你算法的阶段,会写入这些字段,而不会写入其他字段:
| 字段 | 作用对象 | 含义 |
|---|---|---|
node.x, node.y | 节点 | 节点的中心,处于布局空间中 |
node.width, node.height | 节点 | 为叶节点测量到达位置——保持原样不动;组节点上可以设置 |
edge.points | 边 | 从源边界到目标边界的折线,至少两个点 |
edge.x, edge.y | 边 | 边标签的锚点,当边有标签时使用 |
一个阶段可以做更多事情。prepareLayout 在测量之前运行,并可能重建图 —— 替换 data4Layout.nodes 和 data4Layout.edges,添加边标签需要的合成节点,移除你的算法以其他方式处理的内容。“只有这些字段,别的不要” 是关于 runLayoutCore 的规则,这里图已经固定,只有几何会动。
🌐 One stage is allowed to do more. prepareLayout runs before measurement and may rebuild the graph — replace data4Layout.nodes and data4Layout.edges, add the synthetic nodes an edge label needs, drop what your algorithm handles another way. "These fields and no others" is a rule about runLayoutCore, where the graph is settled and only geometry moves.
布局空间
🌐 Layout space
位置是中心。node.x, node.y 是节点框的中点,所以它的左边缘是 x - width / 2,上边缘是 y - height / 2。布局库对此有不同的说法——有些报告的是节点的左上角,有些则是从左下角原点向上测量 y——所以如果你在封装其中一个,最好在 runLayoutCore 中转换它的输出,而不是让渲染器自己猜。Dagre 和 ELK 都报告中心点,这就是内置布局会直接传递它们的原因。
🌐 Positions are centers. node.x, node.y is the middle of the node's box, so its left edge is x - width / 2 and its top edge is y - height / 2. Layout libraries disagree about this — some report a node's top-left corner, some measure y upward from a bottom-left origin — so if you are wrapping one, convert its output in runLayoutCore rather than leaving the renderer to guess. Dagre and ELK both report centers, which is why the built-in layouts pass them straight through.
除此之外,布局空间是一个平面:x 向右增加,y 向下增加。没有固定的原点,也不要求坐标为正。渲染器会把你生成的所有内容的边界框转化成 SVG 的 viewBox,所以以 (0, 0) 为中心的布局和从 (0, 0) 开始的布局渲染出来是一样的。不要花时间去规范坐标;这个操作会立即被撤销。
🌐 Beyond that, layout space is a plain plane: x grows to the right, y grows downward. There is no fixed origin and no requirement that coordinates be positive. The renderer takes the bounding box of everything you produced and translates it into the SVG viewBox, so a layout centered on (0, 0) and a layout starting at (0, 0) render identically. Do not spend a pass normalizing coordinates; it is undone immediately.
Node 和 Edge 承载的内容比这多得多,所有这些都在 rendering-util/types.ts 中定义。最好去看那里的类型,而不是根据调试器显示的内容去猜:形状、标签几何、样式和端口信息都在同一个对象上,而且大多数属于渲染器,而不是你。
两个结构字段是你的输入,不是你的输出。node.isGroup 标记一个子图容器,node.parentId 表示节点所属的组。读取它们,永远不要重写它们。
🌐 Two structural fields are your input, not your output. node.isGroup marks a subgraph container, and node.parentId names the group a node belongs to. Read them, never rewrite them.
叶子的大小已经是测量好的,不是你可以计算的。渲染器会把每个叶子节点插入到 SVG 中,调用 getBBox(),然后在你的算法运行之前写回 width 和 height,所以如果你重新计算或覆盖叶子的大小,它的标签在渲染时就会溢出形状。读取它们,把它们当作固定的处理。
🌐 Leaf sizes arrive already measured, and they are not yours to compute. The renderer inserts every leaf node into the SVG, calls getBBox(), and writes width and height back before your algorithm runs, so a leaf whose size you recalculate or overwrite will have its label spill out of its shape at render time. Read them; treat them as fixed.
组是例外,也是唯一你需要设置大小的地方。组的范围取决于你放置它的子项的位置,所以无法提前测量——每个设置了 isGroup 的节点都需要你提供 width 和 height,足够大以包住它的成员和标题栏。详见 Groups。
🌐 Groups are the exception, and the only place you do set a size. A group's extent is a consequence of where you put its children, so nothing can measure it up front — every node with isGroup set needs a width and height from you, big enough to enclose its members and the title band. See Groups.
那一次测量过程是整个流程中唯一接触 DOM 的步骤,这也就是为什么它之后的所有东西都可以在 Node 中进行测试。
🌐 That one measuring pass is the only step in the whole pipeline that touches the DOM, which is what makes everything after it testable in Node.
五个阶段
🌐 The five stages
布局是通过从 rendering-util/layout-algorithms/common/index.ts 调用 createCommonLayoutRenderer 来构建的。它会给你五个钩子,按顺序运行它们,并处理绘图:
🌐 Layouts are built by calling createCommonLayoutRenderer from rendering-util/layout-algorithms/common/index.ts. It gives you five hooks, runs them in order, and handles painting:
ts
export const render = createCommonLayoutRenderer({
prepareLayout, // reshape LayoutData before measuring
measureLayout, // DOM: insert elements, read sizes (has a default)
runLayoutCore, // your algorithm, no DOM allowed
paintLayout, // escape hatch: take over painting entirely
afterPaint, // touch up after paths exist
paintOptions, // tweak the standard painter
});只需要 runLayoutCore。泳道布局是一个完整的二十行示例:
🌐 Only runLayoutCore is required. The swimlanes layout is a complete example in twenty lines:
ts
import { createCommonLayoutRenderer } from '../common/index.js';
import { applySwimlaneLineJumps } from './adjustLayout.js';
import { prepareLayoutForSwimlanes } from './helpers.js';
import { createEdgeLabelNodes } from './edgeLabelNodes.js';
import { runSwimlaneLayoutCore } from './layoutCore.js';
function prepareSwimlaneLayout(data4Layout: LayoutData): void {
prepareLayoutForSwimlanes(data4Layout);
const transformedData = createEdgeLabelNodes(data4Layout);
data4Layout.nodes = transformedData.nodes;
data4Layout.edges = transformedData.edges;
}
export const render = createCommonLayoutRenderer({
prepareLayout: prepareSwimlaneLayout,
runLayoutCore: runSwimlaneLayoutCore,
afterPaint: applySwimlaneLineJumps,
});保持核心不被 DOM 附着
🌐 Keep the core free of the DOM
runLayoutCore 必须是 LayoutData 的纯函数。不能有 document,不能有 getBBox,也不能有 d3 选择。
原因是为了测试。节点大小会在浏览器中测量一次,然后保存到一个文件中。测试会加载这些大小,把它们传给 runLayoutCore,然后得到浏览器本来会生成的相同坐标,而无需打开浏览器。这个方法只有在核心保持与 DOM 无关时才有效。一旦它需要用到 document,就只能在真实页面中运行,这时围绕它的任何测试其实都是在走和用户实际经历不同的代码路径。
🌐 The reason is testing. Node sizes get measured in a browser once and saved to a file. A test then loads those sizes, hands them to runLayoutCore, and gets back the same coordinates the browser would have produced, without opening a browser at all. That only holds while the core stays free of the DOM. The moment it reaches for document, it can only run inside a real page, and any test around it is exercising a different code path than the one your users hit.
所以把核心写成一个导出的函数,让浏览器和你的测试都调用同一个函数。
🌐 So write the core as one exported function, and have both the browser and your tests call that same function.
极简布局
🌐 A minimal layout
从这里开始的示例构建了一个名为 grid 的布局。Mermaid 并没有自带这种布局。它只是代表你正在编写的内容,下面的代码就是你用来创建它的。
🌐 The examples from here on build a layout called grid. No such layout ships with Mermaid. It stands in for whatever you are writing, and the code below is what you would write to create it.
把算法放在 packages/mermaid/src/rendering-util/layout-algorithms/<name>/。这个算法会把叶节点排列在网格中,并用直线连接它们:
ts
// layout-algorithms/grid/layoutCore.ts
import type { LayoutData } from '../../types.js';
const GAP = 60;
/** DOM-free: positions come from measured sizes only. */
export function runGridLayoutCore(data4Layout: LayoutData): void {
const leaves = data4Layout.nodes.filter((node) => !node.isGroup);
const columns = Math.ceil(Math.sqrt(leaves.length));
const cell = Math.max(...leaves.map((n) => Math.max(n.width ?? 0, n.height ?? 0))) + GAP;
leaves.forEach((node, i) => {
node.x = (i % columns) * cell;
node.y = Math.floor(i / columns) * cell;
});
const byId = new Map(data4Layout.nodes.map((node) => [node.id, node]));
for (const edge of data4Layout.edges) {
const from = byId.get(edge.start ?? '');
const to = byId.get(edge.end ?? '');
if (!from || !to) {
continue;
}
edge.points = [
{ x: from.x ?? 0, y: from.y ?? 0 },
{ x: to.x ?? 0, y: to.y ?? 0 },
];
}
}ts
// layout-algorithms/grid/index.ts
import { createCommonLayoutRenderer } from '../common/index.js';
import { runGridLayoutCore } from './layoutCore.js';
export const render = createCommonLayoutRenderer({ runLayoutCore: runGridLayoutCore });那会渲染出来。它在布局可能出错的大多数方面也都是错的。
🌐 That renders. It is also wrong in most of the ways a layout can be wrong.
注册布局
🌐 Registering the layout
随 Mermaid 一起提供的布局列在 packages/mermaid/src/rendering-util/render.ts 的 registerDefaultLayoutLoaders() 中。为你自己的布局添加一条记录:
🌐 The layouts that ship with Mermaid are listed in registerDefaultLayoutLoaders() in packages/mermaid/src/rendering-util/render.ts. Add one entry for yours:
ts
registerLayoutLoaders([
{ name: 'dagre', loader: async () => await import('./layout-algorithms/dagre/index.js') },
{ name: 'swimlane', loader: async () => await import('./layout-algorithms/swimlanes/index.js') },
// your new layout
{ name: 'grid', loader: async () => await import('./layout-algorithms/grid/index.js') },
]);加载器是懒加载的,所以代码只有在图表需要时才会下载。cose-bilkent 的注册方式相同,但被封装在对 includeLargeFeatures 的检查中,这就是布局如何避免被包含到小型构建里的方法。然后用户通过你注册的名称来选择布局:
🌐 The loader is lazy, so the code only downloads when a diagram asks for it. cose-bilkent is registered the same way but wrapped in a check on includeLargeFeatures, which is how a layout stays out of the tiny build. Users then select the layout by the name you registered:
text
---
config:
layout: grid
---
flowchart TB
A --> B组、标签和边
🌐 Groups, labels, and edges
群组
🌐 Groups
一个组节点携带 isGroup: true,其成员携带指向它的 parentId。组可以嵌套。你的算法管理组的 x、y、width 和 height,而框架必须给每个子代留出标题的空间。组还可以有一个可选的 groupTitleRect 来描述标题带。通过标题带路由的边会与标题文字发生碰撞。
🌐 A group node carries isGroup: true, and its members carry parentId pointing at it. Groups nest. Your algorithm owns the group's x, y, width, and height, and the frame must enclose every descendant with room for the title. Groups also have an optional groupTitleRect describing the header band. Edges routed through that band collide with the title text.
标准绘图工具会将一组画成一个簇,其它的都画成节点。当你的布局对哪些节点是容器有自己的想法时,可以用 paintOptions.isCluster 来覆盖这个行为。
🌐 The standard painter draws a group as a cluster and everything else as a node. Override that with paintOptions.isCluster when your layout has its own idea of which nodes are containers.
边缘标签
🌐 Edge labels
在确定位置之前,标签需要预留空间,否则它们会落在边和节点上。可行的方法是将每条带标签的边在临时节点周围拆成 start → label → end,让算法像对待其他节点一样放置该节点,然后再将其折叠回覆盖标签。Swimlanes 在 createEdgeLabelNodes 中就是这样做的,通过它的 prepareLayout 钩子调用,这样假节点就像真实文本一样和其他内容一起被测量。
🌐 Labels need space reserved before positions are decided, otherwise they land on top of edges and nodes. The approach that works is to split each labelled edge into start → label → end around a temporary node, let the algorithm place that node like any other, then fold it back into an overlay label. Swimlanes does this in createEdgeLabelNodes, called from its prepareLayout hook so the dummy node is measured as real text along with everything else.
发明第三种机制,你的标签就不会被验证器检查,因为验证器会读取这个模式产生的标签几何形状。
🌐 Invent a third mechanism and your labels will not be checked by the validator, because the validator reads the label geometry this pattern produces.
边缘端点和标记
🌐 Edge endpoints and markers
边缘路径必须从它们节点的边界开始和结束,而不是从中心,也不能悬浮在空中。默认情况下,绘图器会通过将路径与节点形状相交来重新计算端点,这会悄悄弯曲精心规划的边的最后一段。如果布局需要精确连接到端口,应该用 paintOptions.skipIntersect 关闭这个功能。
🌐 Edge paths must start and end on the boundary of their nodes, not at the center and not floating in space. By default the painter recomputes the endpoint by intersecting the path with the node shape, which will quietly bend the last segment of a carefully routed edge. Layouts that route to exact ports should turn that off with paintOptions.skipIntersect.
箭头大约占据最后一段的最后十个像素。在那段里面有一个弯曲,使得箭头下面有一个角,验证器就会说:常量是 EPS_FINAL_APPROACH,值是 10。
🌐 Arrowheads occupy roughly the last ten pixels of the final segment. A bend inside that stretch puts a corner underneath the arrowhead, and the validator calls it: the constant is EPS_FINAL_APPROACH, and it is 10.
自环和重边
🌐 Self-loops and parallel edges
带有 start === end 的边没有固定方向,需要自己找路线,通常是在节点一侧的小矩形。相同节点之间的平行边需要手动分开,否则它们会显示为一条线。两者的夹具都在 e2e/platform/dev-diagrams/layout-tests/ 中,以 self-loop.mmd、self-loop-2.mmd、self-loop-multi.mmd 和 identical-edges.mmd 形式存在。
🌐 An edge with start === end has no direction to follow and needs its own route, usually a small rectangle off one side of the node. Parallel edges between the same pair need to be separated by hand, or they render as one line. Fixtures for both live in e2e/platform/dev-diagrams/layout-tests/ as self-loop.mmd, self-loop-2.mmd, self-loop-multi.mmd, and identical-edges.mmd.
验证结果
🌐 Validating the result
layout-algorithms/layout-utils/validateLayout.ts 中的 validateLayout 是布局质量的共同评判者。它接受完成的 LayoutData 并返回一个判决和一个分数:
ts
import { validateLayout } from '../layout-utils/validateLayout.js';
const result = validateLayout(layout);
// result.ok → boolean, false when any hard constraint is broken
// result.issues → what broke, with node/edge ids and details
// result.score → 0 to 1000; exactly 0 whenever ok is false
// result.breakdown → crossings, per-edge bend penalties, point histogram硬性约束
🌐 Hard constraints
ok 如果有任何问题就为假,分数会降到零。在你写路由之前,这些失败情况是值得了解的:
| 问题 | 它的意思 |
|---|---|
node-overlap | 两个节点占据了同一个位置 |
edge-intersects-node、edge-intersects-obstacle | 一条路径经过它没有连接到的节点 |
edge-intersects-group-title | 一条路径穿过子图的标题栏 |
edge-endpoint-detached-from-node、edge-endpoint-inside-node | 一个端点遗漏了边界 |
edge-non-orthogonal | 一段既不是水平也不是垂直 |
edge-missing-points | 边缘上的点少于两个 |
edge-shared-subpath、edge-parallel-segment-too-close | 两条边重叠或太靠近,难以分辨 |
edge-shared-attachment-point、edge-same-port-departure | 两条边从同一个节点的同一个点出发 |
edge-bend-near-endpoint,edge-bend-overlaps-arrowhead | 一个角位于箭头下方或靠近节点 |
edge-border-hugging、node-border-hugging | 几何图形沿着边界运行,而不是远离它 |
edge-label-overlaps-node、edge-label-overlaps-foreign-edge | 标签覆盖在其他东西上 |
edge-label-off-edge | 标签离它所属的边缘有一段距离 |
容差是在 validateLayout.ts 顶部定义的常量,其中有两个解释了大多数新人会遇到的惊讶:EPS_FINAL_APPROACH 是 10,表示端点附近的一个区域,在这个区域内不允许弯曲;EPS_SHARED_ATTACH 是 3,表示两条边在一个节点上相互靠多近才算作共享同一个点。测试断言的是相对顺序,而不是精确数值,所以把这些数字当作当前值来看,而不是固定值。
🌐 The tolerances are named constants at the top of validateLayout.ts, and two of them explain most first-time surprises: EPS_FINAL_APPROACH is 10, the stretch near an endpoint where a bend is not allowed, and EPS_SHARED_ATTACH is 3, how close two edges may attach to the same node before they count as sharing a point. The tests assert relative ordering rather than exact magnitudes, so treat the numbers as current rather than fixed.
有些检查是基于正交布线的假设。如果布局有弯曲或对角线的边,edge-non-orthogonal 会在每条边上触发,所以要么采用正交布线,要么在把它的分数当作目标之前弄清楚验证器的哪一部分适用于你。
🌐 Some checks assume orthogonal routing. A layout with curved or diagonal edges will trip edge-non-orthogonal on every edge, so either route orthogonally or work out which subset of the validator applies to you before treating its score as a target.
得分
🌐 The score
当 ok 为真时,分数从 1000 开始下降。折线的拐点按边计算,交叉点则全局只算一次:
🌐 When ok is true the score starts at 1000 and comes down. Bends are counted per edge from the polyline point count, and crossings are counted once globally:
| 折线点数 | 弯曲数 | 惩罚 |
|---|---|---|
| 2 | 0 | 0 |
| 3 | 1 | 0 |
| 4 | 2 | 5 |
| 5 | 3 | 12 |
| 6 | 4 | 30 |
| 7 或更多 | 5+ | 30 × 2^(点数 − 6) |
每次穿越花费3。曲线在顶部刻意设计得很陡:一个七弯的边比二十多次穿越还贵,因为没人能走的路径比起有交点但整洁的图更糟。
🌐 Each crossing costs 3. The curve is deliberately steep at the top end: one seven-bend edge costs more than twenty crossings, because a path nobody can follow is worse than a tidy diagram with intersections.
分数不会随着图表大小而变化。一个小图可能就能达到1000分;而一个大图很少会达到,所以应该把一个装置和它自己的历史比较,而不是和另一个装置比。
🌐 The score does not scale with diagram size. A small graph reaches 1000; a large one rarely will, so compare a fixture against its own history rather than against another fixture.
result.breakdown.edges 是按最差优先排序的,这让它成为找到拖慢布局的原因的最快方式。
看起来有用但其实没用的东西
🌐 One thing that looks useful and is not
layout-utils 也包含 scoreLayout,它会计算一些更温和的指标:宽高比、每条边的平均弯曲次数、秩保持度、邻域保持度、直边比例。坐在 validateLayout 旁边,它看起来就像是一对匹配中质量较好的一半。
它没有接入任何东西。布局管道里没有什么会调用它,没有任何夹具规范会调用它,它唯一的调用者就是它自己的单元测试。它的 symmetryScore 是一个未完成的占位符,总是返回 NaN。把 validateLayout 当作唯一的判断标准,除非你是故意去处理那个未完成的工作,否则别动 scoreLayout。
🌐 It is not wired into anything. Nothing in the layout pipeline calls it, no fixture spec calls it, and its only caller is its own unit test. Its symmetryScore is an unfinished placeholder that always returns NaN. Treat validateLayout as the only judge, and leave scoreLayout alone unless you are deliberately picking up that unfinished work.
计划有第三级,但尚未建成。compareLayoutSnapshot() 会将布局结构与存储的基线进行比较,以捕捉那些仍在验证器容差范围内的回归。现在没有这样的功能,所以没有任何东西依赖它。
🌐 A third level is planned and not built. compareLayoutSnapshot() would diff a layout's structure against a stored baseline to catch regressions that stay inside the validator's tolerances. There is no such function today, so nothing depends on it.
用 DDLT 测试
🌐 Testing with DDLT
DOM-解耦布局测试会在 Node 上运行你的算法,使用从真实浏览器中捕获的尺寸。测试几秒钟就能完成,每次都会给出相同的结果,而且因为浏览器和测试调用的是同一个核心函数,所以在一个地方修复的问题在另一个地方也会被修复。
🌐 DOM-Decoupled Layout Testing runs your algorithm in Node against sizes captured once from a real browser. Tests come back in seconds and give the same answer every time, and because the browser and the tests call the same core function, a fix in one is a fix in the other.
测试必须运行浏览器的代码路径
🌐 Tests must run the browser's code path
型号是 parse → measure → run layout → paint,第三步必须是一个单独的函数。测试会替换测量;浏览器是真实执行的。中间运行的部分必须完全相同。
🌐 The model is parse → measure → run layout → paint, and the third step has to be a single function. Tests swap out the measuring; the browser does it for real. What runs in between must be identical.
这听起来很明显,而实际上问题就是经常出在这里。一个仅用于测试的布局入口放在浏览器入口旁边,即使看起来做的是同样的事情,也算是一个 bug。这已经让这个仓库栽过不止一次,每次的情况都差不多。浏览器入口会把边缘管线包在退化检查和方向违规检查里,还挂上一个回退分支和一个重路由分支。测试后端则直接调用管线,跳过了所有这些。测试数据返回是有效的,浏览器走了回退分支,而回退分支直接画了一条折线穿过了节点内部。验证器本来能抓到这个问题,但测试框架从来没发现过它。
🌐 That sounds obvious, and it is where this gets broken most often. A test-only layout entry point sitting alongside the browser's is a bug even when the two look like they do the same thing. This has bitten this repo more than once, and the shape is always the same. The browser entry wrapped the edge pipeline in a degeneracy check and a direction-violation check, with a fallback and a reroute branch hanging off them. The test backend called the pipeline directly and skipped all of that. Fixtures came back valid, the browser took the fallback, and the fallback drew a polyline straight through the interior of a node. The validator would have caught it. The test harness never saw it.
所以当你接上一个测试后端时,找到浏览器实际调用的函数,然后从头到尾跟踪它。如果它需要 DOM,就把不用 DOM 的部分提取到一个辅助函数里,然后两边都调用这个辅助函数,而不是重新实现那个流程。绝不要把测试指向浏览器用检查、回退、镜像分支或者二次处理包起来的原始对象:测试必须包含所有这些。
🌐 So when you wire up a test backend, find the function the browser actually calls and trace it end to end. If it needs the DOM, lift its DOM-free body into a helper and call that helper from both sides rather than reimplementing the sequence. Never point the test at a primitive that the browser wraps in checks, fallbacks, mirror branches, or second passes: the test has to include all of it.
有一种便宜的方法可以证明接缝是真的。改变浏览器的编排,添加回退,或者切换默认设置,同一个测试装置的测试结果应该会有所变化。如果没有变化,说明测试没有真正作用到你的更改。修复接缝,而不是放宽测试。
🌐 There is a cheap way to prove the seam is real. Change the browser orchestration, add a fallback or switch a default, and the test result for the same fixture should move. If it does not, the test is running around your change. Fix the seam rather than relaxing the test.
当一个灯具在 Node 上通过但在屏幕上看起来有问题时,在同一个灯具上对两者运行 validateLayout 并比较问题。不同的问题意味着不同的管线。把两条调用链放在一起,看看预处理、主调用和后处理的情况,它们第一次分叉的地方就是缝隙。
🌐 When a fixture passes in Node but looks broken on screen, run validateLayout on both, on the same fixture, and compare the issues. Different issues mean different pipelines. Put the two call chains side by side, look for pre-passes, the main call, and post-passes, and the first place they diverge is the seam.
灯具
🌐 Fixtures
一个 fixture 是 e2e/platform/dev-diagrams/layout-tests/ 下的一对文件:
🌐 A fixture is a pair of files under e2e/platform/dev-diagrams/layout-tests/:
text
layout-tests/
simple-graph.mmd ← real Mermaid source, parsed by the real parser
simple-graph.sizes.json ← node dimensions captured from a browser render
ddlt-manifest.json ← per-fixture overrides.mmd 文件会通过实际的解析器,因此测试用例会使用用户图表生成的相同 LayoutData。.sizes.json 文件为每个叶节点和每个边标签保存一个条目,同时包含新鲜度元数据:
🌐 The .mmd file goes through the actual parser, so fixtures exercise the same LayoutData a user's diagram produces. The .sizes.json file holds one entry per leaf node and one per edge label, plus freshness metadata:
json
{
"metadata": {
"captureVersion": 1,
"sourceSha256": "…",
"capturedAt": "2026-02-09T10:00:00Z",
"capturedFrom": "theme=default&look=classic"
},
"nodes": [{ "id": "A", "width": 62, "height": 39 }]
}sourceSha256 是 .mmd 文件的哈希值。在不重新捕获的情况下编辑图表,测试会因为过时的夹具错误而失败,而不会默默地用错误的尺寸布局。
ddlt-manifest.json 给一个测试夹具设置了一个配置文件,这决定了它使用的后端,并且当在其专门的规范中跟踪到已知失败时,可以将其标记为 allowLevel1Failure。
捕捉尺寸
🌐 Capturing sizes
这里有一个按钮可以完成这个操作。用 pnpm dev 启动开发服务器,在 /dev/ 打开资源管理器,然后从文件树中选择你的图表,文件树的根目录和存放测试数据的 dev-diagrams 文件夹是一样的。切换到代码标签页,点击 保存尺寸,就在保存按钮旁边。
🌐 There is a button for this. Start the dev server with pnpm dev, open the explorer at /dev/, and pick your diagram out of the file tree, which is rooted at the same dev-diagrams folder the fixtures live in. Switch to the Code tab and click Save sizes, sitting next to Save.
它完成了整个工作。先保存对图表的未保存编辑,图表在开启尺寸捕捉后重新渲染,测量值会显示在 .mmd 旁边的 <name>.sizes.json。哈希和捕捉版本会为你填写好,所以夹具一到手就能通过新鲜度检查。
🌐 It does the whole job. Unsaved edits to the diagram are written first, the diagram re-renders with size capture switched on, and the measurements go to <name>.sizes.json beside the .mmd. The hash and the capture version are filled in for you, so the fixture passes the freshness check the moment it lands.
这个按钮只对可以生成捕获数据的布局有效,当按钮是灰色时,它会通过工具提示解释自己。
🌐 The button is only enabled for layouts that can produce capture data, and it explains itself through a tooltip when it is greyed out.
如果你需要从资源管理器以外的地方捕获,相同的机制也可以从控制台访问:
🌐 If you need to capture from somewhere other than the explorer, the same machinery is reachable from the console:
js
window.mermaidCaptureSizes = true;
// render the diagram, then:
copy(JSON.stringify(window.mermaidLastCapturedSizes.sizes, null, 2));这样手写的话,元数据块就由你来填写。捕获模块只有在设置了那个标志时才会动态导入,所以无论如何它都不会进入生产包里。
🌐 Written by hand this way, the metadata block is yours to fill in. The capture module is dynamically imported only when that flag is set, so it never reaches a production bundle either way.
当图表源发生变化或形状的实际尺寸发生变化时重新捕捉。不要为了让失败的测试通过而重新捕捉。夹具是之前的图片,重写它会抹掉你试图捕捉的回归问题。
🌐 Recapture when the diagram source changes or when a shape's real dimensions change. Do not recapture to make a failing test pass. The fixture is the before-picture, and rewriting it erases the regression you were trying to catch.
解析图表,不要手动构建图形
🌐 Parse the diagram, do not hand-build the graph
跳过解析器,手动为测试写 LayoutData 很诱人。别这么做。手工构建的图通常会偏离解析器生成的东西,通常是在标识符上,而且一旦 ID 不一样,测试评分的就是浏览器根本不会布局的图。
🌐 It is tempting to skip the parser and write the LayoutData for a test by hand. Resist it. Hand-built graphs drift from what the parser emits, usually in the identifiers, and once the ids differ the test is scoring a graph the browser never lays out.
这些 ID 遵循一些值得了解的规则,因为固定条目会根据它们进行匹配:
🌐 The ids follow rules worth knowing, because fixture entries are matched against them:
| 实体 | ID |
|---|---|
| 内容节点 | 来自图表源的未加引号的名称,例如 A 或 E |
| 未加引号的子图 | 子图文本,保持原样 |
| 加引号的子图 | subGraph<N>,由每个子图递增的计数器编号 |
| 边 | L_<start>_<end>_<counter>,用于区分平行边的计数器 |
| 边标签节点 | edge-label-<start>-<end>-<edgeId> |
这个工具会帮你完成解析。parseMmdFileToLayoutData 会去掉 frontmatter 和指令,运行真正的检测器和解析器,然后像流程图渲染器那样标记方向。parseApplySizesAndLayout 更进一步,会应用捕获到的尺寸并运行后端。在你自己写之前,先试试这些工具。
🌐 The harness does the parsing for you. parseMmdFileToLayoutData strips frontmatter and directives, runs the real detector and parser, and stamps the direction the way the flowchart renderer does. parseApplySizesAndLayout goes further and applies the captured sizes and runs a backend. Reach for those before writing your own.
如果你自己驱动解析器,首先会有两个调用:addDiagrams() 用于注册图表类型,preprocessDiagram() 用来在 Diagram.fromText() 看到前置内容之前处理它。跳过任意一个都会产生看起来像解析器错误的失败,但实际上并不是。
🌐 If you do drive the parser yourself, two calls come first: addDiagrams() to register the diagram types, and preprocessDiagram() to handle frontmatter before Diagram.fromText() sees it. Skipping either produces failures that look like parser bugs and are not.
无论你选择哪条路线,当一个夹具条目没有匹配的解析器生成节点时,要大声报错。悄悄遗漏会让节点保持默认大小,而你测量的布局就不是任何人实际能看到的布局。
🌐 Whichever route you take, fail loudly when a fixture entry has no matching parser-produced node. A silent miss leaves a node at its default size, and the layout you then measure is not the layout anyone will see.
写规范
🌐 Writing a spec
加载夹具,运行你的后端,断言结果:
🌐 Load the fixture, run your backend, assert on the result:
ts
import { describe, it, expect, beforeAll } from 'vitest';
import { loadDdltFixture } from '../ddlt/index.js';
import { validateLayout } from '../layout-utils/validateLayout.js';
describe('swimlanes, 1-simple', () => {
let layout: LayoutData;
beforeAll(async () => {
layout = await loadDdltFixture('swimlanes/1-simple', { backendId: 'swimlanes' });
});
it('produces a valid layout', () => {
const result = validateLayout(layout);
expect(result.issues.map((i) => i.type)).toEqual([]);
expect(result.score).toBeGreaterThan(900);
});
});明确传入 backendId。默认使用的后端在 develop 上不可用,所以如果不传会抛出错误,而不是默默地做点合理的事情。你自己的布局需要在 ddlt/backends.ts 中有一个条目,才能让夹具顺利运行。
🌐 Pass backendId explicitly. The default is a backend that is not present on develop, so a call without it throws rather than silently doing something reasonable. Your own layout needs an entry in ddlt/backends.ts before a fixture can run through it.
ddlt/index.ts 还导出 baselineDdltSpec(name),这是一个一行代码的函数,用来断言一些通用的不变量:有限的坐标、每条边至少两个点、没有线段穿过无关的节点、端点在边界上。它硬编码了同样缺失的默认后端,而且没有被调用,所以还是看看它检查了哪些不变量比较好,而不是直接调用它。
三种测试形式可以覆盖大多数需求。一个小型的在线几何测试,你手动构建几个节点并检查一个路由规则,适合算法的单元测试。一个有夹具的、基于现实规模的测试,适合用于可追溯的回归测试。一个完整的从源码到布局的解析运行,适合用户报告的任何问题。匹配周围的文件夹:swimlanes/query-process.ddlt.spec.ts 是树状结构中最完整的示例,而 layout-utils/validateLayout.spec.ts 和 ddlt/aggregateValidate.spec.ts 展示了较小的测试形式。
🌐 Three shapes of test cover most needs. A tiny inline geometry test, where you build a handful of nodes by hand and check one routing rule, is right for a unit of the algorithm. A fixture-backed test on realistic sizes is right for a regression you can point at. A full source-to-layout run through the parser is right for anything a user reported. Match the surrounding folder: swimlanes/query-process.ddlt.spec.ts is the fullest worked example in the tree, and layout-utils/validateLayout.spec.ts and ddlt/aggregateValidate.spec.ts show the smaller shapes.
扫除
🌐 The sweep
ddlt/layout-fixtures.ddlt.spec.ts 会发现夹具对,运行它们,并对整体的有效性进行断言。它还会生成一个综合报告,这个数字会告诉你某个更改是否总体上有帮助,而不仅仅是你盯着看的那一个图表:
bash
# The sweep, with the aggregate report
pnpm exec vitest run \
packages/mermaid/src/rendering-util/layout-algorithms/ddlt/layout-fixtures.ddlt.spec.ts
# Just the aggregate line
pnpm exec vitest run \
packages/mermaid/src/rendering-util/layout-algorithms/ddlt/layout-fixtures.ddlt.spec.ts \
2>&1 | grep 'DDLT-AGG'
# One fixture while iterating
pnpm exec vitest run -t "1-simple" \
packages/mermaid/src/rendering-util/layout-algorithms/ddlt/报告提供了 total、avg、min 和 invalid,然后每个灯具一行,显示它的分数和问题类型。把它当作工作队列来读:最下面的一行就是下一步需要改进的地方。命令前加上 ORTHO_TEST_DEBUG=1 会把布局记录器从 fatal 提升到 debug,当某行失败而报告没说明原因时,这会很有帮助。
🌐 The report gives total, avg, min, and invalid, then one row per fixture with its score and issue types. Read it as a work queue: the lowest row is where the next improvement is. ORTHO_TEST_DEBUG=1 in front of the command turns the layout logger from fatal up to debug, which helps when a row fails for reasons the report does not explain.
按原文,sweep 会过滤到 swimlanes 配置文件,并将该配置文件的总数与最低值进行比较。添加布局意味着要把它的配置文件加到清单里,并添加自己的汇总断言,而不能以为现有配置会自动包含你的设备。
🌐 The sweep as written filters to the swimlanes profile and holds that profile's total against a floor. Adding a layout means adding its profile to the manifest and its own aggregate assertion, not assuming the existing one will pick your fixtures up.
会破坏布局的情况
🌐 The cases that break layouts
一个处理一连串盒子的布局几乎不会告诉你任何东西。下面的情况是引擎实际会出错的地方,大致按你遇到错误的顺序排列。每一种情况在 layout-tests 中已经有图表了,所以在你查到之前没什么可写的。
🌐 A layout that handles a chain of boxes tells you almost nothing. The cases below are where engines actually fail, roughly in the order yours will fail them. Every one has a diagram in layout-tests already, so there is nothing to write before you can find out.
它们大多数只是源文件。这个文件夹里大约有45个图示,大约有15个带有捕获的尺寸,而扫描是通过查找 .sizes.json 文件并将其与对应的 .mmd 配对来发现夹具的。如果一个图示没有尺寸文件,那就说明没有任何东西在测试它,无论它看起来多么复杂。在假设一个案例已经覆盖之前先检查一下,如果尺寸缺失,就在资源管理器中打开图示,然后点击“保存尺寸”。就这一点击的差别,就可能决定一个图示是仅仅躺在文件夹里,还是被扫描保护的案例。
🌐 Most of them are source only. The folder holds around 45 diagrams and about 15 have captured sizes, and the sweep discovers fixtures by looking for .sizes.json files and pairing each with its sibling .mmd. A diagram with no sizes file is not being tested by anything, however tricky it looks. Check before assuming a case is covered, and if the sizes are missing, open the diagram in the explorer and press Save sizes. That one click is the difference between a diagram sitting in a folder and a case the sweep will defend.
自环
🌐 Self-loops
一个起点和终点是同一个节点的边没有方向可走,而用于计算两处不同位置路线的代码通常会生成一个零长度的路径、除零错误,或者只是一个点。路线必须人工制造:在一侧做一个小圈,远离节点,以及节点其他边的任何操作。
🌐 An edge whose source and target are the same node has no direction to travel in, and code that computes a route from two distinct positions tends to produce a zero-length path, a division by zero, or a dot. The route has to be manufactured: a small loop off one side, clear of the node and of anything the node's other edges are doing.
self-loop.mmd 是单节点情况,也是三个中唯一捕获了尺寸的一个。self-loop-2.mmd 和 self-loop-multi.mmd 更严格,会在循环的四个节点上都加上自环,所以每个环都要和已经使用这些边的真实边争空间。
子图
🌐 Subgraphs
这是长尾部分,也是大部分工作的所在。子图是包含其他节点的节点,所以每个边的端点现在有两种可能的含义,而且每个框架都是一个障碍,同时也必须随着其内容的移动而移动。
🌐 This is the long tail, and it is where most of the work is. A subgraph is a node that contains other nodes, so every edge endpoint now has two possible meanings and every frame is an obstacle that also has to move as its contents move.
| 情况 | 例子 |
|---|---|
| 独立存在的子图 | decoupled-subgraph.mmd |
| 指向子图的边 | edge-to-subgraph.mmd |
| 指向子图内节点的边 | edge-to-node-in-subgraph.mmd |
| 从子图出来的边 | edge-from-subgraph.mmd |
| 从子图内部指向普通节点 | subgraph-variation.mmd, subgraph-variation-2.mmd |
| 两个兄弟子图之间 | nested-sg-outgoing-2.mmd, nested-incoming.mmd |
| 从一个子图内部到另一个子图内部 | nested-sg-outgoing-2.mmd, nested-subgraphs-2.mmd |
| 嵌套子图 | nested-subgraphs.mmd, nested-subgraphs-3.mmd |
| 跨越多个嵌套层级的边 | nested-sb-edges-in-out.mmd, nested-outgoing-edges.mmd |
| 竞争空间的子图标题 | subgraph-labels.mmd 及其两个变体 |
按顺序处理那张列表。每一行都假设上面的行已经完成。今天只有前两行有捕获的尺寸,所以它们下面的内容都是可以在浏览器中打开查看的图示,而不是会告诉你何时出错的测试。
🌐 Work down that list in order. Each row assumes the ones above it. Only the first two rows have captured sizes today, so everything below them is a diagram you can open in the browser rather than a test that will tell you when you break it.
两个问题反复出现。一个是边缘终点在子图上时,它应该在框处停止,而不是穿过去到一个成员上;另一个是边缘终点在成员上时,它必须越过框而不能裁掉标题栏。另一个问题是尺寸:框必须包含里面的所有内容,包括标签,而且即使后面调整成员位置,也必须继续保持这样。
🌐 Two failures recur. An edge that ends on a subgraph should stop at the frame rather than diving through to a member, and an edge that ends on a member has to cross the frame without clipping the title band. The other is sizing: a frame has to enclose everything inside it including the labels, and it has to keep doing so after a later pass nudges a member.
平行边
🌐 Parallel edges
在大多数路由代码中,同一对节点之间的两条边被视为一条边,因为它们都有相同的端点和相同的最优路径,所以它们会完全重叠,图表也会悄悄丢失信息。必须刻意将它们分开。
🌐 Two edges between the same pair of nodes are one edge as far as most routing code is concerned, because both get the same endpoints and the same optimal path, so they land exactly on top of each other and the diagram silently loses information. They have to be separated deliberately.
identical-edges.mmd 是最简单的情况。multiple-edges.mmd 在束中添加了一个反向边,所以修复不能只是按索引展开边而忽略方向。identical-edges-in-subgraph.mmd 在一个框架内为每个方向放置了一束边,其中展开的空间是有限的。
繁忙节点
🌐 Busy nodes
一个有超过四条边的节点不能给每条边分配自己的边。端口必须共享边,以不交叉的顺序共享边,并保持足够的距离以便区分。那些每条边只分配一边的引擎在这里会明显下降,通常会在节点旁边形成一个结。
🌐 A node with more than four edges cannot give each one its own side. Ports have to share sides, share sides in an order that does not cross, and stay far enough apart to be told apart. Engines that assign one edge per side degrade sharply here, usually into a knot right against the node.
edge-types.mmd 将几条边叠加到一个节点上,每条边的箭头类型都不同。Company.mmd 和 Company-simp.mmd 是同一问题的真实版本,并且两者的规模都有记录。
组合,以及两个方向
🌐 Combinations, and both directions
这些会相互作用,而且组合起来比单独的部分更糟。在嵌套子图里的繁忙节点上有一个自环会同时触发所有四个,这就是为什么即使其中一个失败更难诊断,保留较大的测试装置还是值得的。deploy-pipeline.mmd、life-choices.mmd 和 project-sox2.mmd 是这里最接近用户实际会写的示意图的东西。
🌐 These interact, and the combinations are worse than the parts. A self-loop on a busy node inside a nested subgraph exercises all four at once, which is why the larger fixtures are worth keeping even though a failure in one is harder to diagnose. deploy-pipeline.mmd, life-choices.mmd, and project-sox2.mmd are the closest thing here to diagrams a user would actually write.
在 TB 和 LR 中都运行重要的那些。布局代码往往会对图的流向产生一种隐含的假设,而第二个方向就是这个假设浮现的地方。
🌐 Run the ones that matter in TB and LR both. Layout code tends to grow an implicit assumption about which way the graph flows, and the second direction is where that assumption surfaces.
在你发明某样东西之前
🌐 Before you invent something
图形绘制有着长期的研究记录,而且布局引擎所需的大部分内容已经被研究了几十年。正交路由、紧凑布局、端口和边约束、分层管线、减少交叉:这些问题都不是新问题,通常查阅相关资料比自己推导启发式方法并逐个发现其失败模式要快得多。
🌐 Graph drawing has a long research record, and most of what a layout engine needs has been studied for decades. Orthogonal routing, compaction, port and side constraints, layered pipelines, crossing minimisation: none of these are new problems, and reading up on one is usually faster than deriving a heuristic and discovering its failure modes one fixture at a time.
了解词汇差距是值得的,因为它会让搜索比必要的更困难。这个代码库所说的 ‘jog’,在文献中叫做 ‘bend’。一个港口窗口是一个销或者一个侧面约束。一条轨道是一个轨道或通道。一个组是一个复合顶点。
🌐 The vocabulary gap is worth knowing about, because it makes searching harder than it needs to be. What this codebase calls a jog, the literature calls a bend. A port window is a pin or a side constraint. A rail is a track or a channel. A group is a compound vertex.
当你故意偏离既定方法的建议时,在拉取请求里写下原因,以及你是如何验证这种偏离可行的。
🌐 When you knowingly diverge from what the established approach recommends, write down why in the pull request, along with how you checked that the divergence works.
在浏览器里边看测试运行情况
🌐 Watching it in the browser while the tests run
扫动会告诉你分数下降了。它不会告诉你图表现在看起来像一盘意大利面。在测试运行旁边保持浏览器打开。
🌐 The sweep tells you a score dropped. It does not tell you the diagram now looks like a plate of spaghetti. Keep a browser open next to the test run.
bash
pnpm dev不要随便假设地址。端口是从 checkout 的路径派生出来的,所以每个 worktree 和每个克隆都有自己的端口,这样你可以同时运行多个开发服务器,而不用担心它们争抢 9000。服务器启动时会打印它的 URL,在构建输出滚动过去之前。MERMAID_DEV_PORT 可以固定端口,如果你想用固定的。
🌐 Do not assume the address. The port is derived from the path of the checkout, so every worktree and every clone gets its own and you can run several dev servers side by side without them fighting over 9000. The server prints its URL as it starts, before the build output scrolls past. MERMAID_DEV_PORT pins it if you want a fixed one.
打开 /dev/,你会看到资源管理器:一边是设备树,另一边是图表,还有一个代码标签用于编辑源码,以及一个布局选择器,用来把你的算法和相同输入下的其他算法进行比较。当你修改源码时,它会自动重新加载,所以你改了算法,图表就会重绘,无需手动刷新浏览器。
🌐 Open /dev/ and you get the explorer: the fixture tree on one side, the diagram on the other, a code tab for editing the source, and a layout picker for comparing your algorithm against the others on the same input. It reloads when you change the source, so an edit to your algorithm redraws the diagram without you touching the browser.
对于不在夹具树中的图表,请复制独立页面模板:
🌐 For a diagram that is not in the fixture tree, copy the standalone page template instead:
bash
cp demos/dev/example.html demos/dev/grid.html它会在同一个服务器的 /dev/grid.html 着陆。把图表放在页面里,并在 frontmatter 中给你的布局命名:
🌐 That lands at /dev/grid.html on the same server. Put the diagram in the page and name your layout in the frontmatter:
html
<pre class="mermaid">
---
config:
layout: grid
---
flowchart TB
A --> B
B --> C
</pre>能在长时间工作中保持顺畅的工作流程:
🌐 A workflow that holds up over a long session:
- 在一个终端中运行扫描,只筛选你正在处理的夹具。
- 在浏览器中保持那个同样的装置开着。
- 做一个改变,然后观察两者。分数说明有没有帮助,图片说明分数是否衡量了正确的东西。
- 在提交之前,先运行完整检查,确认总数没有下降。
他们俩的意见分歧比你想象的要多,而且这种分歧很有信息量。得分提高而图表变差意味着验证者忽略了某些东西,这种差距值得记录下来。
🌐 The two disagree more often than you would expect, and the disagreement is informative. A score that improves while the diagram gets worse means the validator is blind to something, and that gap is worth writing down.
用浏览器检查测试看不到的东西:超出形状的文本、指向错误方向的箭头、穿过标签的子图框。
🌐 Use the browser to check what the tests cannot see: text that overflows its shape, arrowheads pointing the wrong way, subgraph frames cutting through labels.
大型图表的性能
🌐 Performance on large diagrams
在十个节点上看起来很舒服的布局,在一千个节点上可能就完全无法使用,而这个差别在夹具测试中是看不到的——DDLT 夹具故意做得很小,所以它们无法说明你的算法如何扩展。关于这一点,有一个单独的语料库。
🌐 A layout that is pleasant on ten nodes can be unusable on a thousand, and the difference does not show up in the fixture sweep — DDLT fixtures are small on purpose, so they say nothing about how your algorithm scales. There is a separate corpus for that.
语料库
🌐 The corpus
e2e/platform/dev-diagrams/performance/flowcharts/ 有十五个真实流程图,已匿名处理并按大小分组:
text
performance/flowcharts/
medium1.mmd … medium5.mmd ← ~45-50 KB of source each
large1.mmd … large5.mmd ← ~70-75 KB
huge1.mmd … huge5.mmd ← 120-240 KB
baseline.json ← a captured profiler run, for comparison这些桶是按源大小划分的,节点数量并不跟随它。huge1.mmd 大约有 2400 个节点,但只有不到五十条边;huge2.mmd 有几百个节点,却有一千多条边;huge3.mmd 有一百个节点,埋在 classDef 声明里。这样的分布是故意的。这些都是人画的真实图,所以那些在实际中会破坏布局算法的形状——一个节点有六十条边、深度嵌套的子图、又长又细的链、宽而扁的扇形——出现的比例就是它们实际存在的比例,而不是生成器会生成的比例。有几张图带有 frontmatter 配置、handDrawn 样式和 HTML 标签,因此它们既会测试测量过程,也会测试布局。
🌐 The buckets are by source size, and node count does not follow it. huge1.mmd is around 2400 nodes with barely fifty edges; huge2.mmd is a few hundred nodes with over a thousand edges; huge3.mmd is a hundred nodes buried in classDef declarations. That spread is deliberate. These are real diagrams people drew, so the shapes that break layout algorithms in practice — one node with sixty edges, deeply nested subgraphs, a long thin chain, a wide flat fan — appear in the proportions they actually occur rather than the ones a generator would produce. Several carry frontmatter config, handDrawn look, and HTML labels, so they exercise the measuring pass as well as the layout.
运行性能分析器
🌐 Running the profiler
性能分析和上一节里的其他内容一样,都在同一个开发者资源管理器里。
🌐 Profiling lives in the same dev explorer as everything else in the previous section.
pnpm dev,然后在服务器打印的 URL 上打开/dev/。- 在文件树中选择
performance/flowcharts。 - 在分析器面板中,勾选要比较的布局,设置范围为 文件夹,选择迭代次数,然后点击 运行分析。
每个布局都会先热身一次然后丢弃,然后每个布局的每个图表会渲染 iterations 次,每一系列中最快和最慢的运行会被丢掉再取平均。得分是整个集合的总毫秒数,越低越好。复制 JSON 会把整个运行以和 baseline.json 相同的格式复制到剪贴板。
🌐 Each layout is warmed up once and discarded, then every diagram is rendered iterations times per layout, with the fastest and slowest run of each series dropped before averaging. The score is total milliseconds across the set, lower is better. Copy JSON puts the whole run on the clipboard in the same shape as baseline.json.
一个小问题:分析器的布局复选框来自 .esbuild/dev-explorer/diagram-viewer.ts(ALL_LAYOUTS)中的硬编码列表。即使新的布局已经在 Mermaid 中注册,除非你把它加进去,否则它也不会出现在那里。
🌐 One rough edge: the profiler's layout checkboxes come from a hardcoded list in .esbuild/dev-explorer/diagram-viewer.ts (ALL_LAYOUTS). A new layout will not appear there until you add it, even after it is registered with Mermaid.
阅读表格
🌐 Reading the table
你关心的那一行通常不是总数。渲染被分成几个阶段,而只有其中一个是你的:
🌐 The row you care about is rarely the total. Rendering is broken into phases, and only one of them is yours:
| 阶段 | 涵盖内容 |
|---|---|
parse | 图表文本到数据库 |
prepare | 构建 LayoutData,包括你的 prepareLayout |
measure | DOM 插入和 getBBox / getBoundingClientRect |
layout | 布局调用,分为 ↳ lib (external) 和 ↳ ours |
paint | 根据你的坐标绘制节点和边 |
serialize | SVG 转字符串 |
↳ ours (wrapper) 是你的代码;↳ lib (external) 是你封装的底层第三方库。因为 ELK 慢而导致布局慢,和因为你自己的处理而导致布局慢,在 layout 行看起来是一样的数字,但下一行的问题完全不同。
huge3.mmd指出:parse中是1410毫秒,而layout中只有1.5毫秒。总时间主要受你无法通过布局算法修复的因素影响,关注总时间会让你去优化错误的文件。
也看看 measure。它不是你的阶段,但它在 prepareLayout 的下游——你为边标签添加的每一个合成节点,都是插入到 DOM 并被测量的另一个元素,所以一个对辅助节点很大方的 prepareLayout 会显现为别人的回归。
🌐 Watch measure too. It is not your phase, but it is downstream of prepareLayout — every synthetic node you add for an edge label is another element inserted into the DOM and measured, so a prepareLayout that is generous with helper nodes shows up as someone else's regression.
与基线比较
🌐 Comparing against the baseline
baseline.json 是在同一个文件夹里捕获的运行——dagre 和 elk,十次迭代,theme=redux,look=neo,文件里带有捕获日期。
把绝对数值当作某台机器在某天的记录,而不是一个阈值。它们在你的硬件上不会重复出现,CI 运行器也不会复现。真正有意义的是它们的形态:各阶段的相对成本,哪些图表主要受布局影响,哪些主要受解析影响,以及各布局之间的比例。在同一次运行中对你的布局进行分析,同时运行 dagre 和 elk,然后在同一次运行中进行比较。
🌐 Treat the absolute numbers as a record of one machine on one day, not a threshold. They will not reproduce on your hardware, and a CI runner would not reproduce them either. What does carry over is the shape: the relative cost of the phases, which diagrams are layout-dominated and which are parse-dominated, and the ratio between the layouts. Profile your layout in the same run as dagre and elk and compare within that run.
到底出什么问题了
🌐 What actually goes wrong
实际上,这些回归是算法层面的,而不是微优化:
🌐 In practice the regressions are algorithmic, not micro-optimizations:
- 对每一对节点进行一次遍历。在 50 个节点时还可以,在 1200 个节点时要进行 140 万次比较。
edges.filter((e) => e.start === node.id)在遍历节点的循环里面——在两行易读的代码背后隐藏了 O(V·E) 的复杂度。在循环之前先构建邻接表。- 在迭代优化步骤中重建查找表,所以一个 O(V) 的成本变成每次迭代 O(V)。
- 递归进入子图而不进行记忆化,并且每个祖级都会重新访问同一子树一次。
在优化之前先做分析。运行语料库,找到 ↳ ours 行表现最差的图,然后阅读那个——语料库足够小,答案通常就是一个图和一个循环。
🌐 Profile before you optimize. Run the corpus, find the diagram where your ↳ ours row is worst, and read that one — the corpus is small enough that the answer is usually one diagram and one loop.
作为单独封装发送
🌐 Shipping as a separate package
一个外部布局使用相同的 render 签名。不要修改内置注册表,而是导出一个加载器数组,让使用者去注册它:
🌐 An external layout uses the same render signature. Instead of editing the built-in registry, export a loader array and let the consumer register it:
ts
import type { LayoutLoaderDefinition } from 'mermaid';
const loader = async () => await import('./render.js');
const layouts: LayoutLoaderDefinition[] = [{ name: 'grid', loader, algorithm: 'grid.compact' }];
export default layouts;js
import mermaid from 'mermaid';
import layouts from 'my-mermaid-layout';
mermaid.registerLayoutLoaders(layouts);algorithm 字段通过 options 传回给你的渲染器,这让一个包可以注册多个共享实现的命名变体。
🌐 The algorithm field is passed back to your renderer through options, which lets one package register several named variants that share an implementation.
当布局引入一个很大的依赖时,把它单独打包。其他所有东西都放在主包里,这样就会被夹具清扫覆盖。
🌐 Package it separately when the layout pulls in a large dependency. Everything else belongs in the main package, where it gets covered by the fixture sweep.
清单
🌐 Checklist
- [ ]
runLayoutCore是一个没有 DOM 访问权限的导出函数 - [ ] 浏览器和测试都会调用同一个函数
- [ ] 组框会把它们的成员包起来,并保持标题栏清晰
- [ ] 边缘标签在位置确定前会预留空间
- [ ] 边缘端点落在节点边界上
- [ ] 自环和并行边有路径
- [ ]
validateLayout在你的夹具上返回ok: true - [ ] 已有夹具,并记录了尺寸,以应对算法设计时要处理的情况
- [ ] 你的后端注册在
ddlt/backends.ts,你的个人资料在ddlt-manifest.json - [ ] 扫过后,总分没有下降
- [ ]
e2e/diagrams/下的.mmd夹具在视觉上覆盖了布局,因为布局的变化就是渲染的变化 - [ ] 布局已经根据
performance/flowcharts/进行了分析,并且理解了↳ ours行