
Lark Whiteboard
- 8 installs
- 60 repo stars
- Updated April 13, 2026
- liangdabiao/lark-workflow-feishu-cli
Helps with ai & agent building tasks.
About
lark-whiteboard is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- lark-whiteboard
- AI & Agent Building
- AI-coding skill
Lark Whiteboard by the numbers
- 8 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #12,339 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/liangdabiao/lark-workflow-feishu-cli --skill lark-whiteboardAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 60 |
| Last updated | April 13, 2026 |
| Repository | liangdabiao/lark-workflow-feishu-cli ↗ |
What it does
Helps with ai & agent building tasks.
Files
Whiteboard Cli Skill
[!NOTE]
环境依赖:绘制画板需要@larksuite/whiteboard-cli(画板 Node.js CLI 工具),以及lark-cli(LarkSuite CLI 工具)。
如果执行失败,手动安装后重试:npm install -g @larksuite/whiteboard-cli@^0.1.0[!IMPORTANT]
执行 npm install 安装新的依赖前,务必征得用户同意!Workflow
这是画板,不是网页。 画板是无限画布上自由放置元素,flex 布局是可选增强。
Step 1: 路由 & 读取知识
- 判断渲染路径(见路由表):Mermaid 还是 DSL?
- 读对应 scene 指南 — 了解结构特征和布局策略
- 确定布局策略(见下方快速判断)和构建方式
- 读 references/ 核心模块 — 语法、布局、配色、排版、连线
Step 2: 生成完整 DSL(含颜色)
- 按 content.md 规划信息量和分组
- 按 layout.md 选择布局模式和间距
- 按 style.md 上色(用户没指定时用默认经典色板)
- 按 schema.md 语法输出完整 JSON
- 连线参考 connectors.md,排版参考 typography.md
注意:部分图形(鱼骨/飞轮/柱状/折线等)等, 要按 scene 指南的脚本模板写 .js 脚本生成 JSON:
- node xxx.js → 产出 JSON 文件
- 用产出的 JSON 文件进入 Step 3
Step 3: 渲染 & 审查 → 交付
- 渲染前自查(见下方检查清单)
- 渲染 PNG,检查:
· 信息完整?布局合理?配色协调?
· 文字无截断?连线无交叉?
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
- 没问题 → 交付:
· 用户要求上传飞书 → 见下方”上传飞书画板”章节中的说明
· 用户未指定 → 展示 PNG 图片给用户布局策略快速判断(详见 layout.md):
| 判断条件 | 布局策略 | 构建方式 |
|---|---|---|
| 有明确上下层级(用户层→服务层→数据层) | Flex 分层 | 直接写 JSON |
| 空间位置承载信息(地理、拓扑、角度) | 纯绝对定位 | 写脚本算坐标(node xxx.js) |
| 多个独立模块平级互联 | 混合(岛屿式) | 直接写 JSON + 估高辅助 |
| 不确定 | 默认 Flex(最安全) | 直接写 JSON |
构建方式是强约束:当 scene 指南要求"脚本生成"时,必须先写脚本(.js)并用 node 执行来产出 JSON 文件。绝对定位场景(鱼骨图、飞轮图、柱状图、折线图等)的坐标需要数学计算,直接手写 JSON 极易导致节点重叠或连线穿模。---
渲染路径选择(DSL or Mermaid)
| 图表类型 | 路径 | 理由 |
|---|---|---|
| 思维导图 | Mermaid | 辐射结构自动布局 |
| 时序图 | Mermaid | 参与方+消息自动排列 |
| 类图 | Mermaid | 类关系自动布局 |
| 饼图 | Mermaid | Mermaid 原生支持 |
| 流程图 | Mermaid | 通过 Mermaid 语法稳定生成结构 |
| 其他所有类型 | DSL | 精确控制样式和布局 |
路由规则: 1. 自动 Mermaid:思维导图、时序图、类图、饼图、流程图 → 默认走 Mermaid 2. 显式 Mermaid:用户输入包含 Mermaid 语法 → 走 Mermaid 3. DSL 路径:其他所有类型 → 先读核心模块,再读对应场景指南
Mermaid 路径:参考 scenes/mermaid.md 编写 .mmd 文件,跳过 DSL 模块。 DSL 路径:按 Workflow 3 步执行。
---
模块索引
核心参考(DSL 路径必读)
| 模块 | 文件 | 说明 |
|---|---|---|
| DSL 语法 | references/schema.md | 节点类型、属性、尺寸值 |
| 内容规划 | references/content.md | 信息提取、密度决策、连线预判 |
| 布局系统 | references/layout.md | 网格方法论、Flex 映射、间距规则 |
| 排版规则 | references/typography.md | 字号层级、对齐、行距 |
| 连线系统 | references/connectors.md | 拓扑规划、锚点选择 |
| 配色系统 | references/style.md | 多色板、视觉层级 |
场景指南(按类型选读一个)
| 图表类型 | 文件 | 适用场景 |
|---|---|---|
| 架构图 | scenes/architecture.md | 分层架构、微服务架构 |
| 组织架构图 | scenes/organization.md | 公司组织、树形层级 |
| 对比图 | scenes/comparison.md | 方案对比、功能矩阵 |
| 鱼骨图 | scenes/fishbone.md | 因果分析、根因分析 |
| 柱状图 | scenes/bar-chart.md | 柱状图、条形图 |
| 折线图 | scenes/line-chart.md | 折线图、趋势图 |
| 树状图 | scenes/treemap.md | 矩形树图、层级占比 |
| 漏斗图 | scenes/funnel.md | 转化漏斗、销售漏斗 |
| 金字塔图 | scenes/pyramid.md | 层级结构、需求层次 |
| 循环/飞轮图 | scenes/flywheel.md | 增长飞轮、闭环链路 |
| 里程碑 | scenes/milestone.md | 时间线、版本演进 |
| Mermaid | scenes/mermaid.md | 思维导图、时序图、类图、饼图、流程图 |
---
CLI 命令
渲染:
npx -y @larksuite/whiteboard-cli@^0.1.0 -i my-diagram.json -o ./images/my-diagram.png # DSL 路径
npx -y @larksuite/whiteboard-cli@^0.1.0 -i diagram.mmd -o ./images/diagram.png # Mermaid 路径
npx -y @larksuite/whiteboard-cli@^0.1.0 -i skeleton.json -o ./images/step1.png -l coords.json # 两阶段(提取坐标)上传飞书画板:
上传需要飞书认证。遇到认证或权限错误时,阅读 `../lark-shared/SKILL.md` 了解登录和权限处理。
第一步:获取画板 Token
| 用户给了什么 | 怎么获取 Token |
|---|---|
画板 Token(XXX) | 直接使用 |
| 文档 URL 或 doc_id,文档中已有画板 | lark-cli docs +fetch --doc <URL> --as user,从返回的 <whiteboard token=”XXX”/> 中提取 token |
| 文档 URL 或 doc_id,需要新建画板 | lark-cli docs +update --doc <doc_id> --mode append --markdown '<whiteboard type=”blank”></whiteboard>' --as user,从响应的 data.board_tokens[0] 获取 token |
关于飞书文档的创建,读取等更多操作,请参考 lark-doc skill `../lark-doc/SKILL.md`。
第二步:上传
[!CAUTION]
MANDATORY PRE-FLIGHT CHECK (上传前强制拦截检查)
当你要向一个已存在的画板 Token 写入内容时,绝对禁止直接执行上传命令!你必须严格遵守以下两步:
强制执行 Dry Run(状态探测)
必须先在命令中添加 --overwrite --dry-run 参数来探测画板当前状态。示例命令:```bash
npx -y @larksuite/whiteboard-cli@^0.1.0 --to openapi -i <输入文件> --format json | lark-cli docs +whiteboard-update --whiteboard-token <Token> --overwrite --dry-run --as user
```
>
解析结果并拦截
- 仔细阅读 Dry Run 的输出日志。
- 如果日志包含 `XX whiteboard nodes will be deleted`:这说明画板非空,当前操作会覆盖并摧毁用户的原有图表!
- 你必须立即停止操作,并通过 AskUserQuestion 工具(或直接回复)向用户确认:”目标画板当前非空,继续更新将清空原有的 XX 个节点,是否确认覆盖?”- 只有在用户明确授权”同意覆盖”后,你才能移除 --dry-run 真正执行上传。- 用户可能会要求你不覆盖更新画板内容,在这种情况下,移除--overwrite和--dry-run参数再上传。
npx -y @larksuite/whiteboard-cli@^0.1.0 --to openapi -i <输入文件> --format json | lark-cli docs +whiteboard-update --whiteboard-token <画板Token> --yes --as user画板一经上传不可修改。如需应用身份上传,将--as user替换为--as bot。
如果画板非空,先加--overwrite --dry-run检查待删除节点数,向用户确认后去掉--dry-run执行。
症状→修复表(视觉审查发现问题时参照):
| 看到的问题 | 改什么 |
|---|---|
| 文字被截断 | height 改为 fit-content |
| 文字溢出容器右侧 | 增大 width,或缩短文字 |
| 节点重叠粘连 | 增大 gap |
| 节点挤成一团 | 增大 padding 和 gap |
| 连线穿过节点 | 调整 fromAnchor/toAnchor 或增大间距 |
| 大面积空白 | 缩小外层 frame 宽度 |
| 文字和背景色太接近 | 调整 fillColor 或 textColor |
| 布局整体偏左/偏右 | 调整绝对定位的 x 坐标使内容居中 |
---
渲染前自查
生成 DSL 后、渲染前,快速检查:
- [ ] 不同分组用了不同颜色?同组节点样式完全一致?
- [ ] 外层浅色背景、内层白色节点?(外重内轻)
- [ ] 所有节点有边框(borderWidth=2)?文字在背景上清晰可读?
- [ ] 连线用灰色(#BBBFC4),不用彩色?
- [ ] frame 都写了 layout 属性?gap 和 padding 都显式设置了?
- [ ] 含文字节点 height 用 fit-content?connector 在顶层 nodes 数组?
---
关键约束速查
最高频出错的规则,即使不读子模块文件也必须遵守。
1. 含文字节点的 height 必须用 `'fit-content'` — 写死数值会截断文字 2. `fill-container` 仅在 flex 父容器中生效 — layout: 'none' 下宽度退化为 0 3. connector 必须放在顶层 nodes 数组 — 不能嵌套在 frame children 里 4. 图层顺序 — 数组顺序 = 绘制顺序。后定义的元素层级越高,会覆盖先定义的。重叠/浮层/标注元素务必放在数组末尾。 5. flex 容器内的 x/y 会被完全忽略 — 需要自由定位时用 layout: 'none' 或放在顶层 nodes
❌ 致命错误:flex 容器内设 x/y,坐标不生效,节点按顺序排列
{ "type": "frame", "layout": "vertical", "children": [
{ "type": "rect", "x": 100, "y": 0, "text": "成都" },
{ "type": "rect", "x": 540, "y": 0, "text": "康定" }
]}✅ 正确:用 layout: "none" 或放在顶层 nodes 用 x/y 定位。
连线系统
连线策略
| 连线数 | 策略 |
|---|---|
| ≤8 | 逐条画 |
| 9-15 | 代表性连线(每层选 1-2 个节点连到下一层) |
| >15 | 层到层连线,或回退精简分组 |
一个节点有 3+ 条连线时:入线从 top,出线从 bottom,同侧多条线用不同方向分散。
---
connector 必须放根 nodes 数组
// 错误:connector 放在 frame children 里
{ type: 'frame', children: [
{ type: 'connector', ... } // 会导致 Schema 报错或无法连线!
]}
// 正确:connector 放在根 nodes 数组
const doc: WBDocument = {
version: 2,
nodes: [
{ type: 'frame', id: 'box', ... },
{ type: 'connector', ... }, // 必须和顶层 frame 平级
],
};---
箭头默认值
endArrow省略时默认为'arrow'(即连线末端默认带箭头)。startArrow省略时默认为'none'(即连线起始端默认无箭头)。
---
连线技巧
// 自动绕线(推荐):仅需指定节点 id(锚点也是可选的,引擎可自动推断),并使用 polyline(或 rightAngle)形状
// 只要不传 waypoints,引擎会尝试自动避开障碍物并生成折线。
{ type: 'connector', connector: {
from: 'a', to: 'b', // fromAnchor 和 toAnchor 也可以省略,让引擎自己找最短路径
lineShape: 'polyline', lineColor: '#000000', lineWidth: 2, endArrow: 'arrow' }}
// 精确坐标(做注解箭头)
{ type: 'connector', connector: {
from: { x: 150, y: 200 }, to: 'b', toAnchor: 'left',
lineShape: 'curve', lineColor: '#BBBFC4', lineWidth: 2,
lineStyle: 'dashed', endArrow: 'triangle' }}
// 手动控制路径点 waypoints(仅在需要强制固定路线、或者自动路由不符合预期时使用)
// 注意:一旦提供了 waypoints,引擎将严格尊重这些点,不再进行自动避障。
{ type: 'connector', connector: {
from: { x: 300, y: 140 }, to: { x: 300, y: 340 },
waypoints: [{ x: 350, y: 140 }, { x: 350, y: 340 }],
lineShape: 'polyline', lineColor: '#000000', lineWidth: 2, endArrow: 'arrow' }}[!IMPORTANT]
1. `lineShape` 强制选用约束:
- `'polyline'`(圆角折线):默认首选。适用于流程图、架构图等绝大多数场景。
- `'straight'`(直线):适用于坐标轴、数轴、几何图形边框等绝对不能弯曲的场景。
- `'rightAngle'`(直角折线):适用于 organization.md 等明确要求“总线/直角规约”、树状层级严格对齐的场景。
- `'curve'`(曲线):适用于优雅的跨层连线(S型弯)、自由发散的脑图分支、或做注解箭头时。
2. 间距要求:有 connector 连线的卡片间 gap ≥ 40,否则箭头挤在缝里看不清。
3. 顶层约束:connector必须直接放在WBDocument.nodes,严禁嵌套在children内。建议在数据末尾统一声明连线。
[!TIP]
何时手动算 waypoints:引擎没有连线自动避障功能,当需要避开特定障碍物、或保证特定的走线形状时,需要手动计算 waypoints 控制走向。连线标签:需要文字说明时,可用 label 标注。---
锚点方向规则
锚点(top/right/bottom/left)表示连线从节点的哪个边出发,方向含义与 CSS border 四边相同。
注意:由于目前自动绕线功能支持省略锚点让引擎自动推断,以下规则主要适用于你想强制控制出线方向,或者使用直线/曲线时的场景。
选择锚点时根据两个节点的相对位置:目标在下方用 fromAnchor: 'bottom' + toAnchor: 'top',目标在右侧用 fromAnchor: 'right' + toAnchor: 'left'。如果手动指定了锚点,必须与节点的实际相对位置匹配,否则可能导致连线反向绕行。
锚点绑定的常见范式:
- 同层横向推进(目标在正右):
fromAnchor: "right"->toAnchor: "left" - 垂直下沉推进(目标在正下):
fromAnchor: "bottom"->toAnchor: "top" - 跨层斜切推进(目标在左下或右下):首选 `fromAnchor: "bottom"` -> `toAnchor: "top"`。由于线段自身带有重力倾向,从底部出线再弯曲进入下一层顶部,完美契合流水线的 S 型大弯,能画出最优雅顺滑的跨层曲线。避免使用左右锚点互相跨接。
- 逆流回捞(底部发散回指顶部原点):首选 `fromAnchor: "top"` -> `toAnchor: "bottom"` 配合
lineStyle: "dashed"。
内容规划
核心原则:信息量匹配用户需求的详细程度。 用户说"画一个简单架构图"就画简单的,说"画一个完整的微服务架构"才画复杂的。不要自作主张过度展开。
用户 prompt 简短/模糊时(如"画个漏斗图"、"画个架构图"),不要只输出字面内容。应适当补充该领域合理的内容
信息量参考
| 用户需求 | 合理的信息量 |
|---|---|
| "画一个简单的 XX 架构图" | 3 层,每层 2-3 节点,无侧边栏 |
| "画一个 XX 架构图"(普通请求) | 3-4 层,每层 3-4 节点 |
| "画一个完整/详细的 XX 架构图" | 4-5 层,每层 4-6 节点,可加侧边栏(侧边栏最多 2-3 项) |
| 流程图 | 6-10 步骤 + 1-2 个条件分支 |
| 对比表 | 4-6 个维度,每格 1-2 行说明 |
| 组织架构 | 3-4 层,每个父节点下 2-4 个子节点 |
节点文字:标题 + 简短说明(如"用户服务\n注册登录和权限管理"),不要写长段落。说明 12 字以内为佳。
分组
每组 2-5 个节点。超过 5 个拆成子组。
连线预判
| 连线数 | 策略 |
|---|---|
| ≤8 | 逐条画 |
| 9-15 | 代表性连线 |
| >15 | 层到层,或回退精简 |
精简触发条件
布局放不下时才精简:
| 问题 | 精简方式 |
|---|---|
| 节点文字放不下 | 缩短描述文字 |
| 一行节点超过 5 个 | 拆成两排或合并同类 |
| 连线交叉 | 减少连线数量 |
布局系统
布局决策
不要靠关键词猜布局。先分析信息结构,再决定布局策略。
| 判断条件 | 布局策略 |
|---|---|
| 元素有明确上下层级(用户层→服务层→数据层) | Flex 分层 |
| 空间位置承载信息(地理方位、拓扑坐标、角度) | 纯绝对定位(脚本计算坐标) |
| 多个独立模块平级互联,无上下级 | 混合布局(岛屿式) |
| 不确定 | 默认 Flex 分层(最安全) |
| 布局策略 | 适用图表 |
|---|---|
| 纯绝对定位 | 鱼骨图、柱状图、折线图、拓扑图、地图路线 |
| Flex 骨架 | 架构层级图、卡片墙、组织架构图、对比表 |
| 混合(岛屿式) | 系统集成图、飞轮图、流程图 |
读代码画架构图:扫目录结构(按层分 → Flex;按功能模块分 → 看依赖方向)→ grep import(单向→Flex;网状→混合)→ 拿不准→默认 Flex。
flex 容器内的 `x/y` 会被完全忽略!
❌ 致命错误:
{ "type": "frame", "layout": "vertical", "children": [
{ "type": "rect", "x": 100, "y": 0, "text": "成都" },
{ "type": "rect", "x": 540, "y": 0, "text": "康定" }
]}✅ 正确:用 layout: "none" 或放在顶层 nodes 用 x/y。
构建方式:
| 布局类型 | 做法 |
|---|---|
| 纯 Flex | 直接写 JSON |
| 混合布局 | 直接写 JSON + 估高辅助 |
| 纯绝对定位 | 写脚本生成 JSON(node xxx.js) |
| 需要精确避让 | 脚本 + --layout 两阶段 |
---
网格方法论
核心理念:先画网格,再填内容。
先回答三个问题: 1. 信息分几行几列? 每组一行或一列 2. 每格多大? 等宽还是有主次? 3. 行列间距多大? 分区间 24-32px,同区内 12-16px
---
布局模式选择
| 模式 | 适用场景 | DSL 映射 |
|---|---|---|
| grid | 架构图、对比表、卡片墙、看板 | vertical frame 嵌套 horizontal frame |
| flow | 流程图、审批流 | vertical frame,主流程居中 |
| tree | 组织架构、模块依赖 | 根节点居中,子节点横向展开 |
| free | 系统集成、拓扑图、鱼骨图 | layout: "none" + x/y |
大多数图表用 grid 模式。只有节点位置本身有含义时才用 free。
以上都是布局策略名称,不是 DSL 的layout属性值。DSL 的 layout 只支持'horizontal'、'vertical'、'none'三种。
---
DSL 与 CSS Flexbox 属性映射
| DSL 属性 | 对应的 CSS 心智模型 | 限制 |
|---|---|---|
layout: 'horizontal' | flex-direction: row | 不写 layout = 绝对定位 |
layout: 'vertical' | flex-direction: column | 同上 |
layout: 'none' | position: absolute(子节点用 x/y) | 子节点不能用 fill-container |
width/height: 'fill-container' | flex: 1(主轴)/ align-self: stretch(交叉轴) | 祖先必须有确定尺寸 |
width/height: 'fit-content' | width/height: auto | — |
alignItems | 同 CSS align-items | 仅 'start'/'center'/'end'/'stretch'(无 flex- 前缀) |
justifyContent | 同 CSS justify-content | 仅 'start'/'center'/'end'/'space-between'/'space-around' |
gap | 同 CSS gap | 必须显式写(不写节点会粘连) |
padding | 同 CSS padding | 必须显式写。支持 number / [v,h] / [t,r,b,l] |
alignItems 默认值为 'start'(CSS Flexbox 默认 stretch)。需要等高卡片时必须显式写 alignItems: 'stretch'。
DSL 的语法是严格白名单,不能写原生 CSS 属性(不支持 alignSelf、flexWrap、margin 等)。
---
DSL 注意事项
1. frame 必须写 layout 属性,不写时子节点全堆在左上角。 2. fill-container 死锁陷阱:使用 fill-container 时,祖先链中必须有固定宽度(或高度),否则和 fit-content 形成死锁,尺寸退化为 0。
// 死锁:horizontal 父 width fit-content + 子 width fill-container
{ "type": "frame", "layout": "horizontal", "width": "fit-content", "children": [
{ "type": "rect", "width": "fill-container" }
]}
// 正确:祖先在对应轴有固定尺寸
{ "type": "frame", "layout": "horizontal", "width": 1200, "children": [
{ "type": "rect", "width": "fill-container" }
]}3. 含文字节点高度用 fit-content,引擎不支持 overflow,写死高度会截断文字。 4. Shape 节点有内边距:rect/ellipse/diamond/triangle 各边 12px;cylinder 垂直 +42px。 5. 不支持 flex-wrap,需要换行时用嵌套 frame 模拟。 6. 图层顺序:数组中越靠后的节点层级越高。需要叠加标注时放在数组最后。
---
布局选择指南
| 你要表达的关系 | 怎么排 | DSL 写法 |
|---|---|---|
| 先后顺序、层级从上到下 | 纵向堆叠 | layout: 'vertical' |
| 并列、同等重要、可对比 | 横向等分 | layout: 'horizontal' + alignItems: 'stretch' + width: 'fill-container' |
| 区域有名称,名称在侧边 | 侧标签 + 内容并排 | 横向 frame: [text(标签), frame(内容)] |
| 多个大分区,各自独立 | 分区纵向排列 | 纵向 frame 包多个彩色 frame |
| 一行放不下,需要换行 | 嵌套横向 frame 模拟换行 | 纵向 frame 包多个横向 frame |
| 节点位置本身有含义(拓扑、地图) | 绝对定位 | layout: 'none' + x/y |
这些可以自由嵌套组合。比如:纵向堆叠(标题) + 分区纵向排列(多个层) + 每个层内横向等分(节点)。
---
布局示例
纵向堆叠(标题 + 内容)
{
"type": "frame", "layout": "vertical", "gap": 28, "padding": 32,
"width": 1200, "height": "fit-content",
"children": [
{ "type": "text", "width": "fill-container", "height": "fit-content",
"text": "图表标题", "fontSize": 24, "textAlign": "center" },
...内容...
]
}横向等分(并列元素)
{
"type": "frame", "layout": "horizontal", "gap": 16, "padding": 0,
"width": "fill-container", "height": "fit-content",
"alignItems": "stretch",
"children": [
{ "type": "rect", "width": "fill-container", "height": "fit-content",
"textAlign": "center", "verticalAlign": "middle", "text": "A" },
{ "type": "rect", "width": "fill-container", "height": "fit-content",
"textAlign": "center", "verticalAlign": "middle", "text": "B" }
]
}alignItems: 'stretch' + width: 'fill-container' = 等宽等高。
侧标签 + 内容
{
"type": "frame", "layout": "horizontal", "gap": 24, "padding": 0,
"width": "fill-container", "height": "fit-content",
"alignItems": "center",
"children": [
{ "type": "text", "width": 160, "height": "fit-content",
"text": "区域名称", "fontSize": 20, "textColor": "#1F2329", "textAlign": "right" },
{ "type": "frame", "width": "fill-container", "height": "fit-content",
...区域内容...
}
]
}不要用 frame 的 title 属性做标签——渲染为极小标题栏,不可读。
分区纵向排列
把内容划分为几个大区域,每个区域用不同颜色区分(颜色从 style 文件的色板选取):
{
"type": "frame", "layout": "vertical", "gap": 28, "padding": 0,
"width": "fill-container", "height": "fit-content",
"children": [
{ "type": "frame", "borderRadius": 8,
"layout": "horizontal", "gap": 16, "padding": 20, ...区域1... },
{ "type": "frame", "borderRadius": 8,
"layout": "horizontal", "gap": 16, "padding": 20, ...区域2... }
]
}模拟换行
一行放不下时,拆成多个横向 frame:
{
"type": "frame", "layout": "vertical", "gap": 8, "padding": 0,
"children": [
{ "type": "frame", "layout": "horizontal", "gap": 8, "padding": 0,
"children": [item1, item2, item3, item4] },
{ "type": "frame", "layout": "horizontal", "gap": 8, "padding": 0,
"children": [item5, item6] }
]
}---
绝对定位
当节点位置本身有含义(拓扑图、地图、时间线轴)时用绝对定位。大多数图表优先用 Flex。
混合布局
模块内部用 Flex 自动排版,模块之间用绝对定位自由摆放。每个模块是一个带 x/y 的 flex frame:
{
"type": "frame", "id": "module-a", "x": 100, "y": 100,
"width": 300, "height": "fit-content",
"layout": "vertical", "gap": 8, "padding": 16,
"children": [
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "内容1" },
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "内容2" }
]
}两阶段绘图
先出骨架图导出坐标,再基于坐标补充连线和注解:
npx -y @larksuite/whiteboard-cli@^0.1.0 -i skeleton.json -o step1.png -l coords.jsoncoords.json 包含每个带 id 节点的精确坐标(absX, absY, width, height)。
---
常用间距和尺寸
| 参数 | 常用范围 | 说明 |
|---|---|---|
| 整图宽度 | 1000-1400px | — |
| 分区之间间距 | 24-32px | — |
| 同分区内节点间距 | 12-16px | — |
| 有连线的节点间距 | >= 40px | 给箭头留空间 |
| 分区内边距 | 16-24px | — |
| 侧标签宽度 | 120-180px | — |
---
等大卡片
一排卡片需要等宽等高时,不要写固定像素:
{
"type": "frame", "layout": "horizontal", "gap": 16, "padding": 0,
"alignItems": "stretch",
"children": [
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "A" },
{ "type": "rect", "width": "fill-container", "height": "fit-content", "text": "B" }
]
}alignItems: 'stretch' + width: 'fill-container' = 等宽等高。
DSL Schema
Frame 的布局系统基于 Yoga 引擎,行为基本等同于 CSS Flexbox。layout: 'horizontal'=flex-direction: row,fill-container=flex: 1,fit-content=width: auto,gap/padding/alignItems/justifyContent语义相同。枚举值用'start'/'end'而非'flex-start'/'flex-end'。注意差异:alignItems默认值为'start'(CSS 默认stretch),需要等高卡片时必须显式写alignItems: 'stretch'。
WBDocument
interface WBDocument {
version: 2;
nodes: WBNode[]; // 顶层节点。connector 必须放在这里,不能嵌套在 children 中
}节点类型
Frame(容器)
唯一可以包含子节点的类型。用于分组、布局、背景。
{
type: 'frame';
id?: string;
x?: number; y?: number; // Flex 子节点不需要 x/y
width: WBSizeValue;
height: WBSizeValue;
layout: 'horizontal' | 'vertical' | 'none'; // 必须写,不写默认绝对定位
gap: number; // 必须显式写(不写节点会粘连,容易出 bug)
padding: number | [number, number] | [number, number, number, number]; // 必须显式写(不写内容贴边)
justifyContent?: 'start' | 'center' | 'end' | 'space-between' | 'space-around';
alignItems?: 'start' | 'center' | 'end' | 'stretch';
fillColor?: string;
borderColor?: string;
borderWidth?: number;
borderDash?: 'solid' | 'dashed' | 'dotted';
borderRadius?: number;
children?: WBNode[]; // 不能包含 connector
}虚拟 frame 陷阱:无 title、无 fillColor、无 borderColor、无 borderWidth 的 frame 在编译时会被跳过(子节点直接提升到父级)。如果给这种虚拟 frame 设了 id 并用 connector 连接它,编译后 frame 消失,connector 引用会失效。解决办法:给 frame 加上 borderWidth: 0 或任意可见属性,阻止它被优化掉。基础图形
{
type: 'rect' | 'ellipse' | 'cylinder' | 'diamond' | 'triangle' | 'trapezoid';
id?: string;
x?: number; y?: number;
opacity?: number; // 0-1,仅影响 fillColor 的透明度(对 frame/text/stickyNote 无效)
vFlip?: boolean;
hFlip?: boolean;
width: WBSizeValue;
height: WBSizeValue;
fillColor?: string;
borderColor?: string;
borderWidth?: number;
borderDash?: 'solid' | 'dashed' | 'dotted';
borderRadius?: number;
topWidth?: number; // 仅对 triangle / trapezoid 有效,梯形顶边宽度或三角形顶角截断宽度
text?: string | WBTextRun[]; // 纯文本或富文本
fontSize?: number;
textColor?: string;
textAlign?: 'left' | 'center' | 'right'; // Shape 默认 'center'(与 CSS 不同)
verticalAlign?: 'top' | 'middle' | 'bottom'; // Shape 默认 'middle'(与 CSS 不同)
}cylinder 约束:cylinder 的弧度固定 16px,不随宽度缩放。宽度过大会变成扁椭圆。禁止width: "fill-container",必须用固定宽度 +height: "fit-content"。宽度根据文字长度选择,通常 120-200px。
Shape 内边距(TEXT_INSET):Shape 节点有强制内边距,fit-content 会自动补偿。
- rect / ellipse / diamond / triangle:上下左右各 12px
- cylinder:顶部弧形 32px + 底部弧形 10px(垂直 +42px),水平各 7px
>
需要手算固定尺寸时:实际文字宽/高 + 对应 inset。例:rect 内 14px 字号两行文字高 ~32px → height >= 32 + 24 = 56pxText(纯文本节点)
{
type: 'text';
id?: string;
x?: number; y?: number;
width: WBSizeValue;
height: WBSizeValue;
text?: string | WBTextRun[];
fontSize?: number;
textColor?: string;
textAlign?: 'left' | 'center' | 'right';
verticalAlign?: 'top' | 'middle' | 'bottom';
}StickyNote(便签)
{
type: 'stickyNote';
id?: string;
x?: number; y?: number;
width: WBSizeValue;
height: WBSizeValue;
fillColor?: '#FEF1CE' | '#F5D1A7' | '#DFF5E5' | '#CDF7CC' | '#C9E8EF' | '#D6DCF3' | '#D3CCEE' | '#F1C5E7' | '#F6C8C8'; // 便签底色(仅支持这 9 种)
text?: string | WBTextRun[];
fontSize?: number;
textColor?: string;
textAlign?: 'left' | 'center' | 'right';
verticalAlign?: 'top' | 'middle' | 'bottom';
}Connector(连线)
必须放在顶层 nodes 数组中,不能嵌套在 frame 的 children 里。
{
type: 'connector';
id?: string;
connector: {
from: string | { x: number; y: number }; // 节点 id 或坐标
to: string | { x: number; y: number };
fromAnchor?: 'top' | 'right' | 'bottom' | 'left';
toAnchor?: 'top' | 'right' | 'bottom' | 'left';
lineShape?: 'straight' | 'polyline' | 'curve' | 'rightAngle'; // 直线、圆角折线、曲线、直角折线
lineColor?: string;
lineWidth?: number;
lineStyle?: 'solid' | 'dashed' | 'dotted';
startArrow?: 'none' | 'arrow' | 'triangle' | 'circle' | 'diamond';
endArrow?: 'none' | 'arrow' | 'triangle' | 'circle' | 'diamond';
label?: string; // 连线中间的标签文字
waypoints?: { x: number; y: number }[]; // polyline 途经点
label?: string; // 连线中间的标签文字
labelPosition?: number; // 标签位置,0-1,默认 0.5(中点)
};
}SVG
{
type: 'svg';
id?: string;
x?: number; y?: number;
opacity?: number;
width: WBSizeValue;
height: WBSizeValue;
svg: { code: string }; // SVG 代码字符串
}渲染规范
SVG 通过 image/svg+xml Blob 加载到画布,不在 HTML DOM 中,因此存在严格限制:
必须:
- 包含
viewBox属性(如viewBox="0 0 24 24"),引擎依赖它确定坐标系 - 包含
xmlns="http://www.w3.org/2000/svg"(SVG 作为独立image/svg+xml解析时,XML 规范要求声明命名空间)
允许的元素(纯几何绘制):
- 基本图形:
<rect><circle><ellipse><line><polyline><polygon><path> - 渐变/滤镜:
<defs><linearGradient><radialGradient><filter><feGaussianBlur><feMerge> - 结构:
<g><clipPath><mask><use>
禁止的元素(字体和外部资源在 Blob 沙箱中无法加载):
<text><tspan>(用同层 DSL rect 节点 + text 属性替代)<image>(用同层 DSL image 节点替代)<foreignObject>- 任何引用外部 URL 的属性(
xlink:href指向远程资源等)
两种典型用法
1. 背景装饰 SVG(大尺寸,与 frame 同大小)
用于绘制连线、曲线、发光效果等几何背景。文字信息通过同一 frame 内的 rect 节点叠加:
{
"type": "frame", "width": 1400, "height": 680, "layout": "none",
"children": [
{ "type": "svg", "x": 0, "y": 0, "width": 1400, "height": 680,
"svg": { "code": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1400 680\" ...>...</svg>" } },
{ "type": "rect", "x": 100, "y": 50, "width": 200, "height": 40,
"text": "Label", "fillColor": "transparent" }
]
}2. 内联图标 SVG(24-48px,Feather/Lucide 风格)
用于卡片/按钮中的小图标,纯 stroke 线条:
{ "type": "svg", "width": 32, "height": 32,
"svg": { "code": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3B82F6\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><polyline points=\"12 6 12 12 16 14\"/></svg>" } }---
富文本 WBTextRun
text 字段可以是纯字符串或 WBTextRun[] 数组。类似 HTML 内联样式:bold 对应 <b>,italic 对应 <i>,listType 对应 <ol>/<ul>。每个 run 是一段带样式的文字:
interface WBTextRun {
content: string; // 文字内容,可含 \n 换行
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikeThrough?: boolean;
fontSize?: number;
color?: string; // 文字颜色
backgroundColor?: string; // 文字高亮背景
hyperlink?: string;
listType?: 'none' | 'ordered' | 'unordered';
indent?: number; // 缩进级数
quote?: boolean; // 引用块
}示例:
{
"text": [
{ "content": "标题文字\n", "bold": true, "fontSize": 16 },
{ "content": "正文内容,", "fontSize": 14 },
{ "content": "高亮部分", "backgroundColor": "#FEF1CE", "fontSize": 14 }
]
}text 和 content 中出现的双引号必须写成 \",这是 JSON 规范要求。换行用 \n(JSON 中写为 "第一行\n第二行",不要双重转义为 \\n)。
---
尺寸值 WBSizeValue
| 值 | 含义 | 注意 |
|---|---|---|
number | 固定像素 | 任何场景 |
'fit-content' | 由内容决定大小 | 父级需要 Flex 布局 |
'fit-content(N)' | 同上,无内容时 fallback N | 同上 |
'fill-container' | 填满父级剩余空间 | 父级需要 Flex 布局,且祖先链有固定宽度 |
'fill-container(N)' | 同上,无 Flex 时 fallback N | — |
fill-container 在 layout: 'none'(绝对定位)下无效。fit-content 仍可用于含文字节点(引擎通过 Yoga measureFunc 测量文字尺寸)。
配色系统
怎么上色(最重要)
上色步骤:
1. 找出图中有几个分组(层级、分支、类别、阶段...) 2. 为每个分组选一种不同颜色(从色板中选 2-4 种颜色) 3. 分组容器用浅色填充 — 告诉读者"这块是一个整体" 4. 分组内节点用白色填充 + 该分组的深色 borderColor — 告诉读者"这些属于这个分组"
具体映射(经典色板):
| 分组 | 层容器 fillColor | 层容器 borderColor | 内部节点 borderColor |
|---|---|---|---|
| 第 1 组 | #F0F4FC(浅蓝) | #5178C6 | #5178C6 |
| 第 2 组 | #EAE2FE(浅紫) | #8569CB | #8569CB |
| 第 3 组 | #DFF5E5(浅绿) | #509863 | #509863 |
| 第 4 组 | #FEF1CE(浅黄) | #D4B45B | #D4B45B |
| 第 5 组 | #FEE3E2(浅红) | #D25D5A | #D25D5A |
| 内部节点 | #FFFFFF | 跟随所属分组 | — |
各类图表怎么上色:
- 架构图有 3 层 → 每层一种颜色,层背景浅色填充,层内节点白色+深色边框
- 对比表有 3 列 → 每列表头一种颜色,该列数据单元格用同色边框
- 组织架构有 4 个部门 → 每个部门一种颜色,子部门白色+同色边框
- 流程图 → 起止节点一种颜色,判断节点一种颜色,步骤节点白色
[!IMPORTANT]
用户配色优先。 用户指定了色值/风格时以用户为准。用户只给 1-2 个色值时,推导完整色板:主色→浅底→深边框→灰调连线色。
用户未指定配色时,必须从上方色板表中选取颜色,不要使用表中没有的自创色值(如#E8F3FF、#1664FF、#14C9C9等都不在色板中)。
---
结构规则
分组 — 不同层/分组必须用不同颜色
选 2-4 种颜色,每种代表一个分组。同组节点视觉完全一致(fillColor、borderColor 相同)。
分层 — 外重内轻
- 外层(大分区):浅色填充背景
- 内层(具体节点):白色填充 + 分组色边框
清晰
- 所有节点有边框(borderWidth=2)
- 间距不粘连(gap >= 8,有连线时 >= 40)
- 文字在背景上清晰可读(fontSize >= 14)。文字与背景色对比度应足够(参考 WCAG 2.1:正文至少 4.5:1,标题至少 3:1)
- 不要仅靠颜色区分信息——同时使用边框、形状或文字标签辅助,确保色觉障碍用户也能理解
- 连线用灰色(#BBBFC4),不抢节点注意力
统一参数
| 参数 | 值 | 为什么 |
|---|---|---|
| borderWidth | 2 | 让边框清晰可见 |
| borderRadius | 8 | 统一的圆角,整洁 |
| gap(最小值) | 8 | 元素不粘连 |
| padding(最小值) | 8 | 内容不贴边 |
| gap(有连线时) | 40 | 给箭头留空间 |
| fontSize(正文) | >= 14 | 可读 |
| fontSize(标题) | >= 24 | 醒目 |
| fontSize(辅助) | >= 13 | 不费眼 |
---
色板选择指南
根据用户需求的关键词或场景选择合适的色板。未指定时默认使用"经典"色板。
| 色板 | 适用场景 | 关键词 |
|---|---|---|
| 经典 | 通用图表、说明文档 | 默认、通用 |
| 商务 | 汇报、企业架构、正式文档 | 专业、正式、给老板看 |
| 科技 | 技术架构、DevOps、监控 | 技术、炫酷、暗色 |
| 清新 | 流程图、用户旅程、教程 | 清新、自然、轻松 |
| 极简 | 论文配图、学术报告 | 学术、极简、黑白 |
---
预设色板
每套色板定义 7 个角色的颜色。连线色是色板的一部分,不同色板的连线色不同。
经典
| 角色 | fillColor | borderColor | textColor |
|---|---|---|---|
| 分区背景 | #F0F4FC | #5178C6 | #1F2329 |
| 分组标题 | #EAE2FE | #8569CB | #1F2329 |
| 内容节点 | #FFFFFF | #5178C6 | #1F2329 |
| 第二分组 | #DFF5E5 | #509863 | #1F2329 |
| 第三分组 | #FEF1CE | #D4B45B | #1F2329 |
| 第四分组 | #FEE3E2 | #D25D5A | #1F2329 |
| 强调/表头 | #1F2329 | #1F2329 | #FFFFFF |
| 连线 | -- | -- | #BBBFC4 |
商务
| 角色 | fillColor | borderColor | textColor |
|---|---|---|---|
| 分区背景 | #EDF2F7 | #4A6FA5 | #1A202C |
| 分组标题 | #D4E0ED | #4A6FA5 | #1A202C |
| 内容节点 | #FFFFFF | #718BAE | #1A202C |
| 第二分组 | #E8EDF3 | #5A7B9A | #1A202C |
| 第三分组 | #F0F0F0 | #8895A7 | #1A202C |
| 强调/表头 | #2D4A7A | #2D4A7A | #FFFFFF |
| 连线 | -- | -- | #718BAE |
科技
| 角色 | fillColor | borderColor | textColor |
|---|---|---|---|
| 画布/分区背景 | #0F172A | #1E293B | #E2E8F0 |
| 分组标题 | #1E293B | #3B82F6 | #E2E8F0 |
| 内容节点 | #1E293B | #334155 | #E2E8F0 |
| 第二分组 | #1E293B | #8B5CF6 | #E2E8F0 |
| 第三分组 | #1E293B | #10B981 | #E2E8F0 |
| 强调 | #2563EB | #3B82F6 | #FFFFFF |
| 连线 | -- | -- | #475569 |
清新
| 角色 | fillColor | borderColor | textColor |
|---|---|---|---|
| 分区背景 | #F0FDF4 | #86EFAC | #14532D |
| 分组标题 | #DCFCE7 | #4ADE80 | #14532D |
| 内容节点 | #FFFFFF | #86EFAC | #14532D |
| 第二分组 | #ECFDF5 | #6EE7B7 | #14532D |
| 第三分组 | #F0FDFA | #5EEAD4 | #134E4A |
| 强调 | #16A34A | #16A34A | #FFFFFF |
| 连线 | -- | -- | #86EFAC |
极简
| 角色 | fillColor | borderColor | textColor |
|---|---|---|---|
| 分区背景 | #F8F9FA | #DEE2E6 | #212529 |
| 分组标题 | #E9ECEF | #ADB5BD | #212529 |
| 内容节点 | #FFFFFF | #CED4DA | #212529 |
| 第二分组 | #F1F3F5 | #868E96 | #212529 |
| 第三分组 | #F8F9FA | #ADB5BD | #212529 |
| 强调/表头 | #495057 | #495057 | #FFFFFF |
| 连线 | -- | -- | #ADB5BD |
---
各元素怎么画
以下示例使用经典色板。如果选了其他色板,替换对应颜色即可,结构保持不变。
图表标题
告诉读者"这张图讲什么"。大号深色文字,居中。
{ "type": "text", "fontSize": 24, "textColor": "#1F2329", "textAlign": "center" }分区背景
把相关的内容圈在一起,告诉读者"这些属于同一个大类"。浅色做 fillColor,对应深色做 borderColor。内部放白色节点。
{ "fillColor": "#F0F4FC", "borderColor": "#5178C6", "borderWidth": 2, "borderRadius": 8, "padding": 20 }分区标签
给分区一个名字。用独立 text 节点,不要用 frame 的 title 属性(会被渲染为极小标题栏)。
所有分区标签统一用深色文字 `#1F2329`,不要给每个标签用不同颜色——颜色区分通过层容器背景和边框体现,标签文字颜色保持一致。
{ "type": "text", "width": 180, "height": "fit-content", "text": "Access layer", "fontSize": 20, "textColor": "#1F2329", "textAlign": "right" }分组标题
告诉读者"这个子分组叫什么"。色板色填充 + 同色系深色边框。
{ "fillColor": "#EAE2FE", "borderColor": "#8569CB", "borderWidth": 2, "borderRadius": 8, "fontSize": 14, "textColor": "#1F2329" }内容节点
具体的信息项。白色填充,边框颜色跟随所属分组。
{ "fillColor": "#FFFFFF", "borderColor": "#5178C6", "borderWidth": 2, "borderRadius": 8, "fontSize": 14, "textColor": "#1F2329" }白色节点的 borderColor 取决于它所属的分组:
属于蓝色分组: fillColor="#FFFFFF" borderColor="#5178C6" borderWidth=2
属于紫色分组: fillColor="#FFFFFF" borderColor="#8569CB" borderWidth=2
独立节点: fillColor="#FFFFFF" borderColor="#DEE0E3" borderWidth=2(注:以上为经典色板的值,其他色板替换对应的 borderColor)
表头
告诉读者"这一列/行是什么维度"。深色填充 + 白色文字。
{ "fillColor": "#1F2329", "borderColor": "#1F2329", "borderWidth": 2, "borderRadius": 0, "fontSize": 15, "textColor": "#FFFFFF", "textAlign": "center" }textColor 规则
- 正文:#1F2329(深色,在白底/浅色底上清晰)
- 辅助说明:#646A73(弱化,不抢注意力)
- 深色底上:#FFFFFF(反色,清晰可读)
(以上为经典色板的值,其他色板参考对应 textColor 列)辅助说明
补充信息,不抢主角的注意力。灰色小字。
{ "fontSize": 13, "textColor": "#646A73" }连线
表达元素之间的关系或流向。使用色板中的连线色。
{ "lineColor": "#BBBFC4", "lineWidth": 2 }布局容器
纯粹用来排版的 frame,读者看不见它。不设 fillColor、borderColor。
{ "type": "frame", "layout": "vertical", "gap": 28, "padding": 32 }分组容器
用虚线框圈定一组节点,比分区背景更轻量。
{ "borderColor": "#DEE0E3", "borderWidth": 2, "borderDash": "dashed", "borderRadius": 8 }---
常见错误
错误:每个节点一种颜色 -> 读者分不清谁和谁是一组
{ "fillColor": "#8569CB" }, { "fillColor": "#5178C6" }, { "fillColor": "#509863" }正确:同组节点视觉一致 -> 读者一眼看出关系
{ "fillColor": "#FFFFFF", "borderColor": "#8569CB" }, { "fillColor": "#FFFFFF", "borderColor": "#8569CB" }错误:内外层都用重色 -> 读者不知道先看哪里
{ "type": "frame", "fillColor": "#5178C6", "children": [{ "fillColor": "#8569CB" }] }正确:外层浅色内层白色 -> 读者先看结构再看细节
{ "type": "frame", "fillColor": "#F0F4FC", "children": [{ "fillColor": "#FFFFFF", "borderColor": "#5178C6" }] }错误:连线用和节点一样的彩色 -> 和节点颜色抢注意力
{ "connector": { "lineColor": "#5178C6" } }正确:连线用色板中的连线色 -> 衬托节点
{ "connector": { "lineColor": "#BBBFC4" } }错误:节点没边框 -> 和背景融为一体,看不清边界
{ "fillColor": "#FFFFFF" }正确:节点有边框 -> 边界清晰
{ "fillColor": "#FFFFFF", "borderColor": "#DEE0E3", "borderWidth": 2 }错误:全图黑白灰,没有颜色区分 -> 读者无法快速识别分组
{ "fillColor": "#FFFFFF", "borderColor": "#DEE0E3" }正确:不同分组用不同颜色 -> 一眼看出结构(蓝色分组 + 紫色分组)
{ "fillColor": "#F0F4FC", "borderColor": "#5178C6" }
{ "fillColor": "#EAE2FE", "borderColor": "#8569CB" }排版规则
字号层级表
| 层级 | 字号 | 用途 | 对齐 |
|---|---|---|---|
| H1 | 24-28 | 图表标题(每图一个) | center |
| H2 | 18-20 | 分区/层标签 | right(侧标签)或 center(顶部标签) |
| H3 | 15-16 | 分组标题、卡片标题 | center 或 left |
| Body | 14 | 正文、节点文字 | center(短标签)或 left(长文本) |
| Caption | 13 | 辅助说明、注解 | left |
规则:
- 同张图不超过 3 个字号层级
- 同级节点 fontSize 必须完全相同
- 相邻层级字号差 >= 4px
---
对齐规则
Shape 节点默认 textAlign: 'center' + verticalAlign: 'middle'(与 CSS 相反)。如需左对齐须显式声明。
| 内容类型 | 对齐方式 |
|---|---|
| 短文本(<=15 字) | center |
| 长文本(>15 字) | left |
| 侧标签(层名、分区名) | right |
| 图表标题 | center |
| 多行描述/段落 | left |
---
图表标题
用独立 text 节点,不要用 frame 的 title 属性。
- Flex 布局:放在最外层 frame 的第一个 child,
width: "fill-container" - 绝对定位:width 设为图表整体宽度,
textAlign: "center"
---
标题和描述拆成两个节点
一个卡片内展示名称和描述时,用 frame 包两个 text 节点,不要塞进同一个 shape:
{
"type": "frame", "layout": "vertical", "gap": 4, "padding": 12,
"width": "fill-container", "height": "fit-content",
"borderWidth": 2, "borderRadius": 8,
"children": [
{ "type": "text", "width": "fill-container", "height": "fit-content",
"text": "用户服务", "fontSize": 16 },
{ "type": "text", "width": "fill-container", "height": "fit-content",
"text": "处理注册登录和个人信息管理", "fontSize": 13 }
]
}---
尺寸规则
含文字节点 height 必须用 'fit-content'。写死高度会截断文字。
所有节点必须显式声明 width 和 height。
系统架构图
适用于:分层架构图、微服务架构图、前后端架构图等有明确模块划分的场景。
Content 约束
- 充分展开:用户说"IM 架构",要展开到接入层(Web/iOS/Android/桌面)、网关层(接入/路由/安全)、服务层(核心服务+支撑服务两个子区域)、存储层(MySQL/Redis/MongoDB + 括号说明用途)
- 每层节点 3-6 个。超过 6 个分两排或拆为子区域(如"核心服务"和"支撑服务"各一个子 frame)
- 层标签简短(2-4 字),如"接入层""网关层"
- 每个节点有标题 + 简短说明(如"用户服务\n注册登录和权限管理")
- 技术组件加括号注明技术栈(如"消息队列\n(Kafka)")
- 存储节点必须用
cylinder类型(弧度固定 16px,禁止fill-container宽度,用 120-200 固定宽度)。每行最多 4 个 cylinder(超过 4 个换行或合并同类项,如多个 MySQL 合并为"关系数据库\n(MySQL)") - 侧边栏(如运维监控、基础设施)只在用户明确要求时才加,最多 2-3 项。不要自作主张添加侧边栏
- 连线:非必要不画。 架构图的分层结构本身已表达了调用方向(上层调下层),不需要每对节点都连线。只在需要强调特定调用关系时才画,且总数不超过 3-5 条
Layout 选型
| 模式 | 适用条件 | 特征 |
|---|---|---|
| grid(分层条带) | 有明确上下层级关系(接入→网关→服务→存储) | 行=层级,每行 horizontal frame 等分节点。左侧 text 标签 + 右侧层 frame(Label-Outside 模式) |
| grid(网格矩阵) | 多模块平级,无明确层级 | N×M 网格等分,每格一个模块 |
| 混合(岛屿式) | 模块间网状互联,无清晰分层 | 宏观 layout: "none" + x/y 定位各模块岛屿,微观每个岛屿内部用 flex 布局 |
Layout 规则
- 根节点:固定宽度(1200),
height: "fit-content",layout: "vertical",gap: 20,padding: 24 - 主体双栏(有侧边栏时):horizontal frame,
alignItems: "stretch",gap: 16 - 左侧 layers-container:
width: "fill-container",vertical,gap: 16 - 右侧 sidebar:固定宽度 160-180,
height: "fill-container",justifyContent: "space-between" - 单层(Label-Outside):horizontal frame,左侧 text 标签(
width: 80,textAlign: "right"),右侧层 frame(fill-container,带 borderWidth/borderRadius,padding: 24,gap: 16)。为什么用 Label-Outside:标签放在 frame 外部更简洁,避免在 frame 内部嵌套窄 rect 导致竖排文字和对齐问题。 - 子区域:在层 frame 内嵌套 horizontal wrapper(
alignItems: "stretch"保证同行等高),内含多个 vertical frame(各子区域),每个子区域有自己的标题 text + 内容行。行内组件width: "fill-container"自动均分。 - 侧边栏:拆成独立的逻辑块 frame(如"运维监控"和"基础设施"分开),各块
height: "fill-container"。外层justifyContent: "space-between"保证与左侧对齐,内部可设justifyContent: "center"使内容居中。 - 行内标签:层内如有贯穿多列的特殊组件(如中间件),可采用"左侧小标签 + 右侧组件组"的横向布局
骨架示例
分层条带(Label-Outside + 侧边栏)
{
"version": 2,
"nodes": [
{
"type": "frame",
"id": "root",
"x": 0, "y": 0,
"width": 1200,
"height": "fit-content",
"layout": "vertical",
"gap": 20,
"padding": 24,
"children": [
{
"type": "text",
"id": "title",
"width": "fill-container",
"height": "fit-content",
"text": "[图表标题]",
"fontSize": 24,
"textAlign": "center",
"verticalAlign": "middle"
},
{
"type": "frame",
"id": "main-container",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"alignItems": "stretch",
"gap": 16,
"padding": 0,
"children": [
{
"type": "frame",
"id": "layers-container",
"width": "fill-container",
"height": "fit-content",
"layout": "vertical",
"alignItems": "stretch",
"gap": 16,
"padding": 0,
"children": [
{
"type": "frame",
"id": "row-layer-1",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 24,
"padding": 0,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "label-1",
"width": 80,
"height": "fit-content",
"text": "[层标签]",
"fontSize": 20,
"textAlign": "right"
},
{
"type": "frame",
"id": "layer-1",
"width": "fill-container",
"height": "fit-content",
"borderWidth": 2,
"borderRadius": 8,
"layout": "horizontal",
"gap": 16,
"padding": 24,
"alignItems": "stretch",
"children": [
{ "type": "rect", "id": "n-1-1", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "rect", "id": "n-1-2", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "rect", "id": "n-1-3", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" }
]
}
]
},
{
"type": "frame",
"id": "row-layer-2",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 24,
"padding": 0,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "label-2",
"width": 80,
"height": "fit-content",
"text": "[层标签]",
"fontSize": 20,
"textAlign": "right"
},
{
"type": "frame",
"id": "layer-2",
"width": "fill-container",
"height": "fit-content",
"borderWidth": 2,
"borderRadius": 8,
"layout": "vertical",
"gap": 16,
"padding": 24,
"alignItems": "stretch",
"children": [
{
"type": "frame",
"id": "subareas-wrapper",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"alignItems": "stretch",
"gap": 16,
"padding": 0,
"children": [
{
"type": "frame",
"id": "subarea-a",
"width": "fill-container",
"height": "fit-content",
"layout": "vertical",
"gap": 8,
"padding": 12,
"borderRadius": 8,
"borderWidth": 2,
"children": [
{ "type": "text", "id": "title-a", "width": "fill-container", "height": "fit-content", "text": "[子区域名]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{
"type": "frame",
"id": "row-a-1",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 8,
"padding": 0,
"children": [
{ "type": "rect", "id": "sa-1", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "rect", "id": "sa-2", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" }
]
}
]
},
{
"type": "frame",
"id": "subarea-b",
"width": "fill-container",
"height": "fit-content",
"layout": "vertical",
"gap": 8,
"padding": 12,
"borderRadius": 8,
"borderWidth": 2,
"children": [
{ "type": "text", "id": "title-b", "width": "fill-container", "height": "fit-content", "text": "[子区域名]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{
"type": "frame",
"id": "row-b-1",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 8,
"padding": 0,
"children": [
{ "type": "rect", "id": "sb-1", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "rect", "id": "sb-2", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" }
]
}
]
}
]
}
]
}
]
},
{
"type": "frame",
"id": "row-layer-3",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 24,
"padding": 0,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "label-3",
"width": 80,
"height": "fit-content",
"text": "[层标签]",
"fontSize": 20,
"textAlign": "right"
},
{
"type": "frame",
"id": "layer-3",
"width": "fill-container",
"height": "fit-content",
"borderWidth": 2,
"borderRadius": 8,
"layout": "horizontal",
"gap": 0,
"padding": 24,
"justifyContent": "space-around",
"children": [
{ "type": "cylinder", "id": "db-1", "width": 140, "height": "fit-content", "text": "[存储名]", "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "cylinder", "id": "db-2", "width": 140, "height": "fit-content", "text": "[存储名]", "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" }
]
}
]
}
]
},
{
"type": "frame",
"id": "right-sidebar-wrapper",
"width": 180,
"height": "fill-container",
"layout": "vertical",
"alignItems": "stretch",
"justifyContent": "space-between",
"gap": 16,
"padding": 0,
"children": [
{
"type": "frame",
"id": "side-block-1",
"width": "fill-container",
"height": "fill-container",
"layout": "vertical",
"alignItems": "stretch",
"justifyContent": "center",
"gap": 12,
"padding": 16,
"borderRadius": 8,
"borderWidth": 2,
"children": [
{ "type": "text", "id": "side-title-1", "width": "fill-container", "height": "fit-content", "text": "[侧边栏模块名]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{
"type": "frame",
"id": "side-items-1",
"width": "fill-container",
"height": "fit-content",
"layout": "vertical",
"gap": 8,
"padding": 0,
"children": [
{ "type": "rect", "id": "s-1", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "rect", "id": "s-2", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" }
]
}
]
},
{
"type": "frame",
"id": "side-block-2",
"width": "fill-container",
"height": "fill-container",
"layout": "vertical",
"alignItems": "stretch",
"justifyContent": "center",
"gap": 12,
"padding": 16,
"borderRadius": 8,
"borderWidth": 2,
"children": [
{ "type": "text", "id": "side-title-2", "width": "fill-container", "height": "fit-content", "text": "[侧边栏模块名]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{
"type": "frame",
"id": "side-items-2",
"width": "fill-container",
"height": "fit-content",
"layout": "vertical",
"gap": 8,
"padding": 0,
"children": [
{ "type": "rect", "id": "s-3", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "rect", "id": "s-4", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" }
]
}
]
}
]
}
]
}
]
}
]
}岛屿式(网状互联)
{
"version": 2,
"nodes": [
{
"type": "frame",
"id": "root",
"x": 0, "y": 0,
"width": 1200,
"height": 800,
"layout": "none",
"padding": 24,
"children": [
{
"type": "text",
"id": "title",
"x": 0, "y": 0,
"width": 1152,
"height": "fit-content",
"text": "[图表标题]",
"fontSize": 24,
"textAlign": "center",
"verticalAlign": "middle"
},
{
"type": "frame",
"id": "island-a",
"x": 40, "y": 60,
"width": 320,
"height": "fit-content",
"layout": "vertical",
"gap": 12,
"padding": 20,
"borderWidth": 2,
"borderRadius": 8,
"children": [
{ "type": "text", "id": "island-a-title", "width": "fill-container", "height": "fit-content", "text": "[模块名]", "fontSize": 16, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "rect", "id": "ia-1", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "rect", "id": "ia-2", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" }
]
},
{
"type": "frame",
"id": "island-b",
"x": 440, "y": 60,
"width": 320,
"height": "fit-content",
"layout": "vertical",
"gap": 12,
"padding": 20,
"borderWidth": 2,
"borderRadius": 8,
"children": [
{ "type": "text", "id": "island-b-title", "width": "fill-container", "height": "fit-content", "text": "[模块名]", "fontSize": 16, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "rect", "id": "ib-1", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "rect", "id": "ib-2", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" }
]
},
{
"type": "frame",
"id": "island-c",
"x": 240, "y": 340,
"width": 320,
"height": "fit-content",
"layout": "vertical",
"gap": 12,
"padding": 20,
"borderWidth": 2,
"borderRadius": 8,
"children": [
{ "type": "text", "id": "island-c-title", "width": "fill-container", "height": "fit-content", "text": "[模块名]", "fontSize": 16, "textAlign": "center", "verticalAlign": "middle" },
{ "type": "rect", "id": "ic-1", "width": "fill-container", "height": "fit-content", "text": "[节点名]", "borderRadius": 8, "borderWidth": 2, "fontSize": 14, "textAlign": "center", "verticalAlign": "middle" }
]
}
]
},
{ "type": "connector", "connector": { "from": "ia-1", "to": "ib-1", "fromAnchor": "right", "toAnchor": "left", "lineShape": "straight", "lineWidth": 2, "endArrow": "arrow" } },
{ "type": "connector", "connector": { "from": "island-a", "to": "ic-1", "fromAnchor": "bottom", "toAnchor": "top", "lineShape": "rightAngle", "lineWidth": 2, "endArrow": "arrow" } },
{ "type": "connector", "connector": { "from": "island-b", "to": "ic-1", "fromAnchor": "bottom", "toAnchor": "top", "lineShape": "rightAngle", "lineWidth": 2, "endArrow": "arrow" } }
]
}陷阱
- 所有架构图都用分层条带:多模块平级网状互联时应选岛屿式;无明确层级时应选网格矩阵。先判断信息结构再选布局。
- 连线过多导致交叉:架构图非必要不画连线。分层结构本身已表达调用方向,不需要每对节点连线。如果一定要画,最多 3-5 条关键路径。
- 层标签用 frame title(不可读):层标签必须用独立的 text 节点放在 frame 外侧(Label-Outside 模式),不要嵌入 frame 内部。
- cylinder 用 fill-container 宽度:cylinder 弧度固定 16px 不随宽度缩放,必须用固定宽度(120-200)。
- 侧边栏逻辑混合:"运维监控"和"基础设施"必须是独立 frame,不可合并成一个长条。
- 根节点没有固定宽度:根 frame 必须有明确宽度(如 1200),否则子节点的
fill-container无法计算。
柱状图
Content 约束
- 数据点 ≤ 12
- 同一数据系列用同一颜色(不要每个柱不同色)
- Y 轴必须有单位标注(如 "万元"、"人次")
Layout 选型
- 脚本生成坐标(推荐):用 .js 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用
npx -y @larksuite/whiteboard-cli@^0.1.0渲染 - 绝对定位手写:简单柱状图(≤ 5 个柱)可手写坐标
Layout 规则
- 白板坐标系 Y 轴向下为正,图表"底部原点"拥有最大 Y 值,柱体向上生长时 Y 减小
- 柱体等宽等间距,底部对齐 X 轴
- 柱体高度:
height = (value / maxValue) * chartHeight - 柱体 Y 坐标:
y = originY - height - 坐标轴用 connector 直线,末端带箭头(endArrow: "arrow")
- 格线用虚线 connector(lineStyle: "dashed",endArrow: "none")
- 刻度线短横线 connector(endArrow: "none")
- 数值标注放在柱体顶部上方
- 类别标签放在 X 轴下方,居中对齐柱体
坐标与尺寸计算指南
白板坐标系中,X 轴向右为正,Y 轴向下为正。因此图表的"底部原点"实际上拥有最大的 Y 坐标,图形向上生长时 Y 坐标在不断减小。
1. 确定图表区域:
- 设定图表区高度
chartHeight和宽度chartWidth - 设定左下角坐标原点
(originX, originY) - 示例:originX=80, originY=480, chartWidth=1000, chartHeight=400
2. Y 轴映射(计算高度):
- 找出数据的最大值
maxValue - 将 maxValue 向上取整到"整数刻度"(如数据最大 190 → maxValue 取 200)
- 柱子高度:
height = (value / maxValue) * chartHeight - 柱子 Y 坐标:
y = originY - height
3. X 轴映射(计算宽度与 X 坐标):
- 将 chartWidth 按数据个数均分:
slotWidth = chartWidth / barCount - 设定柱子间距
barGap(推荐 slotWidth 的 25%-30%) - 柱子宽度:
barWidth = slotWidth - barGap - 第 i 根柱子 X 坐标:
x = originX + i * slotWidth + barGap / 2
4. Y 轴刻度计算:
- 将 0 到 maxValue 等分为 4-6 个刻度
- 每个刻度的 Y 坐标:
gridY = originY - (tickValue / maxValue) * chartHeight - 刻度线:从 (originX-10, gridY) 到 (originX, gridY) 的短横线
- 网格线:从 (originX, gridY) 到 (originX+chartWidth, gridY) 的虚线
完整 JSON 示例
以下示例:3 根柱子,数据 [120, 200, 150],maxValue=200,originX=80, originY=480, chartWidth=900, chartHeight=400。
- slotWidth = 900 / 3 = 300
- barGap = 80, barWidth = 220
- 刻度:0, 50, 100, 150, 200(每 50 一格,gridInterval = 80px)
{
"version": 2,
"nodes": [
{ "type": "rect", "x": 0, "y": 0, "width": 1100, "height": 580 },
{ "type": "text", "x": 80, "y": 10, "width": 900, "height": "fit-content",
"text": "季度销售额对比", "fontSize": 24, "textAlign": "center" },
{ "type": "text", "x": 10, "y": 40, "width": 60, "height": "fit-content",
"text": "万元", "fontSize": 12, "textAlign": "center" },
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 480 }, "to": { "x": 80, "y": 55 },
"lineShape": "straight", "lineWidth": 2, "endArrow": "arrow"
}},
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 480 }, "to": { "x": 1000, "y": 480 },
"lineShape": "straight", "lineWidth": 2, "endArrow": "arrow"
}},
{ "type": "connector", "connector": {
"from": { "x": 70, "y": 480 }, "to": { "x": 80, "y": 480 },
"lineShape": "straight", "lineWidth": 1,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "text", "x": 20, "y": 470, "width": 50, "height": 20,
"text": "0", "fontSize": 12, "textAlign": "right" },
{ "type": "connector", "connector": {
"from": { "x": 70, "y": 400 }, "to": { "x": 80, "y": 400 },
"lineShape": "straight", "lineWidth": 1,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "text", "x": 20, "y": 390, "width": 50, "height": 20,
"text": "50", "fontSize": 12, "textAlign": "right" },
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 400 }, "to": { "x": 980, "y": 400 },
"lineShape": "straight", "lineWidth": 1, "lineStyle": "dashed",
"startArrow": "none", "endArrow": "none"
}},
{ "type": "connector", "connector": {
"from": { "x": 70, "y": 320 }, "to": { "x": 80, "y": 320 },
"lineShape": "straight", "lineWidth": 1,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "text", "x": 20, "y": 310, "width": 50, "height": 20,
"text": "100", "fontSize": 12, "textAlign": "right" },
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 320 }, "to": { "x": 980, "y": 320 },
"lineShape": "straight", "lineWidth": 1, "lineStyle": "dashed",
"startArrow": "none", "endArrow": "none"
}},
{ "type": "connector", "connector": {
"from": { "x": 70, "y": 240 }, "to": { "x": 80, "y": 240 },
"lineShape": "straight", "lineWidth": 1,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "text", "x": 20, "y": 230, "width": 50, "height": 20,
"text": "150", "fontSize": 12, "textAlign": "right" },
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 240 }, "to": { "x": 980, "y": 240 },
"lineShape": "straight", "lineWidth": 1, "lineStyle": "dashed",
"startArrow": "none", "endArrow": "none"
}},
{ "type": "connector", "connector": {
"from": { "x": 70, "y": 160 }, "to": { "x": 80, "y": 160 },
"lineShape": "straight", "lineWidth": 1,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "text", "x": 20, "y": 150, "width": 50, "height": 20,
"text": "200", "fontSize": 12, "textAlign": "right" },
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 160 }, "to": { "x": 980, "y": 160 },
"lineShape": "straight", "lineWidth": 1, "lineStyle": "dashed",
"startArrow": "none", "endArrow": "none"
}},
{ "type": "rect", "id": "bar-0", "x": 120, "y": 240,
"width": 220, "height": 240, "borderRadius": 4 },
{ "type": "text", "x": 120, "y": 215,
"width": 220, "height": 20,
"text": "120", "fontSize": 14, "textAlign": "center" },
{ "type": "text", "x": 120, "y": 490,
"width": 220, "height": 30,
"text": "Q1", "fontSize": 14, "textAlign": "center" },
{ "type": "rect", "id": "bar-1", "x": 420, "y": 80,
"width": 220, "height": 400, "borderRadius": 4 },
{ "type": "text", "x": 420, "y": 55,
"width": 220, "height": 20,
"text": "200", "fontSize": 14, "textAlign": "center" },
{ "type": "text", "x": 420, "y": 490,
"width": 220, "height": 30,
"text": "Q2", "fontSize": 14, "textAlign": "center" },
{ "type": "rect", "id": "bar-2", "x": 720, "y": 180,
"width": 220, "height": 300, "borderRadius": 4 },
{ "type": "text", "x": 720, "y": 155,
"width": 220, "height": 20,
"text": "150", "fontSize": 14, "textAlign": "center" },
{ "type": "text", "x": 720, "y": 490,
"width": 220, "height": 30,
"text": "Q3", "fontSize": 14, "textAlign": "center" }
]
}坐标推导验证:
- bar-0 (120): height = (120/200)*400 = 240, y = 480-240 = 240
- bar-1 (200): height = (200/200)*400 = 400, y = 480-400 = 80
- bar-2 (150): height = (150/200)*400 = 300, y = 480-300 = 180
- bar-0 x = 80 + 0300 + 80/2 = 120, bar-1 x = 80 + 1300 + 40 = 420, bar-2 x = 80 + 2*300 + 40 = 720
脚本运行方式:
node generate-bar-chart.js
npx -y @larksuite/whiteboard-cli@^0.1.0 -i bar-chart.json -o ./bar-chart.png陷阱
- 单系列用多色(不专业):同一数据系列所有柱体应使用同一颜色
- 缺 Y 轴单位标注,读者无法理解数值含义
- 柱体间距不均匀(脚本需统一计算 barGap)
- Y 轴刻度线和格线误带箭头
- 坐标轴忘记带箭头
对比图 / 矩阵图
适用于:方案对比、功能矩阵、技术选型等多选项按多维度比较的场景。
Content 约束
- 每格内容要充实:不要只写一个关键词,给出具体说明(如"MVCC 多版本并发控制,支持行级锁"而非仅"支持")
- 单格内容不同格子允许不同长度,但每格不超过 5 行
- 长文本(超过 15 字)用
textAlign: "left"(不要居中) - 第一行是标题行(对象名称),第一列是维度标签列
- 维度数量至少 4 个,充分展开对比维度
Layout 选型
| 模式 | 适用条件 | 特征 |
|---|---|---|
| 严格 grid(默认) | 所有对比场景 | 表头行 + 数据行,每行 horizontal frame,行内 rect 等分 |
| 卡片式对比(替代) | 维度较少(2-3 个) | 每个对象做一张独立卡片,卡片内纵向列出各维度。卡片横向等分:外层 layout: "horizontal",每张卡片 width: "fill-container" |
Layout 规则
- 最外层 frame:
layout: "vertical",固定width(如 1000),height: "fit-content" - 每行:horizontal frame,
width: "fill-container",alignItems: "stretch" - 行内单元格全部
width: "fill-container"等分列宽 - 行间
gap >= 12(不要 8,太紧) - 行内列间
gap: 8-12 - 标题行:深色底白字(由 style 控制具体颜色)
- 每列同色边框保持视觉一致性
- 单元格
height: "fit-content",不要写固定 height
骨架示例
3 列 4 行表格
{
"version": 2,
"nodes": [
{
"type": "frame",
"width": 1000,
"height": "fit-content",
"layout": "vertical",
"gap": 12,
"padding": 0,
"children": [
{
"type": "text",
"id": "title",
"width": "fill-container",
"height": "fit-content",
"text": "[对比图标题]",
"fontSize": 24,
"textAlign": "center",
"verticalAlign": "middle"
},
{
"type": "frame",
"id": "header-row",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 8,
"padding": 0,
"alignItems": "stretch",
"children": [
{ "type": "rect", "id": "h-dim", "width": "fill-container", "height": "fit-content", "text": "[维度]", "fontSize": 15, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 0, "borderWidth": 2 },
{ "type": "rect", "id": "h-col-1", "width": "fill-container", "height": "fit-content", "text": "[对象A]", "fontSize": 15, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 },
{ "type": "rect", "id": "h-col-2", "width": "fill-container", "height": "fit-content", "text": "[对象B]", "fontSize": 15, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 },
{ "type": "rect", "id": "h-col-3", "width": "fill-container", "height": "fit-content", "text": "[对象C]", "fontSize": 15, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 }
]
},
{
"type": "frame",
"id": "data-row-1",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 8,
"padding": 0,
"alignItems": "stretch",
"children": [
{ "type": "rect", "id": "d1-dim", "width": "fill-container", "height": "fit-content", "text": "[维度1]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 },
{ "type": "rect", "id": "d1-c1", "width": "fill-container", "height": "fit-content", "text": "[...]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 },
{ "type": "rect", "id": "d1-c2", "width": "fill-container", "height": "fit-content", "text": "[...]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 },
{ "type": "rect", "id": "d1-c3", "width": "fill-container", "height": "fit-content", "text": "[...]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 }
]
},
{
"type": "frame",
"id": "data-row-2",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 8,
"padding": 0,
"alignItems": "stretch",
"children": [
{ "type": "rect", "id": "d2-dim", "width": "fill-container", "height": "fit-content", "text": "[维度2]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 },
{ "type": "rect", "id": "d2-c1", "width": "fill-container", "height": "fit-content", "text": "[...]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 },
{ "type": "rect", "id": "d2-c2", "width": "fill-container", "height": "fit-content", "text": "[...]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 },
{ "type": "rect", "id": "d2-c3", "width": "fill-container", "height": "fit-content", "text": "[...]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 }
]
},
{
"type": "frame",
"id": "data-row-3",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 8,
"padding": 0,
"alignItems": "stretch",
"children": [
{ "type": "rect", "id": "d3-dim", "width": "fill-container", "height": "fit-content", "text": "[维度3]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 },
{ "type": "rect", "id": "d3-c1", "width": "fill-container", "height": "fit-content", "text": "[...]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 },
{ "type": "rect", "id": "d3-c2", "width": "fill-container", "height": "fit-content", "text": "[...]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 },
{ "type": "rect", "id": "d3-c3", "width": "fill-container", "height": "fit-content", "text": "[...]", "fontSize": 14, "textAlign": "center", "verticalAlign": "middle", "borderRadius": 8, "borderWidth": 2 }
]
}
]
}
]
}陷阱
- 行间距 8px 太紧:行间 gap 至少 12,8 会让行与行视觉粘连。
- 长文本居中对齐:超过一行的文本应改为
textAlign: "left",居中多行文本可读性差。 - 列数太多导致每列太窄:对比对象建议 ≤ 5 列(含维度列),超过时合并维度或拆分为多张表。
- 列宽不等:所有数据列必须用
width: "fill-container"等分,不要给某列写固定宽度。 - 行高不等:每行 frame 必须
alignItems: "stretch",否则同行单元格因文字行数不同高矮不齐。 - 忘记维度标签列:第一列放维度名称,标题行(维度列)用与数据列不同的视觉处理。
- 单元格用固定 height:单元格必须
height: "fit-content",固定高度会导致文字截断。
鱼骨图(因果图)
必须写脚本生成 JSON。 鱼骨图的分支角度、原因小骨坐标需要三角函数计算,直接手写 JSON 极易导致节点重叠和连线穿模。请用下方脚本模板。
Content 约束
- 分类 4-6 个
- 每个分类的原因 ≤ 4
- 总原因 ≤ 20(超过必须合并分类)
Layout 选型
- 脚本生成坐标(必须):用 .js 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用
npx -y @larksuite/whiteboard-cli@^0.1.0渲染
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('fishbone-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"
}}
]
}上述骨架展示一个分类(上方)+ 一条原因的模式。完整鱼骨图重复此模式,上下交替。每个分类下可有多条原因,均匀插值分布在分支骨上。
脚本运行方式:
node generate-fishbone.js
npx -y @larksuite/whiteboard-cli@^0.1.0 -i fishbone.json -o ./fishbone.png陷阱
- 代码生成:必须使用带有动态防重叠算法的脚本来计算坐标并输出 JSON。
- 分支骨防重叠:同一侧的相邻分支骨和原因框必须没有任何交叉。
- 自适应高度:原因数量较多时,分支骨自动拉长以容纳所有小骨。
- 原因小骨水平:原因框右侧的附着点必须与连线起点 Y 坐标一致。
- 无箭头:所有分类的分支连线、小骨连线均必须关闭箭头。
- 同色系:同一个分支骨、分类标签节点以及原因小骨节点和连线,必须使用同色系的颜色以保持视觉连贯性。
增长飞轮图 (Flywheel)
必须写脚本生成 JSON。 飞轮图需要极坐标计算阶段标签位置和 SVG 圆环切割,直接手写 JSON 无法正确实现同心圆环结构。请用下方脚本模板。
Content 约束
- 阶段 4-6 个,每阶段短标签(title + 可选 subtitle/desc)
- 中心放置飞轮主题标题
Layout 选型
- 脚本生成坐标(必须):用 .js 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用
npx -y @larksuite/whiteboard-cli@^0.1.0渲染
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 节点,利用极坐标计算每个分段交界处的坐标,使用 <polyline> 画与背景色相同的粗线条(白色、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,减小文字字号,确保相邻文本框不互相覆盖
骨架示例
此场景必须用 .js 脚本生成。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('flywheel.json', JSON.stringify({ version: 2, nodes }, null, 2));脚本运行方式:
node generate-flywheel.js
npx -y @larksuite/whiteboard-cli@^0.1.0 -i flywheel.json -o ./flywheel.png陷阱
- 中心文字被 SVG 遮挡:中心文字节点必须在大圆和小圆之后、SVG 之前添加,确保 z-index 正确
- 缺方向指示箭头:SVG polyline 切割线必须带角度偏转(da 参数),形成顺时针/逆时针箭头感
- 标签位置不对称:外围卡片必须用极坐标公式
x = cx + R * cos(θ)均匀分布,不可手动摆放 - 外围文字容器死锁:
layout: 'vertical'的 frame 内部 text 节点不能用width: 'fill-container',必须指定固定 width
漏斗图 (Funnel)
Content 约束
- 阶段 3-6 个
- 每阶段一行标签 + 数值(如 "{{STAGE_NAME}} ({{PERCENTAGE}})")
- 文案尽量简短;长文案外置到漏斗旁边,图形内仅保留核心短文案
Layout 选型
绝对定位。用 trapezoid / triangle 节点从宽到窄排列,height 用 fit-content。
Layout 规则
- 外层 frame 使用
layout: "vertical"+alignItems: "center"居中对齐 - 所有层必须使用脚本计算宽度,以保证绝对完美的等斜率(直线边缘)。切勿手写拍脑袋的宽度!
- 每层间 gap 0-8px(紧密堆叠视觉效果好),从上到下宽度递减。注意 children 数组第一个元素是最顶层(最宽)
- 所有图形节点必须设置
"vFlip": false(引擎默认朝上翻转,漏斗需要朝下) - 注意:因为
vFlip: false且是倒金字塔结构,所以topWidth实际控制的是漏斗各层的底部较窄边缘。底层可用triangle(topWidth: 0)收窄为尖角,或继续用trapezoid保持平底。
严格的斜率算法(必须在脚本中实现):
要让漏斗的侧边形成一条完美的直线,宽度的递减必须与高度和 gap 严格挂钩。
1. 设定整体宽度收缩系数 angleK(建议值 1.5 到 2.5,表示高度每增加1px,总宽度减少的像素数)。2. 因为从上往下变窄,所以公式是减法:bottomWidth(即 topWidth 属性) = currentWidth - (height * angleK)3. 下一层的顶宽公式(必须考虑 gap 带来的额外内收):nextLayerWidth = bottomWidth - (gap * angleK)脚本构建模板
必须使用 node 运行脚本生成 JSON。
import fs from 'fs';
// 1. 配置基础参数
const GAP = 4;
const ANGLE_K = 2; // 斜率系数:高度每下降1px,宽度减少2px
const LAYER_HEIGHT = 80;
const data = [
{ text: "展现 (100%)", fillColor: "#F0F4FC", textColor: "#1F2329" },
{ text: "点击 (50%)", fillColor: "#EAE2FE", textColor: "#1F2329" },
{ text: "加购 (20%)", fillColor: "#DFF5E5", textColor: "#1F2329" },
{ text: "成交 (5%)", fillColor: "#1F2329", textColor: "#FFFFFF" }
];
// 计算第一层的初始宽度 (保证最底层缩到0或平底)
// 倒推公式:startWidth = 最后一层底宽 + 所有高度消耗 + 所有gap消耗
const totalHeightLoss = data.length * LAYER_HEIGHT * ANGLE_K;
const totalGapLoss = (data.length - 1) * GAP * ANGLE_K;
// 设定最底层为一个尖角 (底宽为0)
let currentWidth = 0 + totalHeightLoss + totalGapLoss;
const children = data.map((layer, index) => {
// 2. 根据公式计算当前层的底宽 (对应节点的 topWidth 属性)
const currentBottomWidth = currentWidth - (LAYER_HEIGHT * ANGLE_K);
const node = {
type: currentBottomWidth <= 0 ? "triangle" : "trapezoid",
width: currentWidth,
// 注意:漏斗中 topWidth 表示的是下方的窄边!如果 <=0 就用 triangle
topWidth: Math.max(0, currentBottomWidth),
height: LAYER_HEIGHT,
vFlip: false, // 必须为 false
text: layer.text,
textAlign: "center",
fillColor: layer.fillColor,
borderColor: layer.fillColor,
borderWidth: 2,
fontSize: 16,
textColor: layer.textColor
};
// 3. 关键:计算下一层的顶宽。必须减去 gap 的向内收缩量!
currentWidth = currentBottomWidth - (GAP * ANGLE_K);
return node;
});
const output = {
version: 2,
nodes: [
{
type: "frame",
layout: "vertical",
alignItems: "center",
gap: GAP,
padding: 40,
children: children
}
]
};
fs.writeFileSync('funnel.json', JSON.stringify(output, null, 2));陷阱
- 不要手写随意递减的宽度:这会导致漏斗侧边变成折线,不直。必须严格使用上述
angleK公式计算。 - 忘记计算 gap 带来的收缩:如果下一层的
width只是简单等于上一层的topWidth,在有 gap 的情况下,衔接处会产生锯齿折角。必须减去gap * angleK。 - vFlip 未设置:忘记
"vFlip": false会导致梯形朝上翻转,漏斗形状错误 - 文字溢出底层:底层越窄空间越小,短文案用
\n换行,长文案外置到漏斗旁边(外层套layout: "horizontal"的 frame,漏斗一侧,说明文字另一侧)
折线图
Content 约束
- 数据点 ≤ 15
- Y 轴必须有单位标注(如 "万元"、"%")
- 折线系列 ≤ 3(超过太密看不清)
Layout 选型
- 脚本生成坐标(推荐):用 .js 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用
npx -y @larksuite/whiteboard-cli@^0.1.0渲染
Layout 规则
- 白板坐标系 Y 轴向下为正,图表"底部原点"拥有最大 Y 值,数据点向上分布时 Y 减小
- 数据点用小 ellipse 标记(width: 12, height: 12)
- 折线用 connector straight 连接相邻数据点,endArrow: "none"
- 坐标轴用 connector 直线,末端带箭头(endArrow: "arrow")
- 格线用虚线 connector(lineStyle: "dashed",endArrow: "none")
- 刻度线短横线 connector(endArrow: "none")
- 数值标注放在数据点上方
- 类别标签放在 X 轴下方,居中对齐数据点
坐标与尺寸计算指南
白板坐标系中,X 轴向右为正,Y 轴向下为正。图表的"底部原点"拥有最大的 Y 坐标,数据点向上分布时 Y 坐标减小。
1. 确定图表区域:
- 设定图表区高度
chartHeight和宽度chartWidth - 设定左下角坐标原点
(originX, originY) - 示例:originX=80, originY=480, chartWidth=900, chartHeight=400
2. Y 轴范围自适应:
- 找出数据最小值
dataMin和最大值dataMax - yMin 不一定为 0:若数据集中在 80-120,Y 轴从 0 开始会让折线挤在顶部一小段区域
- 推荐:yMin = 向下取整到合适刻度(如 dataMin=82 → yMin=80),yMax = 向上取整(如 dataMax=118 → yMax=120)
- 当数据波动极小时(如 98-102),适当扩大范围避免折线过于平坦
3. 数据点坐标计算:
- X 坐标:在可用宽度内均匀分布。
pointX = originX + (i / (pointCount - 1)) * chartWidth - Y 坐标:按比例映射到高度。
pointY = originY - ((value - yMin) / (yMax - yMin)) * chartHeight - ellipse 定位:
ellipseX = pointX - 6, ellipseY = pointY - 6(圆心对齐数据点)
4. 连线逻辑:
- 用 connector straight 将相邻数据点连接
from= 点[i] 的 (pointX, pointY),to= 点[i+1] 的 (pointX, pointY)- startArrow: "none", endArrow: "none"
5. Y 轴刻度计算:
- 将 yMin 到 yMax 等分为 4-5 个刻度
- 每个刻度的 Y 坐标:
gridY = originY - ((tickValue - yMin) / (yMax - yMin)) * chartHeight
完整 JSON 示例
以下示例:4 个数据点,数据 [120, 200, 150, 180],yMin=100, yMax=220,originX=80, originY=480, chartWidth=900, chartHeight=400。
- 刻度:100, 130, 160, 190, 220(每 30 一格)
- 点0 (120): pointX=80, pointY=480-((120-100)/120)*400=480-66.7=413
- 点1 (200): pointX=80+300=380, pointY=480-((200-100)/120)*400=480-333.3=147
- 点2 (150): pointX=80+600=680, pointY=480-((150-100)/120)*400=480-166.7=313
- 点3 (180): pointX=80+900=980, pointY=480-((180-100)/120)*400=480-266.7=213
{
"version": 2,
"nodes": [
{ "type": "rect", "x": 0, "y": 0, "width": 1100, "height": 580 },
{ "type": "text", "x": 80, "y": 10, "width": 900, "height": "fit-content",
"text": "季度销售额趋势", "fontSize": 24, "textAlign": "center" },
{ "type": "text", "x": 10, "y": 40, "width": 60, "height": "fit-content",
"text": "万元", "fontSize": 12, "textAlign": "center" },
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 480 }, "to": { "x": 80, "y": 55 },
"lineShape": "straight", "lineWidth": 2, "endArrow": "arrow"
}},
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 480 }, "to": { "x": 1000, "y": 480 },
"lineShape": "straight", "lineWidth": 2, "endArrow": "arrow"
}},
{ "type": "connector", "connector": {
"from": { "x": 70, "y": 480 }, "to": { "x": 80, "y": 480 },
"lineShape": "straight", "lineWidth": 1,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "text", "x": 20, "y": 470, "width": 50, "height": 20,
"text": "100", "fontSize": 12, "textAlign": "right" },
{ "type": "connector", "connector": {
"from": { "x": 70, "y": 380 }, "to": { "x": 80, "y": 380 },
"lineShape": "straight", "lineWidth": 1,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "text", "x": 20, "y": 370, "width": 50, "height": 20,
"text": "130", "fontSize": 12, "textAlign": "right" },
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 380 }, "to": { "x": 980, "y": 380 },
"lineShape": "straight", "lineWidth": 1, "lineStyle": "dashed",
"startArrow": "none", "endArrow": "none"
}},
{ "type": "connector", "connector": {
"from": { "x": 70, "y": 280 }, "to": { "x": 80, "y": 280 },
"lineShape": "straight", "lineWidth": 1,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "text", "x": 20, "y": 270, "width": 50, "height": 20,
"text": "160", "fontSize": 12, "textAlign": "right" },
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 280 }, "to": { "x": 980, "y": 280 },
"lineShape": "straight", "lineWidth": 1, "lineStyle": "dashed",
"startArrow": "none", "endArrow": "none"
}},
{ "type": "connector", "connector": {
"from": { "x": 70, "y": 180 }, "to": { "x": 80, "y": 180 },
"lineShape": "straight", "lineWidth": 1,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "text", "x": 20, "y": 170, "width": 50, "height": 20,
"text": "190", "fontSize": 12, "textAlign": "right" },
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 180 }, "to": { "x": 980, "y": 180 },
"lineShape": "straight", "lineWidth": 1, "lineStyle": "dashed",
"startArrow": "none", "endArrow": "none"
}},
{ "type": "connector", "connector": {
"from": { "x": 70, "y": 80 }, "to": { "x": 80, "y": 80 },
"lineShape": "straight", "lineWidth": 1,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "text", "x": 20, "y": 70, "width": 50, "height": 20,
"text": "220", "fontSize": 12, "textAlign": "right" },
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 80 }, "to": { "x": 980, "y": 80 },
"lineShape": "straight", "lineWidth": 1, "lineStyle": "dashed",
"startArrow": "none", "endArrow": "none"
}},
{ "type": "connector", "connector": {
"from": { "x": 80, "y": 413 }, "to": { "x": 380, "y": 147 },
"lineShape": "straight", "lineWidth": 3,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "connector", "connector": {
"from": { "x": 380, "y": 147 }, "to": { "x": 680, "y": 313 },
"lineShape": "straight", "lineWidth": 3,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "connector", "connector": {
"from": { "x": 680, "y": 313 }, "to": { "x": 980, "y": 213 },
"lineShape": "straight", "lineWidth": 3,
"startArrow": "none", "endArrow": "none"
}},
{ "type": "ellipse", "id": "pt-0", "x": 74, "y": 407,
"width": 12, "height": 12 },
{ "type": "text", "x": 55, "y": 383,
"width": 50, "height": 20,
"text": "120", "fontSize": 14, "textAlign": "center" },
{ "type": "text", "x": 50, "y": 490,
"width": 60, "height": 30,
"text": "Q1", "fontSize": 14, "textAlign": "center" },
{ "type": "ellipse", "id": "pt-1", "x": 374, "y": 141,
"width": 12, "height": 12 },
{ "type": "text", "x": 355, "y": 117,
"width": 50, "height": 20,
"text": "200", "fontSize": 14, "textAlign": "center" },
{ "type": "text", "x": 350, "y": 490,
"width": 60, "height": 30,
"text": "Q2", "fontSize": 14, "textAlign": "center" },
{ "type": "ellipse", "id": "pt-2", "x": 674, "y": 307,
"width": 12, "height": 12 },
{ "type": "text", "x": 655, "y": 283,
"width": 50, "height": 20,
"text": "150", "fontSize": 14, "textAlign": "center" },
{ "type": "text", "x": 650, "y": 490,
"width": 60, "height": 30,
"text": "Q3", "fontSize": 14, "textAlign": "center" },
{ "type": "ellipse", "id": "pt-3", "x": 974, "y": 207,
"width": 12, "height": 12 },
{ "type": "text", "x": 955, "y": 183,
"width": 50, "height": 20,
"text": "180", "fontSize": 14, "textAlign": "center" },
{ "type": "text", "x": 950, "y": 490,
"width": 60, "height": 30,
"text": "Q4", "fontSize": 14, "textAlign": "center" }
]
}坐标推导验证:
- 点0 (Q1, 120): pointX = 80 + (0/3)900 = 80, pointY = 480 - ((120-100)/120)400 = 413
- 点1 (Q2, 200): pointX = 80 + (1/3)900 = 380, pointY = 480 - ((200-100)/120)400 = 147
- 点2 (Q3, 150): pointX = 80 + (2/3)900 = 680, pointY = 480 - ((150-100)/120)400 = 313
- 点3 (Q4, 180): pointX = 80 + (3/3)900 = 980, pointY = 480 - ((180-100)/120)400 = 213
- ellipse 定位:ellipseX = pointX - 6, ellipseY = pointY - 6
脚本运行方式:
node generate-line-chart.js
npx -y @larksuite/whiteboard-cli@^0.1.0 -i line-chart.json -o ./line-chart.png陷阱
- Y 轴范围不合理:若数据集中在 80-120,Y 轴从 0 到 120 会让折线挤在顶部一小段区域,应设 yMin 接近数据最小值
- 缺 Y 轴单位标注,读者无法理解数值含义
- 数据点太密时标注互相遮挡(超过 10 个点考虑隔一个标注一次)
- 折线段忘记设 endArrow: "none",默认带箭头
- 多系列时折线颜色相近难以区分,应使用对比度高的不同色系
Mermaid 图表路径
本场景与 DSL 路径互斥。
| DSL 路径 | Mermaid 路径 | |
|---|---|---|
| 中间格式 | JSON(WBDocument) | Mermaid 文本(.mmd 文件) |
| 布局控制 | 精确控制(x/y 坐标、Flex) | 由 parser-kit 自动布局 |
| 视觉定制 | 完全可控(颜色、字号、圆角等) | 有限(Mermaid 语法) |
| 参考模块 | references/ + 对应 scene | 仅本文件 |
适用条件
满足以下任一条件时使用:
- 用户明确要求 "用 Mermaid" 或 "输出 Mermaid"
- 用户直接粘贴了 Mermaid 语法文本
- 图表类型为思维导图、时序图、类图、饼图、流程图(自动路由)
CLI 用法
npx -y @larksuite/whiteboard-cli@^0.1.0 -i diagram.mmd -o output.png思维导图 (Mindmap)
mindmap
root((主题))
分支A
子项A1
子项A2
分支B
子项B1
分支C时序图 (Sequence Diagram)
sequenceDiagram
participant A as 浏览器
participant B as 服务器
participant C as 数据库
A->>B: 请求数据
B->>C: 查询
C-->>B: 返回结果
B-->>A: 响应数据消息类型:
->>实线箭头(同步请求)-->>虚线箭头(响应/异步)-x带 x 箭头(失败)
类图 (Class Diagram)
classDiagram
class Animal {
+String name
+int age
+makeSound()
}
class Dog {
+fetch()
}
Animal <|-- Dog饼图 (Pie Chart)
pie title 分布
"类别A" : 40
"类别B" : 30
"类别C" : 20
"类别D" : 10流程图 (Flowchart)
适用于:业务流程、审批流、订单处理流程等有明确顺序和分支判断的场景。
flowchart TD
A([开始]) --> B{条件判断}
B -->|是| C[处理步骤]
B -->|否| D[另一步骤]
C --> E([结束])
D --> E约束与规范
- 节点文字 ≤ 8 字(超过必须缩写,必要时加图例说明)
- 判断节点(菱形)只写条件关键词,不写长描述
- 步骤数 ≤ 12(超过需合并步骤或拆分为子流程)
- 遵循标准流程图符号:开始/结束用体育场形状或圆形
A([开始]),判断用菱形B{判断},步骤用矩形C[步骤]
语法参考
方向:TD(上到下)、LR(左到右)、BT(下到上)、RL(右到左)
节点形状:A[矩形]、A(圆角)、A{菱形}、A((圆形))、A([体育场])、A[[子程序]]
连线:-->(实线)、-.->(虚线)、==>(粗线)、-->|标签|(带标签)
State Diagram
stateDiagram-v2
[*] --> Idle
Idle --> Processing : 收到请求
Processing --> Success : 处理成功
Processing --> Failed : 处理失败
Success --> [*]
Failed --> Idle : 重试其他支持的图表类型
- 甘特图:
gantt - ER 图:
erDiagram - Git 分支图:
gitGraph
注意事项
- 输出纯 Mermaid 文本,不是 JSON,不要混用 DSL
- 节点文字含特殊字符时用双引号包裹:
A["包含(括号)的文字"] subgraph用于逻辑分组- 流程图默认
TD(上到下),如果流程较宽(步骤多但层级浅),用LR(左到右)
里程碑时间线 (Milestone)
Content 约束
- 节点 4-8 个
- 每节点:标题 + 日期 + 可选描述
- 时间从左到右递增
Layout 选型
两种方案按需选择:
1. 横向时间线:horizontal frame,节点等分 2. 交替上下:绝对定位,节点交替在时间轴上下方(节点多时更紧凑)
结构特征
- 标题居中:顶部放置图表标题
- 年份/时间轴条:箭头形色块承载年份,按时间从左到右递增
- 里程碑卡片:下方虚线圆角卡片承载标题与描述
- 严格对齐:年份条与对应卡片等宽,左右对齐
- 文字层级:标题加粗在上,描述文字更小更浅在下,居中对齐
Layout 规则
- 绝对定位为主(
layout: "none"),节点位置承载时间序列含义 - 先确定里程碑数量,计算等距的 x 坐标序列
- 时间轴用 connector 贯穿所有节点
- 节点与时间轴用短竖线连接
- 节点间水平间距一致
- 年份条宽度 = 卡片宽度,垂直间距统一
- 标题与年份区域保留足够留白
骨架示例
{
"version": 2,
"nodes": [
{
"type": "frame",
"x": 0, "y": 0,
"width": 1200, "height": 360,
"layout": "none",
"children": [
{
"type": "text",
"x": 300, "y": 12,
"width": 600, "height": "fit-content",
"text": [{ "content": "{{CHART_TITLE}}", "bold": true, "fontSize": 24 }],
"textAlign": "center"
},
{
"type": "svg",
"x": 50, "y": 56,
"width": 190, "height": 36,
"svg": {
"code": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 190 36\"><polygon points=\"0,0 170,0 190,18 170,36 0,36\"/></svg>"
}
},
{
"type": "text",
"x": 50, "y": 64,
"width": 190, "height": "fit-content",
"text": "{{DATE_1}}",
"textAlign": "center"
},
{
"type": "rect",
"x": 50, "y": 132,
"width": 190, "height": 120,
"borderDash": "dashed",
"borderRadius": 8
},
{
"type": "text",
"x": 50, "y": 150,
"width": 190, "height": "fit-content",
"text": [{ "content": "{{MILESTONE_1_TITLE}}", "bold": true, "fontSize": 16 }],
"textAlign": "center"
},
{
"type": "text",
"x": 50, "y": 180,
"width": 190, "height": "fit-content",
"text": "{{MILESTONE_1_DESC}}",
"fontSize": 13,
"textAlign": "center"
},
{
"type": "svg",
"x": 290, "y": 56,
"width": 190, "height": 36,
"svg": {
"code": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 190 36\"><polygon points=\"0,0 170,0 190,18 170,36 0,36\"/></svg>"
}
},
{
"type": "text",
"x": 290, "y": 64,
"width": 190, "height": "fit-content",
"text": "{{DATE_2}}",
"textAlign": "center"
},
{
"type": "rect",
"x": 290, "y": 132,
"width": 190, "height": 120,
"borderDash": "dashed",
"borderRadius": 8
},
{
"type": "text",
"x": 290, "y": 150,
"width": 190, "height": "fit-content",
"text": [{ "content": "{{MILESTONE_2_TITLE}}", "bold": true, "fontSize": 16 }],
"textAlign": "center"
},
{
"type": "text",
"x": 290, "y": 180,
"width": 190, "height": "fit-content",
"text": "{{MILESTONE_2_DESC}}",
"fontSize": 13,
"textAlign": "center"
}
]
}
]
}陷阱
- 节点太多时太拥挤:超过 6 个节点时考虑交替上下布局或增大画布宽度
- 右侧节点与时间轴末端重叠:最后一个节点的 x + width 不要超出画布边界
- 年份条与卡片不对齐:年份条和卡片的 x、width 必须完全一致
金字塔图 (Pyramid)
Content 约束
- 层级 3-6 个,从底到顶宽度递减
- 每层一个短标签(如关键词或短语)
- 长文案外置到金字塔旁边,图形内仅保留核心短文案
Layout 选型
vertical frame + 每层宽度递减。gap 4px 保持紧密。
Layout 规则
- 外层 frame 使用
layout: "vertical"+alignItems: "center" - 所有层必须使用脚本计算宽度,以保证绝对完美的等斜率(直线边缘)。切勿手写拍脑袋的宽度!
- children 数组中第一个元素是顶层(最窄),最后一个是底层(最宽)。
- 顶层通常用
triangle(topWidth: 0),中间和底层用trapezoid。 - gap 通常设为 4px 保持紧密的金字塔感。
严格的斜率算法(必须在脚本中实现):
要让金字塔的侧边形成一条完美的直线,宽度的增量必须与高度和 gap 严格挂钩。
1. 设定整体宽度扩张系数 angleK(建议值 1.5 到 2.5,表示高度每增加1px,总宽度增加的像素数)。2. 当前层的底宽公式:width = topWidth + (height * angleK)3. 下一层的顶宽公式(必须考虑 gap 带来的额外外扩):nextTopWidth = width + (gap * angleK)脚本构建模板
必须使用 node 运行脚本生成 JSON。
import fs from 'fs';
// 1. 配置基础参数
const GAP = 4;
const ANGLE_K = 2; // 斜率系数:高度每增加1px,宽度增加2px
const LAYER_HEIGHT = 80;
const data = [
{ text: "顶层核心", fillColor: "#1F2329", textColor: "#FFFFFF" },
{ text: "中间层 B", fillColor: "#DFF5E5", textColor: "#1F2329" },
{ text: "中间层 A", fillColor: "#EAE2FE", textColor: "#1F2329" },
{ text: "最底层基础", fillColor: "#F0F4FC", textColor: "#1F2329" }
];
let currentTopWidth = 0; // 顶层如果是尖角,初始为 0
const children = data.map((layer, index) => {
// 2. 根据公式计算当前层的底宽
const currentBottomWidth = currentTopWidth + (LAYER_HEIGHT * ANGLE_K);
const node = {
type: currentTopWidth === 0 ? "triangle" : "trapezoid",
width: currentBottomWidth,
topWidth: currentTopWidth,
height: LAYER_HEIGHT,
text: layer.text,
textAlign: "center",
fillColor: layer.fillColor,
borderColor: layer.fillColor,
borderWidth: 2,
fontSize: 16,
textColor: layer.textColor
};
// 3. 关键:计算下一层的顶宽。必须把 gap 的延伸也算进去!
currentTopWidth = currentBottomWidth + (GAP * ANGLE_K);
return node;
});
const output = {
version: 2,
nodes: [
{
type: "frame",
layout: "vertical",
alignItems: "center",
gap: GAP,
padding: 40,
children: children
}
]
};
fs.writeFileSync('pyramid.json', JSON.stringify(output, null, 2));陷阱
- 不要手写随意递增的宽度:这会导致金字塔侧边变成折线,不直。必须严格使用上述
angleK公式计算。 - 忘记计算 gap 带来的扩展:如果下一层的
topWidth只是简单等于上一层的width,在有 gap 的情况下,衔接处会产生锯齿折角。必须加上gap * angleK。 - 从上到下排列错误:children 数组第一个是顶层(最窄),最后一个是底层(最宽),宽度依次递增。
- 文字溢出顶层三角形:顶层三角形内部可用空间极小。短文案用
\n手动换行;长文案外置到金字塔旁边(外层套 horizontal frame,金字塔左侧,说明文字右侧) - 倒金字塔误用:如果用户要求"倒金字塔"、"漏斗图"或"自上而下递减的结构",不要使用本文件,切换到
scenes/funnel.md
扩展
- 辅助说明:需要在旁边添加文字说明时,在最外层套一个
layout: "horizontal"的 frame,金字塔放左侧,说明文字(vertical 排列的 text 节点)放右侧 - 配色:各层颜色应从色板中选取不同颜色以示区分(如蓝→紫→绿→黄递进)