必须写脚本生成 JSON。 飞轮图需要极坐标计算阶段标签位置和 SVG 圆环切割,直接手写 JSON 无法正确实现同心圆环结构。请用下方脚本模板。
npx -y @larksuite/whiteboard-cli@^0.2.0 渲染画一个大圆(作为飞轮的底层颜色),然后在它正中心画一个小圆(填充为白色 #FFFFFF)。大圆和小圆都设置 borderWidth: 0,通过叠加遮挡形成圆环。
nodes 数组中的图层顺序(必须严格遵守):
type: 'ellipse', 填色, borderWidth: 0)type: 'ellipse', 白色填色, borderWidth: 0)通过插入一个铺满大圆区域的 svg 节点,利用极坐标计算每个分段交界处的坐标,使用 <polyline> 画与背景色相同的粗线条(白色、20px+ 宽度)。线条从内圆边缘穿过大圆边缘,并在穿过时产生一定角度的偏转(da 参数),在视觉上"切断"圆环并形成箭头方向感。
x = cx + R * cos(θ) 计算每个分段的中心角度frame 容器(layout: 'vertical')text 节点不能用 width: 'fill-container',必须指定固定 width 配合 height: 'fit-content'当阶段数量较多(8 个、12 个或 16 个以上)时,必须动态调整:
rOut 和 rIn(如 16 阶段时 rOut 可设为 400+),同步放大 cx/cy 避免超出边界da(如 da = 4)boxWidth,减小文字字号,确保相邻文本框不互相覆盖此场景必须用 .cjs 脚本生成。Agent 使用时只需修改 stages 数组和 centerTitle/centerSubtitle,其余坐标全自动计算。
const { writeFileSync } = require('fs');
// ══════════════════════════════════════════════════════════════
// 只需修改这里 -- 填入用户要求的阶段数据和中心标题
// ══════════════════════════════════════════════════════════════
const centerTitle = '{{CENTER_TITLE}}';
const centerSubtitle = '{{CENTER_SUBTITLE}}'; // 可选,不需要就留空字符串
const stages = [
{ title: '{{STAGE_1}}', subtitle: '{{SUB_1}}', desc: '{{DESC_1}}' },
{ title: '{{STAGE_2}}', subtitle: '{{SUB_2}}', desc: '{{DESC_2}}' },
{ title: '{{STAGE_3}}', subtitle: '{{SUB_3}}', desc: '{{DESC_3}}' },
{ title: '{{STAGE_4}}', subtitle: '{{SUB_4}}', desc: '{{DESC_4}}' },
];
// ══════════════════════════════════════════════════════════════
// 以下是自动计算逻辑,不需要修改
// ══════════════════════════════════════════════════════════════
// --- 布局参数 ---
const numSegments = stages.length;
const cx = 600, cy = 450; // 画布中心
const rOut = 240, rIn = 160; // 内外圆半径
const textDist = rOut + 40; // 文字离圆心距离
const boxWidth = 220; // 外围文字卡片宽度
const boxHeight = 80; // 估算高度(用于偏移计算)
const da = 8; // 箭头折角
const nodes = [];
// --- 图层 1:底层大圆(圆环底色) ---
nodes.push({
type: 'ellipse',
x: cx - rOut, y: cy - rOut,
width: rOut * 2, height: rOut * 2,
borderWidth: 0,
});
// --- 图层 2:遮罩小圆(白色) ---
nodes.push({
type: 'ellipse',
x: cx - rIn, y: cy - rIn,
width: rIn * 2, height: rIn * 2,
borderWidth: 0,
});
// --- 图层 3:中心文字(必须在两个圆之后) ---
nodes.push({
type: 'text',
x: cx - rIn, y: cy - (centerSubtitle ? 30 : 20),
width: rIn * 2, height: 'fit-content',
text: [{ content: centerTitle, bold: true, fontSize: 32 }],
textAlign: 'center',
});
if (centerSubtitle) {
nodes.push({
type: 'text',
x: cx - rIn, y: cy + 20,
width: rIn * 2, height: 'fit-content',
text: [{ content: centerSubtitle, fontSize: 18 }],
textAlign: 'center',
});
}
// --- 图层 4:SVG 切割箭头 ---
let svg = `<svg viewBox="0 0 ${rOut * 2} ${rOut * 2}" xmlns="http://www.w3.org/2000/svg">`;
for (let i = 0; i < numSegments; i++) {
const a = -90 + i * (360 / numSegments);
const rad = (a * Math.PI) / 180;
const radMid = ((a + da) * Math.PI) / 180;
const R1 = rIn - 5, R2 = rOut + 5, Rm = (rIn + rOut) / 2;
const x1 = rOut + R1 * Math.cos(rad), y1 = rOut + R1 * Math.sin(rad);
const x2 = rOut + Rm * Math.cos(radMid), y2 = rOut + Rm * Math.sin(radMid);
const x3 = rOut + R2 * Math.cos(rad), y3 = rOut + R2 * Math.sin(rad);
svg += `<polyline points="${x1},${y1} ${x2},${y2} ${x3},${y3}" stroke="#FFFFFF" stroke-width="20" fill="none" stroke-linejoin="round" stroke-linecap="round" />`;
}
svg += `</svg>`;
nodes.push({
type: 'svg',
x: cx - rOut, y: cy - rOut,
width: rOut * 2, height: rOut * 2,
svg: { code: svg },
});
// --- 图层 5:外围阶段卡片(极坐标计算位置) ---
for (let i = 0; i < numSegments; i++) {
const stage = stages[i];
const a = -90 + (360 / numSegments) / 2 + i * (360 / numSegments);
const rad = (a * Math.PI) / 180;
const tx = cx + textDist * Math.cos(rad);
const ty = cy + textDist * Math.sin(rad);
// 动态偏移:根据角度将文本框向外推
let offsetX = 0, offsetY = 0;
if (Math.cos(rad) > 0.1) offsetX = 0;
else if (Math.cos(rad) < -0.1) offsetX = -boxWidth;
else offsetX = -boxWidth / 2;
if (Math.sin(rad) > 0.1) offsetY = 0;
else if (Math.sin(rad) < -0.1) offsetY = -boxHeight;
else offsetY = -boxHeight / 2;
const textW = boxWidth - 24; // 卡片 padding 12 * 2
nodes.push({
type: 'frame',
x: tx + offsetX, y: ty + offsetY,
width: boxWidth, height: 'fit-content',
layout: 'vertical', gap: 8, padding: 12,
alignItems: 'start',
borderWidth: 2, borderRadius: 8,
children: [
{ type: 'text', width: textW, height: 'fit-content',
text: [{ content: stage.title, bold: true, fontSize: 18 }], textAlign: 'left' },
{ type: 'text', width: textW, height: 'fit-content',
text: [{ content: stage.subtitle, fontSize: 14 }], textAlign: 'left' },
{ type: 'text', width: textW, height: 'fit-content',
text: [{ content: stage.desc, fontSize: 12 }], textAlign: 'left' },
],
});
}
// --- 图表标题 ---
nodes.push({
type: 'text',
x: cx - rOut - 100, y: 30,
width: (rOut + 100) * 2, height: 'fit-content',
text: [{ content: centerTitle, bold: true, fontSize: 24 }],
textAlign: 'center',
});
writeFileSync('diagram.json', JSON.stringify({ version: 2, nodes }, null, 2));
x = cx + R * cos(θ) 均匀分布,不可手动摆放layout: 'vertical' 的 frame 内部 text 节点不能用 width: 'fill-container',必须指定固定 width