9.6 KiB
9.6 KiB
鱼骨图(因果图)
必须写脚本生成 JSON。 鱼骨图的分支角度、原因小骨坐标需要三角函数计算,直接手写 JSON 极易导致节点重叠和连线穿模。请用下方脚本模板。
Content 约束
- 分类 4-6 个
- 每个分类的原因 ≤ 4
- 总原因 ≤ 20(超过必须合并分类)
Layout 选型
- 脚本生成坐标(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用
npx -y @larksuite/whiteboard-cli@^0.2.10渲染
Layout 规则
- 主干水平居中,从左向右延伸
- 分类节点按 spineX 从左到右排列,奇数(第 1、3、5...)在上方,偶数(第 2、4...)在下方
- 每个分类的原因沿斜线(分支骨)等距排列
- 鱼头(中心问题)在右侧,用 ellipse
- 主干连线带箭头指向鱼头,分支骨和原因小骨连线 endArrow: "none"
- 原因小骨水平延伸到原因框右侧,Y 坐标精准对齐
骨架示例
上下交替:分类标签按 spineX 从左到右排列,奇数(第1、3、5...)在上方,偶数(第2、4...)在下方。
视觉同色系:同一个分支的分类标签、连线及其下的所有原因节点,必须使用同一个色系(如相同的背景色与边框色组合),以保持图形风格统一和逻辑连贯。可以预定义一组颜色数组,按分支轮询使用。
坐标计算脚本模板(必须严格参照此算法生成)
以下 Node.js 脚本模板包含了完整的动态布局算法,能够自动适配任意数量的分类和原因,生成完美不重叠的鱼骨图:
const fs = require('fs');
const nodes = [];
// 1. 数据定义 (根据用户需求填充)
const categories = [
{ id: "c0", text: "前端代码", reasons: ["未压缩资源", "冗余请求", "超大图片未懒加载"] },
{ id: "c1", text: "后端服务", reasons: ["数据库慢查询", "缓存失效", "并发量过大"] },
{ id: "c2", text: "网络环境", reasons: ["CDN配置错误", "DNS解析缓慢", "带宽限制", "网络抖动"] }
];
// 2. 动态布局计算
const catWidth = 120;
const catHeight = 40;
const reasonWidth = 140; // 调整原因框宽度以适应长文本
const reasonHeight = 32;
const lineLength = 20; // 原因小骨连线的水平延伸长度
const paddingX = 40; // 同侧节点间的水平安全间距
// 预置的分支色系数组(分支骨分类和具体原因保持同一色系)
const branchColors = [
{ fill: "#E8F3FF", stroke: "#1664FF" }, // 蓝色系
{ fill: "#E6FFED", stroke: "#00B42A" }, // 绿色系
{ fill: "#FFF7E8", stroke: "#FF7D00" }, // 橙色系
{ fill: "#FFECE8", stroke: "#F5319D" }, // 粉色系
{ fill: "#F2E8FF", stroke: "#722ED1" }, // 紫色系
{ fill: "#E8FFFF", stroke: "#14C9C9" } // 青色系
];
let maxSpineY_up = 0;
let maxSpineY_down = 0;
// 第一步:计算每个 category 的内部尺寸和相对包围盒
categories.forEach((cat, index) => {
const isTop = index % 2 === 0;
const numReasons = cat.reasons.length;
// 动态计算分支高度,确保原因小骨不会垂直重叠
// 每个原因需要 reasonHeight + 上下间距(约 16)
const requiredY = (numReasons + 1) * (reasonHeight + 16);
const branchDY = Math.max(160, requiredY);
const branchDX = -branchDY * 0.7; // 保持固定的倾斜角度向左延伸
cat.isTop = isTop;
cat.branchDX = branchDX;
cat.branchDY = branchDY;
// 记录最大分支高度,用于计算背景高度和主骨 Y 坐标
if (isTop) maxSpineY_up = Math.max(maxSpineY_up, branchDY + catHeight + 40);
else maxSpineY_down = Math.max(maxSpineY_down, branchDY + catHeight + 40);
// 计算该分类的相对包围盒的极值(相对于 spineX 锚点)
// 最左侧可能由分类框或原因框决定
cat.minX = Math.min(branchDX - catWidth / 2, branchDX - lineLength - reasonWidth);
// 最右侧为主骨挂载点 0 或 分类框右侧
cat.maxX = Math.max(0, branchDX + catWidth / 2);
});
// 第二步:计算每个 category 在主骨上的绝对 X 坐标 (spineX)
let currentSpineX = 100; // 初始偏移
for (let i = 0; i < categories.length; i++) {
const cat = categories[i];
let startX = currentSpineX;
// 需要和上一个同侧的 category 保持距离,防止水平重叠
if (i >= 2) {
const prevSameSideCat = categories[i - 2];
const requiredX = prevSameSideCat.spineX + prevSameSideCat.maxX - cat.minX + paddingX;
startX = Math.max(startX, requiredX);
}
// 确保左侧最长分支不会超出画布左边界
if (startX + cat.minX < 50) {
startX = 50 - cat.minX;
}
cat.spineX = startX;
// 每次略微向前推进,确保异侧节点也能稍微错开
currentSpineX = startX + 80;
}
// 第三步:计算全局画布尺寸
const lastCat = categories[categories.length - 1];
const spineY = maxSpineY_up + 50; // 动态推导主骨 Y 坐标
const totalWidth = lastCat.spineX + 350; // 右侧留出鱼头的空间
const totalHeight = spineY + maxSpineY_down + 50;
// 4. 生成节点数据
// 背景
nodes.push({ type: "rect", x: 0, y: 0, width: totalWidth, height: totalHeight, fillColor: "#FFFFFF", borderWidth: 0 });
// 鱼头
const headWidth = 180;
const headHeight = 80;
const headX = totalWidth - headWidth - 40;
const headY = spineY - headHeight / 2;
nodes.push({ type: "ellipse", id: "head", x: headX, y: headY, width: headWidth, height: headHeight, text: "核心问题" });
// 主骨连线
const firstSpineX = categories[0].spineX + categories[0].minX;
nodes.push({
type: "connector",
connector: { from: { x: firstSpineX, y: spineY }, to: "head", toAnchor: "left", lineShape: "straight", endArrow: "arrow" }
});
// 遍历生成分类和原因小骨
categories.forEach((cat, index) => {
const isTop = cat.isTop;
const branchDY = cat.branchDY;
const branchDX = cat.branchDX;
const color = branchColors[index % branchColors.length];
// 分类标签
const catX = cat.spineX + branchDX - catWidth / 2;
const catY = spineY + (isTop ? -branchDY - catHeight : branchDY);
nodes.push({
type: "rect", id: cat.id, x: catX, y: catY, width: catWidth, height: catHeight, text: cat.text,
fillColor: color.fill, strokeColor: color.stroke
});
// 分支骨连线
nodes.push({
type: "connector",
connector: { from: { x: cat.spineX, y: spineY }, to: cat.id, toAnchor: isTop ? "bottom" : "top", lineShape: "straight", endArrow: "none", lineColor: color.stroke }
});
// 原因小骨
cat.reasons.forEach((reason, rIndex) => {
// 线性插值,均匀分布在分支骨上
const t = (rIndex + 1) / (cat.reasons.length + 1);
const attachX = cat.spineX + branchDX * t;
const attachY = spineY + (isTop ? -branchDY : branchDY) * t;
// 关键对齐:确保原因盒子完全在连线左侧,并且 Y 坐标中心精准对齐
const boxX = attachX - lineLength - reasonWidth;
const boxY = attachY - reasonHeight / 2;
const rId = `${cat.id}-r${rIndex}`;
nodes.push({
type: "rect", id: rId, x: boxX, y: boxY, width: reasonWidth, height: reasonHeight, text: reason,
fillColor: color.fill, strokeColor: color.stroke
});
// 原因小骨连线
nodes.push({
type: "connector",
connector: { from: { x: attachX, y: attachY }, to: rId, toAnchor: "right", lineShape: "straight", endArrow: "none", lineColor: color.stroke }
});
});
});
fs.writeFileSync('diagram.json', JSON.stringify({ version: 2, nodes }, null, 2));
连线格式与注意点
所有 connector 都用 { "type": "connector", "connector": { ... } } 格式。
注意:除了主骨外,其他所有连线(分支骨、原因小骨)都必须设置 "endArrow": "none",否则会默认带箭头,导致方向混乱。
分支骨:从主骨上的绝对坐标点 → 分类标签节点:
{
"version": 2,
"nodes": [
{ "type": "rect", "x": 0, "y": 0, "width": "__totalWidth__", "height": "__totalHeight__" },
{ "type": "ellipse", "id": "head", "x": "__headX__", "y": "__headY__",
"width": 180, "height": 80, "text": "[中心问题]" },
{ "type": "connector", "connector": {
"from": { "x": "__spineStartX__", "y": "__spineY__" },
"to": "head", "toAnchor": "left",
"lineShape": "straight", "endArrow": "arrow"
}},
{ "type": "rect", "id": "c0", "x": "__catX__", "y": "__catY__",
"width": 120, "height": 40, "text": "[分类A]" },
{ "type": "connector", "connector": {
"from": { "x": "__spineX0__", "y": "__spineY__" },
"to": "c0", "toAnchor": "bottom",
"lineShape": "straight", "endArrow": "none"
}},
{ "type": "rect", "id": "c0-r0", "x": "__reasonX__", "y": "__reasonY__",
"width": 140, "height": 32, "text": "[原因1]" },
{ "type": "connector", "connector": {
"from": { "x": "__attachX__", "y": "__attachY__" },
"to": "c0-r0", "toAnchor": "right",
"lineShape": "straight", "endArrow": "none"
}}
]
}
上述骨架展示一个分类(上方)+ 一条原因的模式。完整鱼骨图重复此模式,上下交替。每个分类下可有多条原因,均匀插值分布在分支骨上。
陷阱
- 代码生成:必须使用带有动态防重叠算法的脚本来计算坐标并输出 JSON。
- 分支骨防重叠:同一侧的相邻分支骨和原因框必须没有任何交叉。
- 自适应高度:原因数量较多时,分支骨自动拉长以容纳所有小骨。
- 原因小骨水平:原因框右侧的附着点必须与连线起点 Y 坐标一致。
- 无箭头:所有分类的分支连线、小骨连线均必须关闭箭头。
- 同色系:同一个分支骨、分类标签节点以及原因小骨节点和连线,必须使用同色系的颜色以保持视觉连贯性。