# 增长飞轮图 (Flywheel) > **必须写脚本生成 JSON。** 飞轮图需要极坐标计算阶段标签位置和 SVG 圆环切割,直接手写 JSON 无法正确实现同心圆环结构。请用下方脚本模板。 ## Content 约束 - 阶段 4-6 个,每阶段短标签(title + 可选 subtitle/desc) - 中心放置飞轮主题标题 ## Layout 选型 - **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.10` 渲染 ## Layout 规则 - 同心圆遮挡法构建圆环:大圆(底色)+ 小圆(白色遮罩)+ 中心文字 - nodes 数组顺序决定 z-index:先大圆 -> 小圆 -> 中心文字 -> SVG 切割 -> 外围卡片 - 阶段标签均匀分布在圆环外围,每个标签到圆心距离相等 - SVG polyline 切割圆环形成分段 + 箭头方向感 - 阶段数多时需动态放大半径、缩小箭头折角、收紧文字容器 ### 同心圆遮挡法详解 画一个大圆(作为飞轮的底层颜色),然后在它正中心画一个小圆(填充为白色 `#FFFFFF`)。大圆和小圆都设置 `borderWidth: 0`,通过叠加遮挡形成圆环。 nodes 数组中的图层顺序(必须严格遵守): 1. **底层大圆** (`type: 'ellipse'`, 填色, `borderWidth: 0`) 2. **遮罩小圆** (`type: 'ellipse'`, 白色填色, `borderWidth: 0`) 3. **中心文字** — 必须在两个圆之后添加,否则被白色小圆盖住 4. **SVG 切割箭头** — 覆盖在圆环上,用白色粗线 polyline 切出分段 5. **外围阶段卡片** — 极坐标计算位置 ### SVG 箭头线切割分段 通过插入一个铺满大圆区域的 `svg` 节点,利用极坐标计算每个分段交界处的坐标,使用 `` 画与背景色相同的粗线条(白色、20px+ 宽度)。线条从内圆边缘穿过大圆边缘,并在穿过时产生一定角度的偏转(`da` 参数),在视觉上"切断"圆环并形成箭头方向感。 ### 外围文字环绕布局 - 利用极坐标 `x = cx + R * cos(θ)` 计算每个分段的中心角度 - 在计算出的坐标点放置 `frame` 容器(`layout: 'vertical'`) - 外围文字容器内部的 `text` 节点不能用 `width: 'fill-container'`,必须指定固定 width 配合 `height: 'fit-content'` ### 动态缩放优化(阶段数 >= 8 时必须) 当阶段数量较多(8 个、12 个或 16 个以上)时,必须动态调整: - **放大画布与圆环半径**:节点越多,需要越长的圆周容纳外围文字。适当调大 `rOut` 和 `rIn`(如 16 阶段时 `rOut` 可设为 400+),同步放大 `cx`/`cy` 避免超出边界 - **缩小箭头切割角度**:段数增多时每段夹角变小,保持默认折角会导致缝隙过大。应减小 `da`(如 `da = 4`) - **收紧外围文字容器**:缩窄 `boxWidth`,减小文字字号,确保相邻文本框不互相覆盖 ## 骨架示例 此场景必须用 .cjs 脚本生成。Agent 使用时只需修改 `stages` 数组和 `centerTitle`/`centerSubtitle`,其余坐标全自动计算。 ```javascript 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 = ``; 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 += ``; } 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)); ``` ## 陷阱 - **中心文字被 SVG 遮挡**:中心文字节点必须在大圆和小圆之后、SVG 之前添加,确保 z-index 正确 - **缺方向指示箭头**:SVG polyline 切割线必须带角度偏转(da 参数),形成顺时针/逆时针箭头感 - **标签位置不对称**:外围卡片必须用极坐标公式 `x = cx + R * cos(θ)` 均匀分布,不可手动摆放 - **外围文字容器死锁**:`layout: 'vertical'` 的 frame 内部 text 节点不能用 `width: 'fill-container'`,必须指定固定 width