larksuite/cliPrüfung bestanden
SKILL DETAIL
lark-whiteboard
larksuite/cli/lark-whiteboard
Lark Whiteboard (lark-whiteboard) is a skill for querying and editing whiteboards in Lark cloud documents. It supports exporting whiteboards as preview images, SVG vector graphics, or raw node structures, and updating whiteboard content using Mermaid, PlantUML, SVG, or OpenAPI native formats. Use this skill when users need to view whiteboard content, export whiteboard images, or edit whiteboards. This skill does not handle content editing of Lark documents (use lark-doc) or embedded spreadsheets/Base (use lark-sheets / lark-base).
Installationen · 503Quelle ansehen
Installation
npx skills add https://github.com/larksuite/cli --skill lark-whiteboard
Skill-Dateien
SKILL.md
Zuletzt synchronisiert · 29.08.2026
elements/connectors.md›
# 连线系统
## 连线策略
| 连线数 | 策略 |
|--------|------|
| ≤8 | 逐条画 |
| 9-15 | 代表性连线(每层选 1-2 个节点连到下一层)|
| >15 | 层到层连线,或回退精简分组 |
一个节点有 3+ 条连线时:入线从 top,出线从 bottom,同侧多条线用不同方向分散。
---
## connector 必须放根 nodes 数组
```typescript
// 错误: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'`(即连线起始端默认无箭头)。
---
## 连线技巧
```typescript
// 自动绕线(推荐):仅需指定节点 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' }}
// 绘制坐标轴/数轴(必须使用 straight,防止刻度文字触发自动避障导致线条弯曲)
{ type: 'connector', connector: {
from: { x: 100, y: 400 }, to: { x: 600, y: 400 },
lineShape: 'straight', lineColor: '#000000', lineWidth: 2, endArrow: 'arrow' }}
```
> [!IMPORTANT]
> **1. 形状选用要求(核心)**,需明确 `lineShape` 类型:
> - **`'polyline'`(圆角折线)**:**默认首选**。适用于流程图、架构图等绝大多数场景。支持引擎的**自动绕线与避障**功能(只需指定 `from` 和 `to`)。
> - **`'rightAngle'`(直角折线)**:适用于明确要求“总线/直角规约”、树状层级严格对齐的场景,同样支持**自动绕线与避障**。
> - **`'straight'`(直线)**:不受自动避障机制的影响,适用于**坐标轴、数轴、几何图形边框、直接指向关系**等要求线条绝对笔直、不允许出现任何绕行或弯曲的场景。
> - **`'curve'`(曲线)**:适用于优雅的跨层连线(S型弯)、自由发散的脑图分支、或做注解箭头时。
> - **注意**:你需要根据当前绘制的图表类型和上下文语境,选择最合适的 `lineShape`。不要盲目全部使用 `polyline`,例如在绘制坐标系时必须主动切换为 `straight`。
> **2. 间距要求**:有 connector 连线的卡片间 gap 需 ≥ 40,否则箭头挤在缝里看不清。
> **3. 顶层约束**:`connector` 必须直接放在 `WBDocument.nodes`,**严禁**嵌套在 `children` 内。建议在数据末尾统一声明连线。
>
> [!TIP]
> **自动绕线 vs 手动控制**
> - **优先依赖自动绕线**:对于 `'polyline'` 和 `'rightAngle'`,引擎会自动规划路径并尝试避开障碍物(`fromAnchor` 和 `toAnchor` 也可省略,引擎会自动推断最优出线方向),这是最推荐的方式。
> - **何时手动算 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"`。
elements/content.md›
# 内容规划
核心原则:**信息量匹配用户需求的详细程度。** 用户说"画一个简单架构图"就画简单的,说"画一个完整的微服务架构"才画复杂的。不要自作主张**过度展开**。
**用户 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 个 | 拆成两排或合并同类 |
| 连线交叉 | 减少连线数量 |
elements/image.md›
# 图片准备 (Image Preparation)
> 本文件说明如何在画板 DSL 中使用图片节点。进入任何含图片的场景前,必须先完成图片准备流程。
## 概述
画板 DSL 支持 `type: 'image'` 节点,但图片不能直接使用 URL 或其他域的 token,**必须先上传到目标画板获取 `whiteboard` 域 media token**,然后在 DSL 中引用。
**核心规则**:不管图片从哪来(本地文件、URL、文档中的 `docx_image` token、其他域的 Drive token),都必须通过 `docs +media-upload --parent-type whiteboard --parent-node <目标画板token>` 上传,拿到画板专属的 media token 后才能在 DSL 中使用。直接使用非 `whiteboard` 域的 token 会导致画板 API 报 500(错误码 2891001)或图片在文档中消失。
## Step 0:图片准备流程
### 1. 获取图片到本地
根据图片来源选择对应方式:
| 图片来源 | 获取方式 |
|---------|---------|
| 本地文件 | 直接使用 |
| 网络 URL | `curl -L -o photo.jpg "<URL>"` |
| 文档中的图片 token | `lark-cli docs +media-download --token <token> --output ./photo.png` |
| 其他域的 Drive token | `lark-cli docs +media-download --token <token> --output ./photo.png` |
**图片源选择(需要搜索图片时)**:
| 图片源类型 | 说明 |
|-------|------|
| 免费版权图库 | 支持按关键词搜索,图片无版权风险(CC0 或类似协议),图库种类丰富(人物/动物/风景/美食/建筑等),关键词能精准匹配图片内容 |
| 直接 URL | 用户提供或已知的图片链接,最可靠 |
**选择图库的必要条件**:
- **版权合规**:图片必须无版权纠纷风险,避免使用需要付费授权或有使用限制的图库
- **关键词搜索**:支持按关键词搜索并返回相关图片,确保图片内容与主题匹配
- **内容丰富**:图库图片种类多、数量大,能覆盖常见主题(宠物、美食、景点、产品等)
**严禁使用随机占位图服务**:某些图库仅提供随机占位图,URL 中的关键词参数不会影响返回的图片内容,下载的图片与主题完全无关。
### 2. 校验图片
```bash
ls -l *.jpg # 确认每张文件大小不同;若大小相同则内容可能重复,需重新下载
```
**图片内容审查(必须执行)**:
- 下载完成后,确认文件是真实图片而非 HTML 错误页:若某张图片大小 < 1KB,很可能是下载失败返回了 HTML 错误页,需重新下载
- **图片内容正确性只能在渲染后验证**:生成 DSL 并本地渲染 PNG 后,必须查看渲染结果,确认每张图片内容与主题相关(如宠物主题的图片确实是宠物,而非建筑/风景等不相关内容)
- 若发现图片内容与主题不符,必须用更精确的关键词重新下载并重新上传
### 3. 上传到目标画板
**必须**使用 `docs +media-upload --parent-type whiteboard` 上传:
```bash
lark-cli docs +media-upload --file ./photo1.jpg --parent-type whiteboard --parent-node <whiteboard_token>
# 响应: { "file_token": "<media_token>", ... }
```
逐张上传,收集每个 media token:
```bash
lark-cli docs +media-upload --file ./photo1.jpg --parent-type whiteboard --parent-node <whiteboard_token> # → <media_token_1>
lark-cli docs +media-upload --file ./photo2.jpg --parent-type whiteboard --parent-node <whiteboard_token> # → <media_token_2>
lark-cli docs +media-upload --file ./photo3.jpg --parent-type whiteboard --parent-node <whiteboard_token> # → <media_token_3>
```
### 4. 在 DSL 中引用
```json
{ "type": "image", "id": "img-1", "width": 240, "height": 160, "image": { "src": "<media_token_1>" } }
```
## 常见错误
| 错误现象 | 原因 | 解决 |
|---------|------|------|
| 画板 API 返回 500(2891001) | 使用了非 `whiteboard` 域 token(如 `docx_image`、Drive file token) | 下载图片后用 `docs +media-upload --parent-type whiteboard` 重新上传 |
| 画板 API 返回 500 | 图片上传到了其他画板 | 重新上传到目标画板 |
| 画板在文档中图片消失 | 图片 token 的资源域与画板不匹配 | 确保图片通过 `--parent-type whiteboard --parent-node <画板token>` 上传 |
| 图片裂开/无法显示 | token 无效或已过期 | 重新上传获取新 token |
| 图片内容与主题无关 | 使用了随机占位图服务 | 改用免费版权图库服务 |
elements/layout.md›
# 布局系统
## 布局决策
> 不要靠关键词猜布局。先分析信息结构,再决定布局策略。
> 本文件负责说明通用布局原则与骨架模板;字段语义看 `elements/schema.md`,完整场景范式看各 `scenes/*.md`。
总原则:**先定主布局,再定子布局。**
**快速判断**:
- **Flex**:按层分、按区排
- **Dagre**:关系网密、流程链主导
- **绝对定位**:空间位置承载信息(地理方位、拓扑坐标、物理面板等),用脚本计算坐标
- **默认选择**:拿不准时优先用 **Flex**
**Dagre 版式统一原则**:
1. Dagre 解决的是**拓扑关系**,不是自动把画布铺满。
2. Dagre 作为子容器嵌套时,默认是不透明节点(Opaque Node),先根据内部拓扑计算自身包围盒,再作为原子节点参与父层布局。若需连线穿透边界,须声明 `layout: "dagre"` + `layoutOptions: { isCluster: true }`。
3. 混合布局时,Flex 更适合负责分区与层次,Dagre 更适合负责局部复杂关系;但如果 Dagre 本身就是主布局,也完全可以直接承担整张图的主体拓扑。
4. 选用 Dagre 前先看三件事:**最长链路方向、分支是否对称、是否有长回边/重试回路**。哪一项失衡,哪一项就会把包围盒撑歪。
5. 长回边、失败重试、跨层返回等关系,优先收敛到局部;必要时拆成局部流程区或旁路说明,不要让一条边把整个 Dagre 宽度拉爆。
6. 若 Dagre 产物在父容器中出现明显单侧留白、宽高失衡或内容只占很小一部分,必须调整 `rankdir`、重构拓扑,或在父层补充对称信息区,不能原样交付。
**读代码画架构图**:扫目录结构(按层分 → Flex;按功能模块分 → 看依赖方向)→ grep import(单向→Flex;网状→ Dagre 或 Flex + Dagre)→ 拿不准 → 默认 Flex。
> **flex 容器内的 `x/y` 会被完全忽略!**
❌ 致命错误:
```json
{ "type": "frame", "layout": "vertical", "children": [
{ "type": "rect", "x": 100, "y": 0, "text": "成都" },
{ "type": "rect", "x": 540, "y": 0, "text": "康定" }
]}
```
✅ 正确:用 `layout: "none"` 或放在顶层 nodes 用 x/y。
> **`layout: "none"`(绝对定位)的容器必须有明确的固定宽高!**
❌ 致命错误:
```json
{ "type": "frame", "layout": "none", "width": "fit-content", "height": "fit-content", "children": [
{ "type": "rect", "x": 0, "y": 0, "text": "区域A" },
{ "type": "rect", "x": 500, "y": 0, "text": "区域B" }
]}
```
✅ 正确:必须给绝对定位容器明确的固定宽高:
```json
{ "type": "frame", "layout": "none", "width": 1064, "height": 680, "children": [
{ "type": "rect", "x": 0, "y": 0, "text": "区域A" },
{ "type": "rect", "x": 554, "y": 0, "text": "区域B" }
]}
```
**构建方式**:
| 布局类型 | 做法 |
| ---------------------- | ----------------------------------------------------------------------------- |
| 纯 Flex / Dagre | 直接写 JSON |
| 混合布局 (Flex包Dagre) | 直接写 JSON(外层先做分区,局部复杂关系交给 Dagre;若被嵌套,默认为不透明节点) |
| 极度依赖几何坐标的图 | 写脚本生成 JSON(node xxx.cjs) |
| 需要精确避让的特殊线 | 脚本 + `--layout` 两阶段 |
---
## 网格方法论
核心理念:**先画网格,再填内容**。
先回答三个问题:
1. **信息分几行几列?** 每组一行或一列
2. **每格多大?** 等宽还是有主次?
3. **行列间距多大?** 分区间 24-32px,同区内 12-16px
---
## 布局模式选择
| 模式 | 适用场景 | DSL 映射 |
| ---- | ---------------------------- | -------------------------------------------------------- |
| grid | 架构图、对比表、卡片墙、看板 | vertical frame 嵌套 horizontal frame |
| flow | 复杂流程图、微服务交互 | `layout: "dagre"`,由引擎自动计算网状连线排版 |
| tree | 组织架构、模块依赖 | `layout: "dagre"` 配 `rankdir: "TB"` 或根节点居中的 Flex |
| free | 地理位置布局、物理面板还原 | `layout: "none"` + x/y |
大多数图表用 grid 或 flow 模式。只有节点坐标本身有强语义(如地图)时才用 free。
> 以上都是布局策略名称,DSL 的 `layout` 属性值只支持 `'horizontal'`、`'vertical'`、`'none'`、`'dagre'` 四种。
---
## DSL 与 CSS Flexbox 属性映射
| DSL 属性 | 对应的 CSS 心智模型 | 限制 |
| -------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------- |
| `layout: 'horizontal'` | `flex-direction: row` | 不写 layout = 绝对定位 |
| `layout: 'vertical'` | `flex-direction: column` | 同上 |
| `layout: 'none'` | `position: absolute`(子节点用 x/y) | 子节点不能用 `fill-container`;容器必须有固定宽高 |
| `layout: 'dagre'` | 类似 Mermaid / DOT 的有向图布局 | 宽高只支持 `fit-content`;先按拓扑算包围盒再参与父层布局;嵌套时默认为不透明节点 |
| `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。
错误示例:
```json
{ "type": "frame", "layout": "horizontal", "width": "fit-content", "children": [
{ "type": "rect", "width": "fill-container" }
]}
```
正确示例:
```json
{ "type": "frame", "layout": "horizontal", "width": 1200, "children": [
{ "type": "rect", "width": "fill-container" }
]}
```
3. **不要给 Dagre 套固定宽高的外框**:Dagre 产物尺寸由拓扑决定,无法提前预知。父容器应使用 `fit-content` 自适应,或直接让 Dagre 作为顶层容器,不要用固定像素框住它。
4. **`layout: 'none'` 的容器必须有固定宽高**,不要写成 `fit-content`,否则子节点绝对定位容易错乱。
5. **含文字节点高度用 fit-content**,引擎不支持 overflow,写死高度会截断文字。
6. **Shape 节点有内边距**:rect/ellipse/diamond/triangle 各边 12px;cylinder 垂直 +42px。
7. **不支持 flex-wrap**,需要换行时用嵌套 frame 模拟。
8. **图层顺序**:数组中越靠后的节点层级越高。需要叠加标注时放在数组最后。
---
## 布局选择指南
| 你要表达的关系 | 怎么排 | DSL 写法 |
| -------------------------- | ------------------------ | ---------------------------------------------------------------------------- |
| 先后顺序、层级从上到下 | 纵向堆叠 | `layout: 'vertical'` |
| 并列、同等重要、可对比 | 横向等分 | `layout: 'horizontal'` + `alignItems: 'stretch'` + `width: 'fill-container'` |
| 区域有名称,名称在侧边 | 侧标签 + 内容并排 | 横向 frame: [text(标签), frame(内容)] |
| 多个大分区,各自独立 | 分区纵向排列 | 纵向 frame 包多个彩色 frame |
| 一行放不下,需要换行 | 嵌套横向 frame 模拟换行 | 纵向 frame 包多个横向 frame |
| 复杂的网状关系、拓扑图 | **Dagre 有向图自动布局** | `layout: 'dagre'` + `layoutOptions.edges` |
| 节点位置本身有含义(地图) | 绝对定位 | `layout: 'none'` + x/y |
这些可以自由嵌套组合。比如:纵向堆叠(标题) + 分区纵向排列(多个层) + 每个层内横向等分(节点)。
---
## 布局示例
### 纵向堆叠(标题 + 内容)
```json
{
"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" },
...内容...
]
}
```
### 横向等分(并列元素)
```json
{
"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'` = 等宽等高。
### 侧标签 + 内容
```json
{
"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 文件的色板选取):
```json
{
"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:
```json
{
"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] }
]
}
```
## 复杂拓扑混合布局 (Dagre + Flex)
当你在处理**连线众多、关系杂乱的拓扑图 / 链路流程图 / 复杂架构图**时,不用手动去算每个节点坐标,优先考虑 **Flex + Dagre 的混合布局策略**。这主要包含两种维度的嵌套:
* **外层 Dagre + 内层 Flex(复杂节点)**:**这是最推荐的复杂架构画法**。整图拓扑交由 `layout: "dagre"` 自动计算并顺滑布线,而图中的节点不再只是单调的矩形,可以是一个用 Flex 自由拼装的复杂 `frame` 卡片(包含图标、主次标题、状态等),让节点承载更丰富的信息。
* **外层 Flex + 内层 Dagre(局部流程)**:外层用 Flex 或绝对定位划分大的业务区域,而某个特定区域内部放入 `layout: "dagre"` 容器负责处理局部的业务流。
* **嵌套前先做宽度预判**:Dagre 会根据拓扑尽情往两侧撑出包围盒。如果可能横跨导致溢出,优先改 `rankdir` 为 `TB`、缩短文案、调小 `nodesep/ranksep`,必要时将超长的链路拆成分步区。
```json
{
"type": "frame", "id": "arch_root",
"layout": "dagre", "padding": 40,
"width": "fit-content", "height": "fit-content",
"layoutOptions": {
"rankdir": "LR", "nodesep": 60, "ranksep": 100,
"edges": [
["client", "auth_svc", "request"],
["auth_svc", "order_svc"],
["order_svc", "order_db"]
]
},
"children": [
{
"type": "frame", "id": "client",
"layout": "vertical", "gap": 6, "padding": [12, 16],
"alignItems": "center",
"fillColor": "#F8FAFC", "borderColor": "#CBD5E1", "borderWidth": 2, "borderRadius": 10,
"children": [
{ "type": "text", "text": "Client App", "fontSize": 14, "textColor": "#0F172A" },
{ "type": "text", "text": "React 18", "fontSize": 10, "textColor": "#64748B" }
]
},
{
"type": "frame", "id": "cluster_gateway",
"layout": "dagre", "layoutOptions": { "isCluster": true, "clusterTitle": "Gateway Tier", "clusterTitleColor": "#15803D" },
"fillColor": "#F0FDF4", "borderColor": "#86EFAC",
"borderWidth": 2, "borderDash": "dashed", "borderRadius": 16,
"children": [
{ "type": "rect", "id": "auth_svc", "width": 120, "height": 40, "text": "Auth Service", "fillColor": "#DCFCE7", "borderColor": "#86EFAC", "borderWidth": 1, "borderRadius": 6, "fontSize": 12 },
{ "type": "rect", "id": "order_svc", "width": 120, "height": 40, "text": "Order Service", "fillColor": "#DCFCE7", "borderColor": "#86EFAC", "borderWidth": 1, "borderRadius": 6, "fontSize": 12 }
]
},
{
"type": "frame", "id": "order_db",
"layout": "vertical", "gap": 4, "padding": [10, 14],
"alignItems": "center",
"fillColor": "#FFFFFF", "borderColor": "#FECACA", "borderWidth": 2, "borderRadius": 10,
"children": [
{ "type": "cylinder", "width": 50, "height": 36, "fillColor": "#FCA5A5", "borderColor": "#DC2626", "borderWidth": 1 },
{ "type": "text", "text": "Order DB", "fontSize": 12, "textColor": "#7F1D1D" }
]
}
]
}
```
**示例要点**:
- `client` 和 `order_db` 是 **Flex 复合节点**(不透明节点),内部用 vertical 布局组合多行信息,对外层 Dagre 是固定宽高的原子。
- `cluster_gateway` 是 **透明子图**(`layout: "dagre"` + `isCluster: true`),外部连线可穿越边界直达 `auth_svc` 和 `order_svc`。
- 所有 `edges` 统一写在最外层根 Dagre 的 `layoutOptions` 中。
**Dagre 嵌套排版规则**:
1. **不透明节点(Opaque Node)**:Dagre 内的子容器,无论其内部 layout 是 flex、absolute 还是 dagre,只要未声明 isCluster: true,对外层 Dagre 就是具有确定宽高的不透明原子节点。外层连线无法寻址其内部子节点。
2. **连线兜底重定向(Edge Redirect Fallback)**:当 edges 引用了某不透明节点内部的子节点 ID 时,引擎自动将该连线端点重定向至其最近的不透明祖先节点。不报错,不产生悬空连线。
3. **透明子图(Compound Cluster)**:子容器同时声明 `layout: "dagre"` 与 `layoutOptions: { isCluster: true }` 时,成为外层 Dagre 的复合子图。其内部子节点直接参与外层拓扑运算,连线可穿越子图边界。子图自身不执行独立排版,尺寸由外层 Dagre 根据内部节点包围盒自动撑开。
---
## 绝对定位
当节点位置本身有含义(拓扑图、地图、时间线轴)时用绝对定位。大多数图表优先用 Flex。
### 混合布局
模块内部用 Flex 自动排版,模块之间用绝对定位自由摆放。注意:承载这些模块的 `layout: "none"` 父容器必须先给出**固定宽高**,再在里面摆放子模块。
```json
{
"type": "frame", "layout": "none", "width": 1200, "height": 800,
"children": [
{
"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" }
]
}
]
}
```
### 两阶段绘图
先出骨架图导出坐标,再基于坐标补充连线和注解:
```bash
npx -y @larksuite/whiteboard-cli@^0.2.13 -i skeleton.json -o step1.png -l coords.json
```
`coords.json` 包含每个带 id 节点的精确坐标(absX, absY, width, height)。
---
## 常用间距和尺寸
| 参数 | 常用范围 | 说明 |
| ---------------- | ----------- | ------------ |
| 整图宽度 | 1000-1400px | — |
| 分区之间间距 | 24-32px | — |
| 同分区内节点间距 | 12-16px | — |
| 有连线的节点间距 | >= 40px | 给箭头留空间 |
| 分区内边距 | 16-24px | — |
| 侧标签宽度 | 120-180px | — |
---
## 等大卡片
一排卡片需要等宽等高时,不要写固定像素:
```json
{
"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'` = 等宽等高。
elements/schema.md›
# DSL Schema
> 本文件只说明 **DSL 里能写什么**:节点类型、字段、枚举值、硬约束。布局策略、组合方法、Dagre/Flex 心智模型统一放在 `elements/layout.md`。
> `?` 表示该字段在 schema 层是 optional;若需要稳定产出,再参考对应 scene 或 layout 文件中的最佳实践。
**📝 布局引擎核心法则**:
- **基本行为与 Flexbox 等同**:Frame 布局基于 Yoga 引擎。`layout: 'horizontal'` = `flex-direction: row`,`fill-container` = `flex: 1`,`fit-content` = `width: auto`,`gap` / `padding` / `alignItems` / `justifyContent` 语义相同。
- **枚举值无 flex- 前缀**:一律使用 `'start'` / `'end'` 而非原生 CSS 的 `'flex-start'` / `'flex-end'`。
- **默认对齐的差异**:`alignItems` 的默认值是 `'start'`(原生 CSS 默认是 `stretch`)。所以同排卡片需要等高时,**必须显式声名** `alignItems: 'stretch'`。
- **Dagre 引擎的特殊性**:`layout: 'dagre'` 作为专属拓扑连线引擎,自身不支持 `fill-container` 宽高,对其父容器而言,它是一个自适应(打包裹)的黑盒。
## WBDocument
```typescript
interface WBDocument {
version: 2;
nodes: WBNode[]; // 顶层节点。connector 必须放在这里,不能嵌套在 children 中
}
```
## 节点类型
### Frame(容器)
唯一可以包含子节点的类型。用于分组、布局、背景。
```typescript
{
type: 'frame';
id?: string;
x?: number; y?: number; // Flex 子节点不需要 x/y
width: WBSizeValue;
height: WBSizeValue;
layout: 'horizontal' | 'vertical' | 'none' | 'dagre'; // 布局模式
gap: number; // 必须显式写(不写节点会粘连,容易出 bug)
padding: number | [number, number] | [number, number, number, number]; // 必须显式写(不写内容贴边)
justifyContent?: 'start' | 'center' | 'end' | 'space-between' | 'space-around';
alignItems?: 'start' | 'center' | 'end' | 'stretch';
layoutOptions?: { // 仅当 layout 为 'dagre' 时生效
rankdir?: 'TB' | 'BT' | 'LR' | 'RL';
nodesep?: number;
edgesep?: number;
ranksep?: number;
edges?: Array<[string, string] | [string, string, string]>; // [fromId, toId, label?] 引擎自动排版子节点并生成贝塞尔曲线连线
isCluster?: boolean; // 透明子图。为 true 时子节点参与父级 Dagre 拓扑运算,连线可穿越边界
clusterTitle?: string; // 子图悬浮标题(自动吸附左上角)
clusterTitleColor?: string; // 标题颜色 (HEX格式,如 "#8B5CF6")
};
fillColor?: string;
borderColor?: string;
borderWidth?: number;
borderDash?: 'solid' | 'dashed' | 'dotted';
borderRadius?: number;
children?: WBNode[]; // 不能包含 connector
}
```
**Dagre 嵌套排版规则**:
1. **不透明节点(Opaque Node)**:Dagre 内的子容器,无论 `layout` 是 `flex`、`absolute` 还是 `dagre`,只要未声明 `isCluster: true`,对外层 Dagre 就是具有确定宽高的不透明原子节点。外层连线无法寻址其内部子节点。
2. **连线兜底重定向(Edge Redirect Fallback)**:当 `edges` 引用了某不透明节点内部的子节点 ID 时,引擎自动将该连线端点重定向至其最近的不透明祖先节点。不报错,不产生悬空连线。
3. **透明子图(Compound Cluster)**:子容器同时声明 `layout: "dagre"` 与 `layoutOptions: { isCluster: true }` 时,成为外层 Dagre 的复合子图。其内部子节点直接参与外层拓扑运算,连线可穿越子图边界。子图自身不执行独立排版,尺寸由外层 Dagre 根据内部节点包围盒自动撑开。
**isCluster 最小用法**:
```json
{
"type": "frame", "id": "cluster_a",
"layout": "dagre", "layoutOptions": { "isCluster": true },
"fillColor": "#F0FDF4", "borderColor": "#86EFAC", "borderWidth": 2, "borderDash": "dashed", "borderRadius": 16,
"children": [
{ "type": "text", "text": "区域标题", "fontSize": 11, "textColor": "#15803D" },
{ "type": "rect", "id": "node_inside", "width": 120, "height": 40, "text": "内部节点" }
]
}
```
> 注意:`edges` 必须写在**最外层的根 Dagre** 的 `layoutOptions` 中,不要写在 cluster 内部。
**其他约束**:
- `layout / gap / padding` 在 schema 层是 optional,但实际生成时推荐显式写出,避免依赖默认行为。
- `layoutOptions` 仅在 `layout: 'dagre'` 时生效。
- `children` 里不能出现 `connector`。
> **虚拟 frame 陷阱**:没有 `fillColor`、`borderColor`、`borderWidth` 的 frame 在编译时可能被当作纯布局容器跳过(子节点直接提升到父级)。如果给这种 frame 设了 `id` 并让外部 connector 连接它,编译后 frame 消失,connector 引用会失效。需要保留这个 frame 时,请给它加上不会被优化掉的外观属性。
### 基础图形
```typescript
{
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 = 56px`
### Image(图片节点)
图片节点用于在画板中展示图片。图片不能直接使用 URL,必须先上传到飞书获取 media token。
```typescript
{
type: 'image';
id?: string;
x?: number; y?: number;
width: WBSizeValue; // 固定宽度,推荐 240 或 200
height: WBSizeValue; // 固定高度,推荐按 3:2 比例(如 240×160 或 200×133)
image: {
src: string; // media token(通过 docs +media-upload --parent-type whiteboard 上传获取)
};
}
```
> **关键约束**:
> - `image.src` 必须是通过 `docs +media-upload --parent-type whiteboard --parent-node <画板token>` 上传后返回的 **media token**,不能是 URL 或 Drive file token
> - 图片必须上传到**目标画板**,跨画板的 token 不可用
> - 同一画板内所有 image 节点应使用统一的 width/height,保持视觉一致
> - 图片宽高比推荐 3:2(如 240×160),避免变形
> - 详细上传流程见 [`elements/image.md`](../elements/image.md)
### Text(纯文本节点)
```typescript
{
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(便签)
```typescript
{
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` 里。
```typescript
{
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';
waypoints?: { x: number; y: number }[]; // polyline 途经点
label?: string; // 连线中间的标签文字
labelPosition?: number; // 标签位置,0-1,默认 0.5(中点)
};
}
```
### SVG
```typescript
{
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 节点叠加:
```json
{
"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 线条:
```json
{ "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>" } }
```
### Icon(内置图标)
引用画板内置图标库的图标。比手写 SVG 更简单——只需指定 `name`。
```typescript
{
type: 'icon';
id?: string;
x?: number; y?: number;
width?: WBSizeValue; // 默认 48
height?: WBSizeValue; // 默认 48,保持正方形
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.13 --icons 输出中选取
color?: string; // 可选颜色覆盖,hex 格式如 '#FF6600'
}
```
**获取可用图标**:规划好内容和布局后,运行以下命令查看所有可用图标名,从中选取:
```bash
npx -y @larksuite/whiteboard-cli@^0.2.13 --icons
```
用法:
```json
{ "type": "icon", "id": "db", "name": "database", "width": 48, "height": 48 }
```
**使用建议**:
- 当图表中的节点代表具体事物(服务器、用户、数据库等)时,用图标比纯文字方块更直观
- 一张图 3-8 个图标为宜,为关键组件配图标,次要节点用普通形状
- 用 `color` 为图标指定合适的颜色, 比如与所在容器的配色一致
- 图标可放在 frame 子元素中参与 flex 布局,连线可通过 id 连接到图标
- 图标+文字组合:frame(vertical) 中放 icon + text,形成富组件
```json
{
"type": "frame", "layout": "vertical", "gap": 8, "padding": 12,
"alignItems": "center", "fillColor": "#F0F5FF", "borderColor": "#ADC6FF",
"children": [
{ "type": "icon", "id": "db-icon", "name": "database", "width": 36, "height": 36 },
{ "type": "text", "text": "PostgreSQL", "fontSize": 12, "width": "fit-content", "height": "fit-content" }
]
}
```
---
## 富文本 WBTextRun
`text` 字段可以是纯字符串或 `WBTextRun[]` 数组。类似 HTML 内联样式:bold 对应 `<b>`,italic 对应 `<i>`,listType 对应 `<ol>/<ul>`。每个 run 是一段带样式的文字:
```typescript
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; // 引用块
}
```
示例:
```json
{
"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 测量文字尺寸)。
elements/style.md›
# 配色系统
## 怎么上色(最重要)
上色步骤:
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 |
---
## 各元素怎么画
> 以下示例使用经典色板。如果选了其他色板,替换对应颜色即可,结构保持不变。
### 图表标题
告诉读者"这张图讲什么"。大号深色文字,居中。
```json
{ "type": "text", "fontSize": 24, "textColor": "#1F2329", "textAlign": "center" }
```
### 分区背景
把相关的内容圈在一起,告诉读者"这些属于同一个大类"。浅色做 fillColor,对应深色做 borderColor。内部放白色节点。
```json
{ "fillColor": "#F0F4FC", "borderColor": "#5178C6", "borderWidth": 2, "borderRadius": 8, "padding": 20 }
```
### 分区标签
给分区一个名字。用独立 text 节点,不要用 frame 的 `title` 属性(会被渲染为极小标题栏)。
**所有分区标签统一用深色文字 `#1F2329`**,不要给每个标签用不同颜色——颜色区分通过层容器背景和边框体现,标签文字颜色保持一致。
```json
{ "type": "text", "width": 180, "height": "fit-content", "text": "Access layer", "fontSize": 20, "textColor": "#1F2329", "textAlign": "right" }
```
### 分组标题
告诉读者"这个子分组叫什么"。色板色填充 + 同色系深色边框。
```json
{ "fillColor": "#EAE2FE", "borderColor": "#8569CB", "borderWidth": 2, "borderRadius": 8, "fontSize": 14, "textColor": "#1F2329" }
```
### 内容节点
具体的信息项。白色填充,边框颜色跟随所属分组。
```json
{ "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)
### 表头
告诉读者"这一列/行是什么维度"。深色填充 + 白色文字。
```json
{ "fillColor": "#1F2329", "borderColor": "#1F2329", "borderWidth": 2, "borderRadius": 0, "fontSize": 15, "textColor": "#FFFFFF", "textAlign": "center" }
```
### 图标组件
icon + text 的组合卡片。icon 的 `color` 跟随所属分组的 borderColor,与其他节点视觉一致。
```json
{
"type": "frame", "layout": "vertical", "gap": 4, "padding": 12,
"alignItems": "center", "fillColor": "#FFFFFF", "borderColor": "#5178C6", "borderWidth": 2, "borderRadius": 8,
"children": [
{ "type": "icon", "name": "server", "width": 36, "height": 36, "color": "#5178C6" },
{ "type": "text", "width": "fit-content", "height": "fit-content", "text": "应用服务器", "fontSize": 12 }
]
}
```
icon color 需要结合上下文选择合适的颜色, 比如: 使用所属分组的borderColor
### textColor 规则
```
- 正文:#1F2329(深色,在白底/浅色底上清晰)
- 辅助说明:#646A73(弱化,不抢注意力)
- 深色底上:#FFFFFF(反色,清晰可读)
(以上为经典色板的值,其他色板参考对应 textColor 列)
```
### 辅助说明
补充信息,不抢主角的注意力。灰色小字。
```json
{ "fontSize": 13, "textColor": "#646A73" }
```
### 连线
表达元素之间的关系或流向。使用色板中的连线色。
```json
{ "lineColor": "#BBBFC4", "lineWidth": 2 }
```
### 布局容器
纯粹用来排版的 frame,读者看不见它。不设 fillColor、borderColor。
```json
{ "type": "frame", "layout": "vertical", "gap": 28, "padding": 32 }
```
### 分组容器
用虚线框圈定一组节点,比分区背景更轻量。
```json
{ "borderColor": "#DEE0E3", "borderWidth": 2, "borderDash": "dashed", "borderRadius": 8 }
```
---
## 常见错误
错误:每个节点一种颜色 -> 读者分不清谁和谁是一组
```json
{ "fillColor": "#8569CB" }, { "fillColor": "#5178C6" }, { "fillColor": "#509863" }
```
正确:同组节点视觉一致 -> 读者一眼看出关系
```json
{ "fillColor": "#FFFFFF", "borderColor": "#8569CB" }, { "fillColor": "#FFFFFF", "borderColor": "#8569CB" }
```
错误:内外层都用重色 -> 读者不知道先看哪里
```json
{ "type": "frame", "fillColor": "#5178C6", "children": [{ "fillColor": "#8569CB" }] }
```
正确:外层浅色内层白色 -> 读者先看结构再看细节
```json
{ "type": "frame", "fillColor": "#F0F4FC", "children": [{ "fillColor": "#FFFFFF", "borderColor": "#5178C6" }] }
```
错误:连线用和节点一样的彩色 -> 和节点颜色抢注意力
```json
{ "connector": { "lineColor": "#5178C6" } }
```
正确:连线用色板中的连线色 -> 衬托节点
```json
{ "connector": { "lineColor": "#BBBFC4" } }
```
错误:节点没边框 -> 和背景融为一体,看不清边界
```json
{ "fillColor": "#FFFFFF" }
```
正确:节点有边框 -> 边界清晰
```json
{ "fillColor": "#FFFFFF", "borderColor": "#DEE0E3", "borderWidth": 2 }
```
错误:全图黑白灰,没有颜色区分 -> 读者无法快速识别分组
```json
{ "fillColor": "#FFFFFF", "borderColor": "#DEE0E3" }
```
正确:不同分组用不同颜色 -> 一眼看出结构(蓝色分组 + 紫色分组)
```json
{ "fillColor": "#F0F4FC", "borderColor": "#5178C6" }
{ "fillColor": "#EAE2FE", "borderColor": "#8569CB" }
```
elements/typography.md›
# 排版规则
## 字号层级表
| 层级 | 字号 | 用途 | 对齐 |
|------|------|------|------|
| 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:
```json
{
"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 }
]
}
```
---
## 图标+文字组合
icon + text 纵向排列时:icon 宽高 36-48px,下方文字 fontSize 12-13,外层 frame gap 4-8。icon 比文字大 2-3 倍时视觉比例最佳。
---
## 尺寸规则
含文字节点 `height` 必须用 `'fit-content'`。写死高度会截断文字。
所有节点必须显式声明 `width` 和 `height`。
references/lark-whiteboard-export.md›
# whiteboard +export(导出画板)
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
导出画板内容,支持导出为预览图片、SVG 矢量图、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
## 参数
| 参数 | 必填 | 说明 |
|----------------------|----|------------------------------------------------------------------------|
| `--whiteboard-token` | 是 | 画板 token,需要拥有画板的读权限 |
| `--output-type` | 是 | 输出格式:`preview`(预览图片)、`svg`(SVG 矢量图)、`source`(PlantUML/Mermaid 代码)、`raw`(OpenAPI 原生画板节点格式) |
| `--output` | 否 | 输出路径。当 `--output-type preview` 时必填;当 `--output-type svg/source/raw` 时可选,不填则直接输出到终端 |
| `--overwrite` | 否 | 覆盖已存在的文件,默认为 false |
## 输出格式
- `preview`:预览图片。保存时会根据接口实际返回的 `Content-Type` 决定扩展名,例如 `image/jpeg` 会保存为 `.jpg`。
- `svg`:导出画板为标准 SVG 矢量图。可用于 SVG 编辑后回写画板(见 [`routes/svg-edit.md`](../routes/svg-edit.md))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
- `source`:PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [§ 编辑 Workflow](lark-whiteboard-workflow.md#编辑-workflow)。
- **需编辑后回写时,导出务必加 `--output <file>` 写入文件**:文件内容可直接作为 `+update` 的输入;直接输出到终端的结果会多一层 `{ ok, identity, data }` 包装,`+update` 无法解析。
## 示例
### 示例 1:导出画板为预览图片
```bash
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output-type preview \
--output ./preview
```
### 示例 2:提取画板中的代码并直接输出
```bash
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output-type source
```
### 示例 3:导出画板为 SVG 矢量图
```bash
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output-type svg \
--output ./whiteboard.svg \
--as user
```
### 示例 4:导出画板原始节点结构到文件
```bash
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output-type raw \
--output ./nodes.json \
--overwrite
```
references/lark-whiteboard-update.md›
# whiteboard +update(更新画板)
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
更新画板内容,支持四种输入格式:
- `raw`:飞书 OpenAPI 原生画板节点格式,不推荐直接编辑。
- `plantuml`:PlantUML 代码
- `mermaid`:Mermaid 代码
- `svg`:SVG 文本
输入内容可以通过管道从 stdin 读取,或通过 `--source` 指定文件。
## 参数
| 参数 | 必填 | 说明 |
|----------------------|----|--------------------------------------------|
| `--whiteboard-token` | 是 | 画板 token,需要拥有画板的编辑权限 |
| `--idempotent-token` | 否 | 幂等 token,确保更新操作幂等;最少 10 个字符,建议使用时间戳 + 场景标识拼接(如 `1744800000-board-1`)。同一次逻辑更新只生成一次该 token,重试时须原样复用;切勿在每次重试时重新生成时间戳或幂等 key,否则会重复写入 |
| `--overwrite` | 否 | 写入模式:带上则覆盖更新(写入前删除画板所有现有内容再写入);省略则为增量追加(保留原有内容,新内容叠加写入)。默认 false(增量追加)|
| `--source` | 是 | 输入画板内容,支持使用 `@path` 从文件读取,或 `-` 从 stdin 读取 |
| `--input_format` | 否 | 输入格式:`raw`、`plantuml`、`mermaid`、`svg`,默认为 `raw` |
### 以 raw (OpenAPI 原生画板节点格式) 创作
**不要以直接生成 json 语法的方式创作 raw 格式的飞书 OpenAPI 原生画板节点参数**
思维导图,时序图,类图,饼图,流程图等图表推荐使用 Mermaid/PlantUML 语法绘制。
而当需要绘制架构图,组织架构图,泳道图,对比图,鱼骨图,柱状图,折线图,树状图,漏斗图,金字塔图,循环/飞轮图,里程碑或其他较为复杂的图表时,推荐参考 [§ 渲染 & 写入画板](lark-whiteboard-workflow.md#渲染--写入画板) 使用 whiteboard-cli 工具创作。
## 示例
### 示例 1:使用 PlantUML 代码更新画板(从 stdin 读取)
```bash
# 编写 PlantUML 代码
cat > diagram.puml << 'EOF'
@startuml
Alice -> Bob: Hello
Bob -> Alice: Hi
@enduml
EOF
# 通过管道传递给命令
cat diagram.puml | lark-cli whiteboard +update \
--whiteboard-token <画板Token> \
--input_format plantuml --source -\
--overwrite --as user
```
### 示例 2:使用 Mermaid 代码更新画板(从文件读取)
```bash
# 编写 Mermaid 代码
cat > diagram.mmd << 'EOF'
graph TD
A[开始] --> B{判断}
B -->|是| C[处理]
B -->|否| D[结束]
C --> D
EOF
# 从文件读取并更新
lark-cli whiteboard +update \
--whiteboard-token <画板Token> \
--input_format mermaid \
--source @./diagram.mmd \
--overwrite --as user
```
### 示例 3:使用 whiteboard-cli 生成 OpenAPI 格式并写入画板
whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](lark-whiteboard-workflow.md#渲染--写入画板)
```bash
# 使用 whiteboard-cli 生成 OpenAPI 格式并通过管道传递
npx -y @larksuite/whiteboard-cli@^0.2.13 -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <画板Token> \
--source - --input_format raw \
--idempotent-token <10+字符唯一串> \
--as user
```
### 示例 4:先生成产物文件,再从文件读取更新
whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](lark-whiteboard-workflow.md#渲染--写入画板)
```bash
# 生成 OpenAPI 格式到文件
npx -y @larksuite/whiteboard-cli@^0.2.13 -i <DSL 文件> --to openapi --format json -o ./temp.json
# 从文件读取并更新
lark-cli whiteboard +update \
--whiteboard-token <画板Token> \
--idempotent-token <10+字符唯一串> \
--input_format raw \
--source @./temp.json \
--overwrite --as user
```
### 示例 5:使用 SVG 写入画板(从文件读取)
适用于从零创建(直接写入 SVG)和编辑现有画板(编辑工作流详见 [`../routes/svg-edit.md`](../routes/svg-edit.md))。
```bash
# 编写或导出 SVG 文件
cat > diagram.svg << 'EOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100">
<rect x="10" y="10" width="80" height="40" fill="#4A90E2"/>
<text x="50" y="35" text-anchor="middle" fill="#fff">Hello</text>
</svg>
EOF
# 从文件读取并更新
lark-cli whiteboard +update \
--whiteboard-token <画板Token> \
--input_format svg \
--source @./diagram.svg \
--overwrite --as user
```
references/lark-whiteboard-workflow.md›
# 画板创作/编辑工作流
## 创作 Workflow
> 此 workflow 用于**独立创作一个画板**。
> 需要在文档中批量创建多个画板时,由 lark-doc 负责调度,见 `lark-doc` 技能的 `references/lark-doc-whiteboard.md`。
**Step 1:获取 board_token**
| 用户给了什么 | 怎么获取 |
|---|---|
| 直接给了 whiteboard token(`wbcnXXX`)| 直接使用 |
| 文档 URL 或 doc_id,文档中已有画板 | `lark-cli docs +fetch --doc <URL> --as user`,从返回的 `<whiteboard token="xxx"/>` 提取 |
| 文档 URL 或 doc_id,需要新建画板 | `lark-cli docs +update --doc <doc_id> --command append --content '<whiteboard type="blank"></whiteboard>' --as user`,从响应 `data.new_blocks[0].block_token` 取得(`block_type == "whiteboard"` 的那条;参数详见 lark-doc SKILL.md)|
**Step 2:渲染 & 写入**
→ 进入 **[§ 渲染 & 写入画板](#渲染--写入画板)** 章节,按流程完成后直接返回结果给用户。
---
## 编辑 Workflow
**Step 1:获取 board_token**(同创作 Workflow Step 1)
**Step 2:探测可编辑性 / 是否由代码绘制**
- `+export --output-type source` — 能返回单一 Mermaid/PlantUML 源码,说明画板由代码绘制、可走路径①;返回无代码/多图则走路径②③④
**Step 3:选编辑路径**(按上到下匹配,命中即停;用户有明确指定则以用户为准)
| 路径 | 命中条件 | 怎么改 | 写入方式 | 是否有损 |
|---|---|---|---|---|
| ①源码重构 | `+export source` 返回单一 Mermaid/PlantUML(即画板由代码绘制) | 在源码上改 → 按源码类型用 `+update --input_format mermaid` 或 `+update --input_format plantuml` | overwrite(整板重建) | ⚠️ **非严格无损,执行前确认** |
| ②属性微调 | 只改已有节点的文字/颜色 | `+export --output-type raw --output <file>`(**必须写入文件**)→ 编辑文件中目标节点字段;如只能用 `+update --input_format raw --source @<file> --overwrite` 写回,先说明会整板重建并等待用户确认 | overwrite(整板重建) | ⚠️ **有损风险,未确认不得执行** |
| ③增量追加 | 在原图基础上新增图/元素,保留原内容 | `+export --output-type preview` → 理解原图 → `+export --output-type raw` → 确定新节点坐标 → [§ 渲染 & 写入画板](#渲染--写入画板) 创作&写入 | append(**不加 `--overwrite`**) | 无损(原节点不动) |
| ④结构重绘 | 需几何变动/增删元素/结构调整/混合编辑 | [`../routes/svg-edit.md`](../routes/svg-edit.md) | overwrite(清空重来) | ⚠️ **有损,必须先经用户确认** |
**⚠️ 止损**:一条路径最多试 2 轮,不行就换条路径尝试一次;仍不行就停下,如实告诉用户卡点,不要在各路径间反复横跳。
---
## 渲染 & 写入画板
### 渲染路由
**先自报身份**:按训练来源,你属于哪一家?`Claude` / `Gemini` / `GPT` / `GLM` / `Doubao 或 Seed` / `Other`。身份决定 SVG / DSL 分流(不同家族对这两种表达语言的能力不均衡),不要跳过这一步。
**然后按当前产物类型 / 图表类型 × 身份选路径**,读对应文件按其完整 workflow 执行(含读 scene 指南、生成内容、渲染审查、交付):
当前产物路由按上到下匹配, 命中即停:
| 图表类型 | 身份 | 路径 |
|--------------------|-------------------------------------|------------------------------------------------|
| 当前要生成/追加的内容包含 @用户提及或图片/配图 | 任何身份 | [`../routes/dsl.md`](../routes/dsl.md) |
| 思维导图、时序图、类图、饼图、甘特图 | 任何身份 | [`../routes/mermaid.md`](../routes/mermaid.md) |
| 鱼骨图、金字塔图、流程图 | `Doubao` / `Seed` | [`../routes/dsl.md`](../routes/dsl.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` / `Doubao` / `Seed` | [`../routes/svg.md`](../routes/svg.md) |
| 其他图表 | `Other` | [`../routes/dsl.md`](../routes/dsl.md) |
> **⚠️ SVG 路径失败回退**:走 `routes/svg.md` 时,碰到以下情况之一 → **丢弃当前 SVG,改读 `routes/dsl.md` 从零重画,不要逐行修补**:
> - 渲染命令直接报错(语法级崩溃,不是 `--check` 的 warn/error)
> - 两轮改写仍无法消除 `--check` 的 `text-overflow` error
> - 目测 PNG 视觉严重错乱(文字大面积溢出、元素重叠压住关键信息、布局整体崩溃)
>
> SVG 源码修补常常引入新 bug,换 DSL 从零重画往往更稳。这是 SVG 路径自由发挥的硬兜底,不要侵入 `routes/svg.md` 的创作流程。
### 产物规范
产物目录:`./diagrams/YYYY-MM-DDTHHMMSS/`(本地时间,不含冒号和时区后缀)。如用户指定路径,以用户为准。
目录内固定文件名:
```
diagram.svg ← SVG 源码(SVG 路径)
diagram.mmd ← Mermaid 源码(Mermaid 路径)
diagram.json ← DSL 源文件(DSL 路径) / OpenAPI JSON(SVG 路径从 diagram.svg 导出)
diagram.gen.cjs ← 坐标计算脚本(仅 DSL 脚本构建方式)
diagram.png ← 渲染结果
```
### 写入画板
写入画板时按最终产物类型选择 `+update --input_format`:
- Mermaid / PlantUML / SVG 产物直接写入时,`--input_format` 取单值 `mermaid` / `plantuml` / `svg`;写入非空已有画板并需要 overwrite 时,先确认会整板重建;SVG 修改已有画板时先走 [`../routes/svg-edit.md`](../routes/svg-edit.md) 的确认 workflow。
- 只有 DSL 产物或已明确需要 OpenAPI 原生节点格式时,才先用 `npx -y @larksuite/whiteboard-cli@^0.2.13 --to openapi --format json` 转换,再用 `raw` 写入。
具体命令示例、`--overwrite`、`--idempotent-token` 和 `--as user/bot` 的使用方式,统一参考 [`whiteboard +update`](./lark-whiteboard-update.md)。
routes/dsl.md›
# DSL 路径
> **这是画板,不是网页。** 画板是无限画布上自由放置元素,flex 布局是可选增强。
## Workflow
```
Step 1: 路由 & 读取知识
- 读对应 scene 指南 — 了解结构特征和布局策略
- 确定布局策略(见下方快速判断)和构建方式
- 读 elements/ 核心模块 — 语法、布局、配色、排版、连线
Step 2: 生成完整 DSL(含颜色)
- 按 content.md 规划信息量和分组
- 按 layout.md 选择布局模式和间距
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.13 --icons` 查看可用图标
- 按 style.md 上色(用户没指定时用默认经典色板)
- 按 schema.md 语法输出完整 JSON
- 连线参考 connectors.md,排版参考 typography.md
注意:部分图形(鱼骨/飞轮/柱状/折线等)要按 scene 指南的脚本模板写 CommonJS 脚本生成 JSON:
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
2. 将脚本保存为 diagram.gen.cjs(必须 .cjs 后缀,脚本用 require() 写,.js 在 ESM 项目下会崩),执行 node diagram.gen.cjs 产出 diagram.json
3. 用产出的 diagram.json 进入 Step 3
Step 3: 渲染 & 审查 → 交付
- 渲染前自查(见下方检查清单)
- 渲染 PNG(仅用于预览验证,不是最终产物):npx -y @larksuite/whiteboard-cli@^0.2.13 -i diagram.json -o diagram.png
- 检查:信息完整?布局合理?配色协调?文字无截断?连线无交叉?
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
- 写入画板:用 whiteboard-cli 将 diagram.json 转换为 OpenAPI 格式并 pipe 给 +update:
npx -y @larksuite/whiteboard-cli@^0.2.13 -i diagram.json --to openapi --format json \
| lark-cli whiteboard +update --whiteboard-token <board_token> \
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
→ 完整 dry-run / 确认流程见 [§ 写入画板](../references/lark-whiteboard-workflow.md#写入画板)
- 交付:向用户报告 board_token 写入成功
```
**布局策略快速判断**(详见 `elements/layout.md`):
先定**主布局**,再定子布局:**结构化信息**优先用 Flex,**关系链路**优先用 Dagre,**灵活定位**用绝对布局。
> **构建方式是强约束**:当 scene 指南要求"脚本生成"时,必须先写脚本(`.cjs`,CommonJS)并用 `node` 执行来产出 JSON 文件。
## 模块索引
### 核心参考(必读)
| 模块 | 文件 | 说明 |
| -------- |----------------------------| ------------------------------- |
| DSL 语法 | `elements/schema.md` | 节点类型、属性、尺寸值 |
| 内容规划 | `elements/content.md` | 信息提取、密度决策、连线预判 |
| 布局系统 | `elements/layout.md` | 网格方法论、Flex 映射、间距规则 |
| 排版规则 | `elements/typography.md` | 字号层级、对齐、行距 |
| 连线系统 | `elements/connectors.md` | 拓扑规划、锚点选择 |
| 配色系统 | `elements/style.md` | 多色板、视觉层级 |
### 场景指南(按类型选读一个)
| 图表类型 | 文件 | 适用场景 |
| ----------- | ------------------------ | -------------------------------------- |
| 架构图 | `scenes/architecture.md` | 分层架构、微服务架构 |
| 组织架构图 | `scenes/organization.md` | 公司组织、树形层级 |
| 泳道图 | `scenes/swimlane.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` | 时间线、版本演进 |
| 流程图 | `scenes/flowchart.md` | 业务流、状态机、带条件判断的链路 |
### 插入 @用户提及 / 图片
| 当前内容包含 | 必读指南 |
|---|---|
| @用户提及 | [`../scenes/mention.md`](../scenes/mention.md) |
| 图片 / 配图 | [`../scenes/photo-showcase.md`](../scenes/photo-showcase.md) |
## 渲染前自查
- [ ] 不同分组用了不同颜色?同组节点样式完全一致?
- [ ] 外层浅色背景、内层白色节点?
- [ ] 所有节点有边框(borderWidth=2)?文字在背景上清晰可读?
- [ ] 连线用灰色(#BBBFC4),不用彩色?
- [ ] frame 都写了 layout 属性?gap 和 padding 都显式设置了?
- [ ] 含文字节点 height 用 fit-content?connector 在顶层 nodes 数组?
## 症状→修复表
| 看到的问题 | 改什么 |
| ------------------ | ----------------------------------- |
| 文字被截断 | height 改为 fit-content |
| 文字溢出容器右侧 | 增大 width,或缩短文字 |
| 节点重叠粘连 | 增大 gap |
| 节点挤成一团 | 增大 padding 和 gap |
| 连线穿过节点 | 调整 fromAnchor/toAnchor 或增大间距 |
| 大面积空白 | 缩小外层 frame 宽度 |
| 文字和背景色太接近 | 调整 fillColor 或 textColor |
| 布局整体偏左/偏右 | 调整绝对定位的 x 坐标使内容居中 |
## 关键约束速查
1. **含文字节点的 height 必须用 `'fit-content'`** — 写死数值会截断文字
2. **`fill-container` 仅在 flex 父容器中生效** — `layout: 'none'` 下宽度退化为 0
3. **`layout: 'none'` 的容器必须有固定宽高** — 不要写成 `fit-content`
4. **connector 必须放在顶层 nodes 数组** — 不能嵌套在 frame children 里
5. **flex 容器内的 x/y 会被完全忽略** — 需要自由定位时用 `layout: 'none'`
6. **Dagre 子容器默认为不透明节点** — 需穿透时声明 `layout: "dagre"` + `layoutOptions: { isCluster: true }`
routes/mermaid.md›
# Mermaid 路径
适用于:思维导图、时序图、类图、饼图、甘特图。
## Workflow
```
Step 1: 读取知识
- 读 scenes/mermaid.md — Mermaid 语法和使用方式
Step 2: 生成 Mermaid
- 按 mermaid.md 的语法编写 .mmd 文件
- 只输出纯 Mermaid 语法文本
Step 3: 渲染验证 & 写入画板 & 交付
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
2. 保存为 diagram.mmd
3. 渲染(仅用于预览验证,PNG 不是最终产物):
npx -y @larksuite/whiteboard-cli@^0.2.13 -i diagram.mmd -o diagram.png
4. 审查 PNG,有问题修改后重新渲染(最多 2 轮)
5. 写入画板:用 whiteboard-cli 将 diagram.mmd 转换为 OpenAPI 格式并 pipe 给 +update:
npx -y @larksuite/whiteboard-cli@^0.2.13 -i diagram.mmd --to openapi --format json \
| lark-cli whiteboard +update --whiteboard-token <board_token> \
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
→ 完整 dry-run / 确认流程见 [§ 写入画板](../references/lark-whiteboard-workflow.md#写入画板)
6. 交付:向用户报告 board_token 写入成功
```
routes/svg-edit.md›
# SVG 编辑路径
通过导出画板的 SVG → 编辑 SVG → 回写画板,实现对已有画板的可视化编辑。
---
## ⚠️ 有损性警告
SVG 导出是**纯视觉快照**,再次导入后画板语义(思维导图层级/表格结构/连线绑定/容器类型/mention/节点 ID/锁定/评论)会丢失。
**保留的信息**:形状几何(位置/大小/路径)、文本内容与基本格式(字号/粗体/斜体/对齐)、填充色/描边色/透明度(线性渐变降级为第一个 stop-color 纯色)、连接器路径形状与箭头样式、`<g>` 嵌套的基本分组关系(≥2 子元素时重建为 DirectFocusGroup)。
---
## Workflow
### 0. 用户确认(强制)
执行任何编辑前,先判断**紧邻的上一条用户消息**是否已明确确认有损编辑:
- **已确认**(含用户主动预授权,如"我知道有损,直接改")→ 直接进入 Step 1,不再重复警告。
- **未确认或回复含糊** → 原样向用户发出下面这句话,**然后立即结束本回合等待回复** —— 同一条消息内不得附带任何导出/编辑/写回命令或工具调用:
> SVG 编辑只保证视觉层面对齐,画板语义(层级/节点类型/思维导图结构/表格结构/连线绑定/容器类型/mention 等)将不可恢复,是否继续?
这是**知情确认**(动手前让用户对语义丢失止损);真正的破坏性写入在 Step 4 还会再经 `--overwrite` dry-run 确认一次,二者职责不同、都不可省。
### 1. 导出当前画板 SVG
```bash
lark-cli whiteboard +export \
--whiteboard-token <TOKEN> \
--output-type svg \
--output <dir>/original.svg \
--as user
```
### 2. 编辑 SVG
在导出的 SVG 上进行修改。参考 [`svg.md` § 画板怎么处理 SVG](./svg.md#画板怎么处理-svg) 了解可识别元素与不支持的装饰特性。
**技术约束**:
- 新增文字必须用 `<text>`(不是 `<path>`),容器宽度留够(CJK ≈ 1em / Latin ≈ 0.6em)
- 避免 `skewX` / `skewY` / `matrix(...)` 变换
- 禁止使用 `<radialGradient>` / `<filter>` / `<pattern>` / `<clipPath>` / `<mask>`
**编辑原则**(区别于从零创作):
- **风格一致**:新增/修改元素应匹配导出 SVG 中已有的配色、字号、线宽、间距风格,不引入突兀的视觉差异
- **最小改动**:只修改用户要求的部分,不主动"优化"或重排无关区域
- **结构稳定**:尽量保留原有 `<g>` 层级结构,避免不必要的重组导致分组关系变化
- **连线协调**:连接器端点绑定已丢失,若移动了形状,必须手动同步调整视觉上连接到该形状的 connector path 端点坐标,否则连线会"断开"
- **内部引用完整性**:不要随意删改 `<defs>` 中被 `url(#id)` 引用的元素(`<marker>`/`<linearGradient>` 等)或修改其 `id`,否则引用方会失效
### 3. 渲染审查
```bash
# 渲染 PNG 预览
npx -y @larksuite/whiteboard-cli@^0.2.13 -i <dir>/edited.svg -o <dir>/edited.png -f svg
# 几何检查(text-overflow / node-overlap)
npx -y @larksuite/whiteboard-cli@^0.2.13 -i <dir>/edited.svg -f svg --check
```
结合 PNG 视觉效果和 `--check` 报告进行调整,有问题则修改 SVG 后重新渲染(最多 2 轮)。
- SVG 本地渲染预览时,画板中的图片因 session 原因无法正常显示,属于预期内的行为。
### 4. 写回画板
`--overwrite` 会清空原画板内容,确认后再执行
```bash
# dry-run 探测
lark-cli whiteboard +update \
--whiteboard-token <TOKEN> \
--source @<dir>/edited.svg \
--input_format svg \
--idempotent-token <10+字符唯一串> \
--overwrite --dry-run --as user
# 用户确认后执行
lark-cli whiteboard +update \
--whiteboard-token <TOKEN> \
--source @<dir>/edited.svg \
--input_format svg \
--idempotent-token <10+字符唯一串> \
--overwrite --as user
```
routes/svg.md›
# SVG 路径
你在设计一张专业的信息图——内容扎实, 美观漂亮, 具有设计感和视觉张力, 不是枯燥的布局和文字堆砌, **不要做的像普通的网页或者千篇一律的模版**
最终交付是**画板跨越重排渲染的节点**(你写 SVG → 画板解析)
**核心心智纠正 (重要)**:
- 大多数 AI 如果只考虑“绝对不报错/完美映射”, 最终给出的都是全篇纯白底色加单层 `<rect>` 的方正卡片网格, 极其死板单调, **这将被视为不及格!**
- **SVG 给你了完全的设计自由**, 请大胆使用你脑内的图标路径 (`<path>`), 连接指引 (`流畅的 <path>`), 各种环境氛围点缀, 大胆一点, 充分信任你的品味, 发挥出你的顶级艺术创造力!
## Workflow
### 1. 想清楚要画什么
- **核心信息是什么?** 能做到一图胜千言, 绝对不要只生成平平无奇的文字表格, 要有设计感
- **内容充实度**:如果用户描述稀疏简略, 利用你的领域知识扩展, 保证信息维度和内容充实, 但不要过度堆砌, 淹没重点
- **视觉层级与隐喻**:这个没有固定的形式, 你自由判断, 比如: 给重要的节点加光环, 加高亮背景;给对比项设计天平或对称结构
### 2. 写 SVG
[!IMPORTANT] 布局, 配色, 信息密度, 装饰物——**全部由你判断**, 打破单调的 `<rect>` 牢笼, 严禁通篇用矩形和文字应付用户
操作边界约束:
- **语言跟随用户**:图表文字的语言与用户 prompt 保持一致, 技术术语用行业里通用的写法, 不机械翻译
- 文字用 `<text>`(不是 `<path>`), 容器宽度留够——画板按 CJK ≈ 1em / Latin ≈ 0.6em 重排
- 连线使用正交折线替代斜直线(`<polyline>` 带水平/垂直折点)视觉效果更好
- 可自由使用 `translate`, `rotate`, `scale`但请尽量避免使用 `skewX` / `skewY` / `matrix(...)` 发生空间级扭曲
### 3. 渲染审查
```
建目录 ./diagrams/YYYY-MM-DDTHHMMSS/ (例:./diagrams/2026-04-15T143022/)
写文件 <dir>/diagram.svg
渲染 npx -y @larksuite/whiteboard-cli@^0.2.13 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
检查 npx -y @larksuite/whiteboard-cli@^0.2.13 -i <dir>/diagram.svg -f svg --check
导出 npx -y @larksuite/whiteboard-cli@^0.2.13 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
```
`npx -y @larksuite/whiteboard-cli@^0.2.13 --check` 检测 `text-overflow` 和 `node-overlap`, 并结合视觉效果(查看 PNG)进行调整
## 画板怎么处理 SVG
画板的 svg-parser 把可识别元素转成可编辑节点, 其余降级为内嵌图片(渲染没问题, 虽然不可编辑, 但是可以正常显示);但非阴影用途的 `<filter>` / `<clipPath>` 等装饰特性画板不支持(见下方⚠️)
**不需要所有元素都可编辑, 但必须避免使用不支持的装饰特性, 且要兼顾可编辑和美观漂亮**
**可识别的元素**
- 形状:`<rect>` / `<circle>` / `<ellipse>` / `<polygon>`
- 连线:`<line>` / `<polyline>` / `<path>`(自动识别为直线 / 折线 / 曲线)
- 文本:`<text>` / `<tspan>` 画板硬编码 Noto Sans SC **文字必须用 `<text>`**
- 分组:`<g>` / `<a>` / `<use>` 引用 `<symbol>`
- 变换:`translate` / `rotate` / `scale` 正常;`skewX` / `skewY` / `matrix(...)` 降级
- 阴影:`<filter>` 里放 `<feDropShadow>` 或标准 drop/inner primitive 链 (`<feGaussianBlur in="SourceAlpha">` + `<feOffset>` + `<feFlood>` + `<feComposite>` + `<feMerge>`), 会被识别成节点阴影, drop 至多 1 个, inner 至多 1 个; 其余 filter 效果不识别
- 渐变:`<linearGradient>` / `<radialGradient>` 在 `<defs>` 中定义, 通过 `fill="url(#id)"` 引用 (载体限 `<rect>` / `<circle>` / `<ellipse>` / `<polygon>` / `<path>`), 需要至少 2 个 `<stop>`, `gradientUnits` 只支持默认的 `objectBoundingBox` (不写即可);
> [!IMPORTANT]
> ⚠️ **不支持的装饰特性**
- `<pattern>` / `<clipPath>` / `<mask>` / 非阴影用途的 `<filter>` (blur / hue-rotate / 复合合成 / `flood-color=url(...)` / 多个 `<feDropShadow>` 等) → 画板不支持,**请避免使用,否则会导致画板渲染问题**
- 渐变边界:`gradientUnits="userSpaceOnUse"` / `spreadMethod="reflect|repeat"` / stops 少于 2 个 / 复杂 `gradientTransform` 会变成不可编辑图片, 视觉正确但失去可编辑性, 若无必要请沿用默认 `objectBoundingBox`
scenes/architecture.md›
# 系统架构图
适用于:分层架构图、微服务架构图、前后端架构图等有明确模块划分的场景。
## 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 项。不要自作主张添加侧边栏
- 可使用 icon+text 组合更直观的进行内容展示和增强辨识度
- **连线:非必要不画。** 架构图的分层结构本身已表达了调用方向(上层调下层),不需要每对节点都连线。只在需要强调特定调用关系时才画,且总数不超过 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 + 侧边栏)
```json
{
"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" }
]
}
]
}
]
}
]
}
]
}
]
}
```
### 岛屿式(网状互联)
```json
{
"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` 无法计算。
scenes/bar-chart.md›
# 柱状图
## Content 约束
- 数据点 ≤ 12
- 同一数据系列用同一颜色(不要每个柱不同色)
- Y 轴必须有单位标注(如 "万元"、"人次")
## Layout 选型
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.13` 渲染
- **绝对定位手写**:简单柱状图(≤ 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)
```json
{
"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 + 0*300 + 80/2 = 120, bar-1 x = 80 + 1*300 + 40 = 420, bar-2 x = 80 + 2*300 + 40 = 720
## 陷阱
- 单系列用多色(不专业):同一数据系列所有柱体应使用同一颜色
- 缺 Y 轴单位标注,读者无法理解数值含义
- 柱体间距不均匀(脚本需统一计算 barGap)
- Y 轴刻度线和格线误带箭头
- 坐标轴忘记带箭头
此场景必须用 .cjs 脚本生成。Agent 使用时只需修改 `data` 数组,其余坐标与柱体高度全自动计算。
```javascript
const { writeFileSync } = require('fs');
```
scenes/comparison.md›
# 对比图 / 矩阵图
适用于:方案对比、功能矩阵、技术选型等多选项按多维度比较的场景。
## 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 行表格
```json
{
"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"`,固定高度会导致文字截断。
scenes/fishbone.md›
# 鱼骨图(因果图)
> **必须写脚本生成 JSON。** 鱼骨图的分支角度、原因小骨坐标需要三角函数计算,直接手写 JSON 极易导致节点重叠和连线穿模。请用下方脚本模板。
## Content 约束
- 分类 4-6 个
- 每个分类的原因 ≤ 4
- 总原因 ≤ 20(超过必须合并分类)
## Layout 选型
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.13` 渲染
## Layout 规则
- 主干水平居中,从左向右延伸
- 分类节点按 spineX 从左到右排列,奇数(第 1、3、5...)在上方,偶数(第 2、4...)在下方
- 每个分类的原因沿斜线(分支骨)等距排列
- 鱼头(中心问题)在右侧,用 ellipse
- 主干连线带箭头指向鱼头,分支骨和原因小骨连线 endArrow: "none"
- 原因小骨水平延伸到原因框右侧,Y 坐标精准对齐
## 骨架示例
**上下交替**:分类标签按 spineX 从左到右排列,奇数(第1、3、5...)在上方,偶数(第2、4...)在下方。
**视觉同色系**:同一个分支的分类标签、连线及其下的所有原因节点,必须使用同一个色系(如相同的背景色与边框色组合),以保持图形风格统一和逻辑连贯。可以预定义一组颜色数组,按分支轮询使用。
### 坐标计算脚本模板(必须严格参照此算法生成)
以下 Node.js 脚本模板包含了完整的动态布局算法,能够自动适配任意数量的分类和原因,生成完美不重叠的鱼骨图:
```javascript
const fs = require('fs');
const nodes = [];
// 1. 数据定义 (根据用户需求填充)
const categories = [
{ id: "c0", text: "前端代码", reasons: ["未压缩资源", "冗余请求", "超大图片未懒加载"] },
{ id: "c1", text: "后端服务", reasons: ["数据库慢查询", "缓存失效", "并发量过大"] },
{ id: "c2", text: "网络环境", reasons: ["CDN配置错误", "DNS解析缓慢", "带宽限制", "网络抖动"] }
];
// 2. 动态布局计算
const catWidth = 120;
const catHeight = 40;
const reasonWidth = 140; // 调整原因框宽度以适应长文本
const reasonHeight = 32;
const lineLength = 20; // 原因小骨连线的水平延伸长度
const paddingX = 40; // 同侧节点间的水平安全间距
// 预置的分支色系数组(分支骨分类和具体原因保持同一色系)
const branchColors = [
{ fill: "#E8F3FF", stroke: "#1664FF" }, // 蓝色系
{ fill: "#E6FFED", stroke: "#00B42A" }, // 绿色系
{ fill: "#FFF7E8", stroke: "#FF7D00" }, // 橙色系
{ fill: "#FFECE8", stroke: "#F5319D" }, // 粉色系
{ fill: "#F2E8FF", stroke: "#722ED1" }, // 紫色系
{ fill: "#E8FFFF", stroke: "#14C9C9" } // 青色系
];
let maxSpineY_up = 0;
let maxSpineY_down = 0;
// 第一步:计算每个 category 的内部尺寸和相对包围盒
categories.forEach((cat, index) => {
const isTop = index % 2 === 0;
const numReasons = cat.reasons.length;
// 动态计算分支高度,确保原因小骨不会垂直重叠
// 每个原因需要 reasonHeight + 上下间距(约 16)
const requiredY = (numReasons + 1) * (reasonHeight + 16);
const branchDY = Math.max(160, requiredY);
const branchDX = -branchDY * 0.7; // 保持固定的倾斜角度向左延伸
cat.isTop = isTop;
cat.branchDX = branchDX;
cat.branchDY = branchDY;
// 记录最大分支高度,用于计算背景高度和主骨 Y 坐标
if (isTop) maxSpineY_up = Math.max(maxSpineY_up, branchDY + catHeight + 40);
else maxSpineY_down = Math.max(maxSpineY_down, branchDY + catHeight + 40);
// 计算该分类的相对包围盒的极值(相对于 spineX 锚点)
// 最左侧可能由分类框或原因框决定
cat.minX = Math.min(branchDX - catWidth / 2, branchDX - lineLength - reasonWidth);
// 最右侧为主骨挂载点 0 或 分类框右侧
cat.maxX = Math.max(0, branchDX + catWidth / 2);
});
// 第二步:计算每个 category 在主骨上的绝对 X 坐标 (spineX)
let currentSpineX = 100; // 初始偏移
for (let i = 0; i < categories.length; i++) {
const cat = categories[i];
let startX = currentSpineX;
// 需要和上一个同侧的 category 保持距离,防止水平重叠
if (i >= 2) {
const prevSameSideCat = categories[i - 2];
const requiredX = prevSameSideCat.spineX + prevSameSideCat.maxX - cat.minX + paddingX;
startX = Math.max(startX, requiredX);
}
// 确保左侧最长分支不会超出画布左边界
if (startX + cat.minX < 50) {
startX = 50 - cat.minX;
}
cat.spineX = startX;
// 每次略微向前推进,确保异侧节点也能稍微错开
currentSpineX = startX + 80;
}
// 第三步:计算全局画布尺寸
const lastCat = categories[categories.length - 1];
const spineY = maxSpineY_up + 50; // 动态推导主骨 Y 坐标
const totalWidth = lastCat.spineX + 350; // 右侧留出鱼头的空间
const totalHeight = spineY + maxSpineY_down + 50;
// 4. 生成节点数据
// 背景
nodes.push({ type: "rect", x: 0, y: 0, width: totalWidth, height: totalHeight, fillColor: "#FFFFFF", borderWidth: 0 });
// 鱼头
const headWidth = 180;
const headHeight = 80;
const headX = totalWidth - headWidth - 40;
const headY = spineY - headHeight / 2;
nodes.push({ type: "ellipse", id: "head", x: headX, y: headY, width: headWidth, height: headHeight, text: "核心问题" });
// 主骨连线
const firstSpineX = categories[0].spineX + categories[0].minX;
nodes.push({
type: "connector",
connector: { from: { x: firstSpineX, y: spineY }, to: "head", toAnchor: "left", lineShape: "straight", endArrow: "arrow" }
});
// 遍历生成分类和原因小骨
categories.forEach((cat, index) => {
const isTop = cat.isTop;
const branchDY = cat.branchDY;
const branchDX = cat.branchDX;
const color = branchColors[index % branchColors.length];
// 分类标签
const catX = cat.spineX + branchDX - catWidth / 2;
const catY = spineY + (isTop ? -branchDY - catHeight : branchDY);
nodes.push({
type: "rect", id: cat.id, x: catX, y: catY, width: catWidth, height: catHeight, text: cat.text,
fillColor: color.fill, strokeColor: color.stroke
});
// 分支骨连线
nodes.push({
type: "connector",
connector: { from: { x: cat.spineX, y: spineY }, to: cat.id, toAnchor: isTop ? "bottom" : "top", lineShape: "straight", endArrow: "none", lineColor: color.stroke }
});
// 原因小骨
cat.reasons.forEach((reason, rIndex) => {
// 线性插值,均匀分布在分支骨上
const t = (rIndex + 1) / (cat.reasons.length + 1);
const attachX = cat.spineX + branchDX * t;
const attachY = spineY + (isTop ? -branchDY : branchDY) * t;
// 关键对齐:确保原因盒子完全在连线左侧,并且 Y 坐标中心精准对齐
const boxX = attachX - lineLength - reasonWidth;
const boxY = attachY - reasonHeight / 2;
const rId = `${cat.id}-r${rIndex}`;
nodes.push({
type: "rect", id: rId, x: boxX, y: boxY, width: reasonWidth, height: reasonHeight, text: reason,
fillColor: color.fill, strokeColor: color.stroke
});
// 原因小骨连线
nodes.push({
type: "connector",
connector: { from: { x: attachX, y: attachY }, to: rId, toAnchor: "right", lineShape: "straight", endArrow: "none", lineColor: color.stroke }
});
});
});
fs.writeFileSync('diagram.json', JSON.stringify({ version: 2, nodes }, null, 2));
```
## 连线格式与注意点
所有 connector 都用 `{ "type": "connector", "connector": { ... } }` 格式。
**注意:除了主骨外,其他所有连线(分支骨、原因小骨)都必须设置 `"endArrow": "none"`,否则会默认带箭头,导致方向混乱。**
分支骨:从主骨上的绝对坐标点 → 分类标签节点:
```json
{
"version": 2,
"nodes": [
{ "type": "rect", "x": 0, "y": 0, "width": "__totalWidth__", "height": "__totalHeight__" },
{ "type": "ellipse", "id": "head", "x": "__headX__", "y": "__headY__",
"width": 180, "height": 80, "text": "[中心问题]" },
{ "type": "connector", "connector": {
"from": { "x": "__spineStartX__", "y": "__spineY__" },
"to": "head", "toAnchor": "left",
"lineShape": "straight", "endArrow": "arrow"
}},
{ "type": "rect", "id": "c0", "x": "__catX__", "y": "__catY__",
"width": 120, "height": 40, "text": "[分类A]" },
{ "type": "connector", "connector": {
"from": { "x": "__spineX0__", "y": "__spineY__" },
"to": "c0", "toAnchor": "bottom",
"lineShape": "straight", "endArrow": "none"
}},
{ "type": "rect", "id": "c0-r0", "x": "__reasonX__", "y": "__reasonY__",
"width": 140, "height": 32, "text": "[原因1]" },
{ "type": "connector", "connector": {
"from": { "x": "__attachX__", "y": "__attachY__" },
"to": "c0-r0", "toAnchor": "right",
"lineShape": "straight", "endArrow": "none"
}}
]
}
```
上述骨架展示一个分类(上方)+ 一条原因的模式。完整鱼骨图重复此模式,上下交替。每个分类下可有多条原因,均匀插值分布在分支骨上。
## 陷阱
- **代码生成**:必须使用带有动态防重叠算法的脚本来计算坐标并输出 JSON。
- **分支骨防重叠**:同一侧的相邻分支骨和原因框必须没有任何交叉。
- **自适应高度**:原因数量较多时,分支骨自动拉长以容纳所有小骨。
- **原因小骨水平**:原因框右侧的附着点必须与连线起点 Y 坐标一致。
- **无箭头**:所有分类的分支连线、小骨连线均必须关闭箭头。
- **同色系**:同一个分支骨、分类标签节点以及原因小骨节点和连线,必须使用同色系的颜色以保持视觉连贯性。
scenes/flowchart.md›
# 流程图 (Flowchart)
适用于:各种业务流转图、决策树、审批流、时序控制逻辑、带条件判断的链路、系统架构拓扑等。
通用字段语义详见 `elements/schema.md`,通用布局原则详见 `elements/layout.md`;本文件只描述流程图场景下的选型边界与范式。
> [!IMPORTANT]
> **流程图必须走 DSL 路径,不再使用 Mermaid!**
> 复杂分支、判断、回路、跳级关系优先使用 `layout: "dagre"` 计算拓扑;如果只是规整的单线流水线,且卡片强对齐比自动拓扑更重要,也可以使用 Flex + 顶层 `connector` 组合实现。
## 美学规范
- **摒弃简陋节点,推崇全卡片化**:核心业务节点不要只用一个纯文本 `rect`。**应优先采用 Flex 组合卡片**(如:在 `vertical` frame 内上下组合【Emoji 标题项】和【补充说明项】),使得节点信息结构化、层级分明。
- **语义化色彩编排**:节点底色严禁随机分配。必须按状态语义映射:常规链路用浅蓝/浅紫、核心风控/检查用预警黄、成功通过用生命绿、失败熔断用危险红。边框颜色可同色系加深,以凸显卡片边缘。
- **统一判定逻辑**:条件分支必须使用 `diamond` 菱形节点,并且**严禁漏掉** `layoutOptions.edges` 边定义里的第三个标签参数(必须清晰写明"是/否"、"通过/拒绝")。
- **形状多样化**:合理搭配不同形状来表达语义 —— `ellipse` 用于外部实体/起终点、`diamond` 用于判断路由、`rect` 用于业务处理节点、`cylinder` 用于持久化存储。
## Layout 选型
| 模式 | 适用条件 | 核心配置 |
| ---------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| **主体用 Dagre** | 有判断、分支、回路、回退、跳级关系的标准流程图 | 主体 frame 设定 `layout: "dagre"`,按需配置 `rankdir: "TB"` 或 `rankdir: "LR"`。 |
| **局部复合节点** | 流程中的某一步本身是一个小型 UI 组合体 | 外层仍用 `dagre`,复合步骤内部改用 `layout: "vertical"` / `"horizontal"`。此类节点为**不透明节点**,外层连线只能连到外壳。 |
| **透明子图** | 需按业务区域分组,且连线穿越区域边界 | 子容器声明 `layout: "dagre"` + `layoutOptions: { isCluster: true }`,成为透明子图。内部节点直接参与外层拓扑运算。 |
| **规整流水线** | 基本是单线 A → B → C → D,且卡片对齐要求极高 | 主体可用 Flex 排版,连线改用顶层 `connector`;不要为了"自动"而硬上 Dagre。 |
## 核心属性
- **`rankdir`**: `TB`(上下)或 `LR`(左右)。**强烈推荐优先使用 `LR`**,充分利用宽屏横向空间。
- **`edges`**: 在根 Dagre 的 `layoutOptions.edges` 中按 `[fromId, toId, "标签"]` 声明。**支持反向连接**实现闭环。所有 edges 统一写在**最外层根 Dagre**,不要写在 cluster 内部。
- **`ranksep` 与边文本**: 若边上标注了说明文字,**必须根据字数调大间距**:`ranksep = max(60, 字数 × 16)`。
- **自适应尺寸**: dagre 容器**必须**设定 `width: "fit-content"` 和 `height: "fit-content"`。
- **`clusterTitle`**: 透明子图可通过 `clusterTitle` 声明悬浮标题(自动吸附左上角、加粗 14px),搭配 `clusterTitleColor` 指定标题颜色。
## 两种嵌套模式
### 不透明节点(Opaque Node)
Dagre 内的子容器,只要未声明 `isCluster: true`,对外层 Dagre 就是具有确定宽高的原子节点。外层连线无法寻址其内部子节点。适合封装复杂的组合卡片(如带图标、版本号、多行描述的业务模块)。
### 透明子图(Compound Cluster)
子容器同时声明 `layout: "dagre"` 与 `layoutOptions: { isCluster: true }` 时,成为外层 Dagre 的复合子图。其内部子节点直接参与外层拓扑运算,连线可穿越子图边界。适合划分网络区域、功能层级、命名空间等边界容器。推荐搭配 `borderDash: "dashed"` 虚线边框 + 淡色背景。
## 骨架示例(推荐范本)
以下是一个混合架构拓扑的完整示例。它同时展示了**透明子图**(Kubernetes Zone,连线可穿透)和**不透明复合节点**(DB 集群、AI 引擎,连线只能连外壳)的标准写法,以及多种形状(ellipse / diamond / rect / cylinder)和语义化配色规范。
```json
{
"version": 2,
"nodes": [
{
"type": "frame",
"id": "root",
"x": 20, "y": 20,
"layout": "dagre",
"width": "fit-content", "height": "fit-content",
"padding": 60,
"fillColor": "#F8FAFC",
"borderColor": "#CBD5E1",
"borderWidth": 1,
"borderRadius": 16,
"layoutOptions": {
"rankdir": "LR",
"nodesep": 60,
"ranksep": 120,
"edges": [
["user", "k8s_ingress", "HTTPS request"],
["k8s_ingress", "web_pod", "Route UI"],
["k8s_ingress", "api_pod", "Route API"],
["web_pod", "api_pod", "Internal REST"],
["api_pod", "db_cluster", "SQL Query"],
["api_pod", "ai_service", "gRPC Stream"]
]
},
"children": [
{
"type": "ellipse", "id": "user", "text": "Global Users",
"width": 110, "height": 60,
"fillColor": "#E2E8F0", "borderColor": "#64748B", "borderWidth": 1,
"fontSize": 14, "textColor": "#334155"
},
{
"type": "frame", "id": "zone_k8s",
"layout": "dagre",
"layoutOptions": {
"isCluster": true,
"clusterTitle": "☸️ Kubernetes Zone (isCluster)",
"clusterTitleColor": "#2563EB"
},
"fillColor": "#EFF6FF", "borderColor": "#60A5FA",
"borderWidth": 2, "borderDash": "dashed", "borderRadius": 24,
"children": [
{
"type": "diamond", "id": "k8s_ingress", "text": "Nginx Ingress",
"width": 130, "height": 70,
"fillColor": "#DBEAFE", "borderColor": "#3B82F6", "borderWidth": 2,
"textColor": "#1E40AF"
},
{
"type": "rect", "id": "web_pod", "text": "Next.js SSR Pod",
"width": 140, "height": 48,
"fillColor": "#BFDBFE", "borderColor": "#2563EB", "borderWidth": 2,
"borderRadius": 8, "textColor": "#1E3A8A"
},
{
"type": "rect", "id": "api_pod", "text": "Go Lang API Pod",
"width": 140, "height": 48,
"fillColor": "#BFDBFE", "borderColor": "#2563EB", "borderWidth": 2,
"borderRadius": 8, "textColor": "#1E3A8A"
}
]
},
{
"type": "frame", "id": "db_cluster",
"layout": "vertical", "gap": 16, "padding": [20, 24],
"alignItems": "center",
"fillColor": "#F0FDF4", "borderColor": "#22C55E",
"borderWidth": 2, "borderRadius": 16,
"children": [
{
"type": "text", "id": "db_title",
"text": "🗄️ Highly Available DB (不透明)", "fontSize": 14, "textColor": "#14532D"
},
{
"type": "frame", "id": "db_row", "layout": "horizontal", "gap": 20,
"children": [
{
"type": "cylinder", "id": "db_master", "text": "Master",
"width": 80, "height": 50,
"fillColor": "#DCFCE7", "borderColor": "#16A34A", "borderWidth": 1,
"textColor": "#166534"
},
{
"type": "cylinder", "id": "db_replica", "text": "Replica",
"width": 80, "height": 50,
"fillColor": "#DCFCE7", "borderColor": "#16A34A", "borderWidth": 1,
"textColor": "#166534"
}
]
}
]
},
{
"type": "frame", "id": "ai_service",
"layout": "vertical", "gap": 10, "padding": [16, 20],
"alignItems": "center",
"fillColor": "#FAF5FF", "borderColor": "#A855F7",
"borderWidth": 2, "borderRadius": 12,
"children": [
{
"type": "text", "id": "ai_title",
"text": "🧠 Multi-Modal Engine (不透明)", "fontSize": 14, "textColor": "#6B21A8"
},
{
"type": "rect", "id": "ai_version",
"text": "v4.2.1-beta", "width": 90, "height": 22,
"fillColor": "#E9D5FF", "borderColor": "#C084FC", "borderWidth": 1,
"borderRadius": 4, "fontSize": 11, "textColor": "#581C87"
},
{
"type": "text", "id": "ai_desc",
"text": "Includes Vector Store\n& Transformer Blocks",
"fontSize": 12, "textColor": "#7E22CE", "textAlign": "center"
}
]
}
]
}
]
}
```
**范本要点**:
- `zone_k8s` 是**透明子图**(`isCluster: true` + `clusterTitle`),外部连线穿越虚线边界直达 `k8s_ingress`、`web_pod`、`api_pod`。
- `db_cluster` 和 `ai_service` 是**不透明节点**(`layout: "vertical"`),内部用 Flex 组合了多行结构化信息,对外层 Dagre 是固定宽高的原子。连线只能连到外壳 ID。
- 所有 `edges` 统一写在最外层根 Dagre 的 `layoutOptions` 中。
- 本范本中用到了 `ellipse`(外部实体)、`diamond`(路由判断)、`rect`(业务节点)、`cylinder`(数据库存储)四种形状。
## 陷阱与常见报错防范
- **误用 Mermaid**:只要用户没有带 `mermaid` 具体语法代码,哪怕描述明确是"流程图",也**强制使用 DSL 框架下的 Dagre 模式**。
- **重复画线**:`dagre` 里的所有子节点关系通过 `edges` 定义,引擎会自动生成连线。**绝对不要再去外层用 `connector` 节点重复连一次**。
- **穿透黑盒**:普通子容器是不透明节点,外部连线无法直接寻址其内部子节点(引擎会自动重定向至外壳)。若需穿透,必须声明 `layout: "dagre"` 与 `layoutOptions: { isCluster: true }`。
- **`id` 缺失**:只要是在 `edges` 里出现的标识符,`children` 里一定能找到同名 `id` 的节点对应,拼写必须完全一致。
- **宽度灾难**:Dagre 内容器禁止子框使用 `fill-container`,因为 dagre 父容器本身是被内容撑开的。
scenes/flywheel.md›
# 增长飞轮图 (Flywheel)
> **必须写脚本生成 JSON。** 飞轮图需要极坐标计算阶段标签位置和 SVG 圆环切割,直接手写 JSON 无法正确实现同心圆环结构。请用下方脚本模板。
## Content 约束
- 阶段 4-6 个,每阶段短标签(title + 可选 subtitle/desc)
- 中心放置飞轮主题标题
## Layout 选型
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.13` 渲染
## 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`,减小文字字号,确保相邻文本框不互相覆盖
## 骨架示例
此场景必须用 .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 = `<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));
```
## 陷阱
- **中心文字被 SVG 遮挡**:中心文字节点必须在大圆和小圆之后、SVG 之前添加,确保 z-index 正确
- **缺方向指示箭头**:SVG polyline 切割线必须带角度偏转(da 参数),形成顺时针/逆时针箭头感
- **标签位置不对称**:外围卡片必须用极坐标公式 `x = cx + R * cos(θ)` 均匀分布,不可手动摆放
- **外围文字容器死锁**:`layout: 'vertical'` 的 frame 内部 text 节点不能用 `width: 'fill-container'`,必须指定固定 width
scenes/funnel.md›
# 漏斗图 (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)`
## 脚本构建模板
此场景必须用 .cjs 脚本生成。
```javascript
const fs = require('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('diagram.json', JSON.stringify(output, null, 2));
```
## 陷阱
- **不要手写随意递减的宽度**:这会导致漏斗侧边变成折线,不直。必须严格使用上述 `angleK` 公式计算。
- **忘记计算 gap 带来的收缩**:如果下一层的 `width` 只是简单等于上一层的 `topWidth`,在有 gap 的情况下,衔接处会产生锯齿折角。必须减去 `gap * angleK`。
- **vFlip 未设置**:忘记 `"vFlip": false` 会导致梯形朝上翻转,漏斗形状错误
- **文字溢出底层**:底层越窄空间越小,短文案用 `\n` 换行,长文案外置到漏斗旁边(外层套 `layout: "horizontal"` 的 frame,漏斗一侧,说明文字另一侧)
scenes/line-chart.md›
# 折线图
## Content 约束
- 数据点 ≤ 15
- Y 轴必须有单位标注(如 "万元"、"%")
- 折线系列 ≤ 3(超过太密看不清)
## Layout 选型
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.13` 渲染
## 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
```json
{
"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
## 陷阱
- Y 轴范围不合理:若数据集中在 80-120,Y 轴从 0 到 120 会让折线挤在顶部一小段区域,应设 yMin 接近数据最小值
- 缺 Y 轴单位标注,读者无法理解数值含义
- 数据点太密时标注互相遮挡(超过 10 个点考虑隔一个标注一次)
- 折线段忘记设 endArrow: "none",默认带箭头
- 多系列时折线颜色相近难以区分,应使用对比度高的不同色系
此场景必须用 .cjs 脚本生成。Agent 使用时只需修改 `data` 数组,其余坐标与折线生成全自动计算。
```javascript
const { writeFileSync } = require('fs');
```
scenes/mention.md›
# 提及用户 (@用户 / mentionUser)
适用于:文本节点内需要 @ 某个飞书用户(如"负责人:@张三"、"@李四 请跟进")。mention 不是独立节点,而是文本节点富文本中的一段 run,可与普通文字混排。
> 当用户要插入 @用户提及时阅读本页。
## 取值来源(强约束)
- 本页只讲 @用户(mentionUser)。@文档(mentionDoc)暂不支持。
- `mentionUserId` 必须是**真实的飞书用户 open_id**(形如 `ou_xxxxxxxx`)。
- 用户只给出**姓名**时,先用 `lark-contact` skill 把姓名解析成 open_id,再填入 `mentionUserId`。
- **无法解析出真实 open_id 时,停下向用户确认,禁止臆造 id**。假 id 会写入失败或 @ 到错误的人。
## Content 约束(关键)
- 带 `mentionUserId` 的 run,其 `content` **必须非空**,约定填 `"*"`(单字符占位)。
- 原因:转换按字符占位来引用样式,`content` 为空串时该 mention 不会产出任何元素(静默丢失)。
- `content` 的字面内容**不会显示**:画板上显示的是按 open_id 反查到的用户名,不是 `content` 的文字。因此不要把用户名写进 `content`,填单个 `"*"` 即可。
- 一个 run 只能是一种类型:`mentionUserId` 与 `hyperlink` **互斥**,不能同时出现在同一个 run(校验会报错)。需要"链接 + @用户"时拆成两个 run。
## 骨架示例
`text` 用 `WBTextRun[]`,把 @用户 拆成独立 run(`content: "*"` + `mentionUserId`),前后再接普通文字 run:
```json
{
"type": "text",
"width": "fit-content",
"height": "fit-content",
"text": [
{ "content": "负责人:", "fontSize": 14 },
{ "content": "*", "mentionUserId": "ou_xxxxxxxxxxxxxxxx", "fontSize": 14 },
{ "content": " 请本周内跟进", "fontSize": 14 }
]
}
```
写入画板走标准 DSL 路径(`npx -y @larksuite/whiteboard-cli@^0.2.13 -i diagram.json --to openapi --format json | lark-cli whiteboard +update ... --input_format raw`),无需手写 raw JSON。
## 正反例
正确:
```json
{ "content": "*", "mentionUserId": "ou_abc123" }
```
错误(content 空串 → 不产出 @用户):
```json
{ "content": "", "mentionUserId": "ou_abc123" }
```
错误(把用户名写进 content → 多余占位,显示仍由 uid 决定):
```json
{ "content": "@张三", "mentionUserId": "ou_abc123" }
```
错误(与 hyperlink 同 run → 校验报错,须拆两个 run):
```json
{ "content": "*", "mentionUserId": "ou_abc123", "hyperlink": "https://xxx.com" }
```
## 陷阱
- **content 为空**:mention 静默丢失,画板上看不到 @用户。必须填 `"*"`。
- **把用户名写进 content**:无意义,显示名由 open_id 反查决定;且多字符会占用多个字符位。
- **mentionUserId + hyperlink 同 run**:一个 run 只能是一种元素类型,会被校验拦截,须拆成两个 run。
- **用假 id 或用户中文名当 id**:`mentionUserId` 只接受真实 open_id,先经 `lark-contact` 解析。
scenes/mermaid.md›
# Mermaid 图表路径
本场景与 DSL 路径互斥。
| | DSL 路径 | Mermaid 路径 |
|---|---|---|
| 中间格式 | JSON(WBDocument) | Mermaid 文本(.mmd 文件) |
| 布局控制 | 精确控制(x/y 坐标、Flex) | 由 parser-kit 自动布局 |
| 视觉定制 | 完全可控(颜色、字号、圆角等) | 有限(Mermaid 语法) |
| 参考模块 | elements/ + 对应 scene | 仅本文件 |
## 适用条件
满足以下任一条件时使用:
- 用户明确要求 "用 Mermaid" 或 "输出 Mermaid"
- 用户直接粘贴了 Mermaid 语法文本
- 图表类型为思维导图、时序图、类图、饼图(自动路由)
## 思维导图 (Mindmap)
```mermaid
mindmap
root((主题))
分支A
子项A1
子项A2
分支B
子项B1
分支C
```
## 时序图 (Sequence Diagram)
```mermaid
sequenceDiagram
participant A as 浏览器
participant B as 服务器
participant C as 数据库
A->>B: 请求数据
B->>C: 查询
C-->>B: 返回结果
B-->>A: 响应数据
```
消息类型:
- `->>` 实线箭头(同步请求)
- `-->>` 虚线箭头(响应/异步)
- `-x` 带 x 箭头(失败)
## 类图 (Class Diagram)
```mermaid
classDiagram
class Animal {
+String name
+int age
+makeSound()
}
class Dog {
+fetch()
}
Animal <|-- Dog
```
## 饼图 (Pie Chart)
```mermaid
pie title 分布
"类别A" : 40
"类别B" : 30
"类别C" : 20
"类别D" : 10
```
## 流程图 (Flowchart)
> [!WARNING]
> **流程图不推荐使用 Mermaid 路径!**
> 带复杂分支、复合节点、高保真卡片样式的流程图应优先走 **DSL 路径**(参见 `scenes/flowchart.md`)。只有用户明确给出 Mermaid 代码,或场景本身就是极简文字流程时,才走此路径。
适用于:极简的文字节点判断业务流。
```mermaid
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
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Processing : 收到请求
Processing --> Success : 处理成功
Processing --> Failed : 处理失败
Success --> [*]
Failed --> Idle : 重试
```
## 其他支持的图表类型
- **甘特图**:`gantt`
- **ER 图**:`erDiagram`
- **Git 分支图**:`gitGraph`
## 注意事项
- 输出纯 Mermaid 文本,不是 JSON,不要混用 DSL
- 节点文字含特殊字符时用双引号包裹:`A["包含(括号)的文字"]`
- `subgraph` 用于逻辑分组
- Mermaid 的流程图样式较基础,也无法在节点内部嵌套复杂排版;复杂流程优先走 DSL(见 `scenes/flowchart.md`),极简文字流程或用户显式给 Mermaid 代码时再使用 Mermaid。
scenes/milestone.md›
# 里程碑时间线 (Milestone)
## Content 约束
- 节点 4-8 个
- 每节点:标题 + 日期 + 可选描述
- 时间从左到右递增
## Layout 选型
两种方案按需选择:
1. **横向时间线**:horizontal frame,节点等分
2. **交替上下**:绝对定位,节点交替在时间轴上下方(节点多时更紧凑)
## 结构特征
- **标题居中**:顶部放置图表标题
- **年份/时间轴条**:箭头形色块承载年份,按时间从左到右递增
- **里程碑卡片**:下方虚线圆角卡片承载标题与描述
- **严格对齐**:年份条与对应卡片等宽,左右对齐
- **文字层级**:标题加粗在上,描述文字更小更浅在下,居中对齐
## Layout 规则
- 绝对定位为主(`layout: "none"`),节点位置承载时间序列含义
- 先确定里程碑数量,计算等距的 x 坐标序列
- 时间轴用 connector 贯穿所有节点
- 节点与时间轴用短竖线连接
- 节点间水平间距一致
- 年份条宽度 = 卡片宽度,垂直间距统一
- 标题与年份区域保留足够留白
## 骨架示例
```json
{
"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 必须完全一致
scenes/organization.md›
# 组织架构图
适用于:公司组织架构、模块依赖树、分类层级树等树形层级结构的场景。
## Content 约束
- 层级 ≤ 4
- 每个父节点下 ≤ 5 个子节点
- 叶节点有意义(不要只为凑数添加空节点)
- 长文本用 `\n` 手动换行(如"研发负责人\n(CTO)")
## Layout 选型
| 模式 | 适用条件 | 特征 |
|------|---------|------|
| **tree(居中展开)** | 有明确从属关系的层级结构 | 根节点居中,子节点横向排列,逐层展开。每个"父+子"用 vertical frame 包裹(子树模块) |
| **grid(矩阵式)** | 多部门平级,每部门内部有细分 | 横向等分各部门,每部门内部 vertical 列表 |
## Layout 规则
以下规则违反会导致连线错乱或排版崩溃:
1. **子树包裹模式(关键)**:每个父节点和它的子节点群用一个 `layout: "vertical"` + `alignItems: "center"` 的 frame 包裹。**不要**把所有父节点放一层、所有子节点放另一层。*违反后果:父节点与子节点群中心偏移,正交连线无法合并,分裂成两条平行线。*
2. **同层节点建议等高**:同层节点统一 `height`(如 60-70),保证连线横向主轴平直。如果文字长度差异大可用 `fit-content`,但要确保同层文字行数接近。*违反后果:同层节点高低不平,rightAngle 连线横向弯折错乱。*
3. **垂直间距 >= 60**:父子间纵向 `gap: 60`。*违反后果:连线引擎没有足够空间折弯与合并,导致连线穿模或提前分叉。*
4. **叶子容器偶数宽度**:包含叶子节点的横向 frame,宽度应手动计算(子节点宽度之和 + gap × (n-1)),如 2 个 120px 节点 + 20px gap = `width: 260`。或用 `fill-container` 自动等分。*违反后果:父节点中心与子节点群中心有像素级偏差。*
5. **同层兄弟间横向 gap: 20-40**
6. 最小字号 14px
7. 连线:所有父子连线必须 `lineShape: "rightAngle"`(总线风格),`fromAnchor: "bottom"`, `toAnchor: "top"`。*违反后果:失去组织架构图专属的总线视觉效果。*
8. 根 frame 宽度要足够(如 1200-1600),避免叶节点被挤压重叠
9. 不同层级在 fontSize、borderWidth、颜色上递进区分(如 Root 深灰 → L1 浅蓝 → L2 浅绿 → L3 浅紫)
10. 长文本用 `\n` 主动换行(如 "基础架构部\n(包含云原生)"),确保节点高度足够容纳
## 骨架示例
### 树形展开(子树包裹模式)
```json
{
"version": 2,
"nodes": [
{
"type": "frame",
"width": 1200,
"height": "fit-content",
"layout": "vertical",
"gap": 48,
"padding": 40,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "title",
"width": "fill-container",
"height": "fit-content",
"text": "[图表标题]",
"fontSize": 24,
"textAlign": "center",
"verticalAlign": "middle"
},
{
"type": "rect",
"id": "root-node",
"width": 240,
"height": "fit-content",
"borderWidth": 3,
"borderRadius": 8,
"text": "[根节点名]",
"fontSize": 18,
"padding": 12
},
{
"type": "frame",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 40,
"padding": 0,
"alignItems": "stretch",
"children": [
{
"type": "frame",
"width": "fill-container",
"height": "fit-content",
"layout": "vertical",
"gap": 48,
"padding": 0,
"alignItems": "center",
"children": [
{
"type": "rect",
"id": "child-a",
"width": 200,
"height": "fit-content",
"borderWidth": 2,
"borderRadius": 8,
"text": "[子节点名]",
"fontSize": 16,
"padding": 10
},
{
"type": "frame",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 40,
"padding": 0,
"alignItems": "stretch",
"children": [
{ "type": "rect", "id": "leaf-a1", "width": "fill-container", "height": "fit-content", "borderWidth": 1, "borderRadius": 8, "text": "[叶节点名]", "fontSize": 14, "padding": 8 },
{ "type": "rect", "id": "leaf-a2", "width": "fill-container", "height": "fit-content", "borderWidth": 1, "borderRadius": 8, "text": "[叶节点名]", "fontSize": 14, "padding": 8 }
]
}
]
},
{
"type": "frame",
"width": "fill-container",
"height": "fit-content",
"layout": "vertical",
"gap": 48,
"padding": 0,
"alignItems": "center",
"children": [
{
"type": "rect",
"id": "child-b",
"width": 200,
"height": "fit-content",
"borderWidth": 2,
"borderRadius": 8,
"text": "[子节点名]",
"fontSize": 16,
"padding": 10
},
{
"type": "frame",
"width": "fill-container",
"height": "fit-content",
"layout": "horizontal",
"gap": 40,
"padding": 0,
"alignItems": "stretch",
"children": [
{ "type": "rect", "id": "leaf-b1", "width": "fill-container", "height": "fit-content", "borderWidth": 1, "borderRadius": 8, "text": "[叶节点名]", "fontSize": 14, "padding": 8 },
{ "type": "rect", "id": "leaf-b2", "width": "fill-container", "height": "fit-content", "borderWidth": 1, "borderRadius": 8, "text": "[叶节点名]", "fontSize": 14, "padding": 8 }
]
}
]
}
]
}
]
},
{ "type": "connector", "connector": { "from": "root-node", "to": "child-a", "fromAnchor": "bottom", "toAnchor": "top", "lineShape": "rightAngle", "lineWidth": 2 } },
{ "type": "connector", "connector": { "from": "root-node", "to": "child-b", "fromAnchor": "bottom", "toAnchor": "top", "lineShape": "rightAngle", "lineWidth": 2 } },
{ "type": "connector", "connector": { "from": "child-a", "to": "leaf-a1", "fromAnchor": "bottom", "toAnchor": "top", "lineShape": "rightAngle", "lineWidth": 2 } },
{ "type": "connector", "connector": { "from": "child-a", "to": "leaf-a2", "fromAnchor": "bottom", "toAnchor": "top", "lineShape": "rightAngle", "lineWidth": 2 } },
{ "type": "connector", "connector": { "from": "child-b", "to": "leaf-b1", "fromAnchor": "bottom", "toAnchor": "top", "lineShape": "rightAngle", "lineWidth": 2 } },
{ "type": "connector", "connector": { "from": "child-b", "to": "leaf-b2", "fromAnchor": "bottom", "toAnchor": "top", "lineShape": "rightAngle", "lineWidth": 2 } }
]
}
```
## 陷阱
- **分离父子层级(致命错误)**:不要把所有同级父节点放一个 horizontal frame、所有子节点放另一个。必须用 `alignItems: "center"` 的 vertical frame 把每个父节点和它的子节点包裹在一起。
- **同层节点高低不平**:同层节点高度应一致(或文字行数接近),否则 rightAngle 连线横向弯折错乱。
- **垂直间距不足**:父子间 gap 必须 >= 60。不够时连线引擎无法折弯合并。也不要用 80,3-4 层会纵向拉伸过度。
- **做成线性链而非树形展开**:每个父节点的子节点必须横向展开,不要做单链。
- **连线混用 straight**:所有父子连线必须 `lineShape: "rightAngle"`,`fromAnchor: "bottom"`,`toAnchor: "top"`。
- **叶节点字号 12px 看不清**:最小字号 14px。
- **所有节点同一大小和样式**:不同层级必须在 fontSize、borderWidth、颜色上有区分(根>子>叶)。
scenes/photo-showcase.md›
# 图片展示 (Photo Showcase)
适用于:用户**显式要求使用图片/配图/插图**的场景(如"画一个带配图的旅行路线"、"做一个有图片的产品展示")。
> **注意**:仅当用户明确说了「图片/配图/插图/照片」等词时才进入本场景。单纯说"旅行路线图"、"产品展示"等不触发。
> **前置条件**:进入本场景前,必须已完成 [`elements/image.md`](../elements/image.md) 的 Step 0(图片准备),拿到所有 media token。
## Content 约束
- 图片 3-6 张,每张配标题(必需)+ 简短描述(可选,15字内)
- **每张图必须是不同的真实图片**(不同 media token),下载时用不同关键词/URL
- 下载后用 `ls -l` 比较文件大小确保每张图不重复
- 文字仅作辅助说明,图片是信息主体
## Layout 选型
| 模式 | 适用条件 | 特征 |
|------|---------|------|
| **卡片网格(默认)** | 多图平级展示(产品墙、团队介绍、美食推荐) | horizontal frame 内放等尺寸图文卡片 |
| **路线时间线** | 有先后顺序(旅行路线、团建路线、项目演进) | 图文卡片 + connector 串联 |
| **中心辐射** | 有一个核心主题 + 周围子项 | 中心标题 + 周围图文卡片 |
## Layout 规则
- **图文卡片结构**:vertical frame(图上文下),image 宽度 = 卡片宽度,height 按 3:2 比例
- **卡片统一尺寸**:所有卡片宽高一致(推荐 240×280 或 200×250)
- **图片统一尺寸**:所有 image 节点用相同 width/height(推荐 240×160 或 200×133)
- **卡片间距**:gap: 24(比纯文字图表间距更大,让图片呼吸)
- **卡片样式**:白色底 + 圆角 12 + 细边框,image 无圆角(紧贴卡片顶部)
- **有序路线时**:卡片间用 connector 连接,connector 放顶层 nodes 数组
## 骨架示例
### 卡片网格(产品展示/团队介绍/美食推荐)
```json
{
"version": 2,
"nodes": [
{
"type": "frame", "id": "grid", "layout": "vertical", "gap": 24, "padding": 32,
"width": 840, "height": "fit-content",
"children": [
{ "type": "text", "id": "title", "width": 776, "height": 36,
"text": "图表标题", "fontSize": 24, "textAlign": "center" },
{
"type": "frame", "id": "row", "layout": "horizontal", "gap": 24, "padding": 0,
"width": "fit-content", "height": "fit-content",
"children": [
{
"type": "frame", "id": "card-1", "layout": "vertical", "gap": 8, "padding": [0, 0, 12, 0],
"width": 240, "height": "fit-content",
"fillColor": "#FFFFFF", "borderWidth": 1, "borderColor": "#E0E0E0", "borderRadius": 12,
"children": [
{ "type": "image", "id": "img-1", "width": 240, "height": 160, "image": { "src": "<token_1>" } },
{ "type": "text", "id": "t-1", "text": "标题", "fontSize": 14, "width": 216, "height": 20 },
{ "type": "text", "id": "d-1", "text": "简短描述", "fontSize": 11, "textColor": "#666666", "width": 216, "height": 16 }
]
}
]
}
]
}
]
}
```
每张图文卡片结构相同,复制并替换 `<token_N>`、标题和描述即可。3 张卡片一行,超过 3 张换行(嵌套第二个 horizontal frame)。
### 路线时间线(旅行路线/团建路线)
```json
{
"version": 2,
"nodes": [
{
"type": "frame", "id": "route", "layout": "vertical", "gap": 24, "padding": 32,
"width": 1100, "height": "fit-content",
"children": [
{ "type": "text", "id": "title", "width": 1036, "height": 36,
"text": "路线标题", "fontSize": 24, "textAlign": "center" },
{
"type": "frame", "id": "stops", "layout": "horizontal", "gap": 32, "padding": 0,
"width": "fit-content", "height": "fit-content",
"children": [
{
"type": "frame", "id": "stop-1", "layout": "vertical", "gap": 8, "padding": [0, 0, 12, 0],
"width": 240, "height": "fit-content",
"fillColor": "#FFFFFF", "borderWidth": 1, "borderColor": "#E0E0E0", "borderRadius": 12,
"children": [
{ "type": "image", "id": "img-1", "width": 240, "height": 160, "image": { "src": "<token_1>" } },
{ "type": "text", "id": "t-1", "text": "第1站:地点名", "fontSize": 14, "width": 216, "height": 20 }
]
},
{
"type": "frame", "id": "stop-2", "layout": "vertical", "gap": 8, "padding": [0, 0, 12, 0],
"width": 240, "height": "fit-content",
"fillColor": "#FFFFFF", "borderWidth": 1, "borderColor": "#E0E0E0", "borderRadius": 12,
"children": [
{ "type": "image", "id": "img-2", "width": 240, "height": 160, "image": { "src": "<token_2>" } },
{ "type": "text", "id": "t-2", "text": "第2站:地点名", "fontSize": 14, "width": 216, "height": 20 }
]
}
]
}
]
},
{ "type": "connector", "id": "c1", "connector": { "from": "stop-1", "to": "stop-2", "fromAnchor": "right", "toAnchor": "left" } }
]
}
```
注意:connector 必须放在**顶层 nodes 数组**,不能嵌套在 frame.children 内。connector 的属性须包裹在 `connector` 字段中。
## 图片准备检查清单
生成 DSL 前确认:
- [ ] 所有 image 节点的 `image.src` 都是通过 `docs +media-upload --parent-type whiteboard` 上传的 media token(非 URL、非 Drive file token)
- [ ] 所有图片已上传到目标画板(`--parent-node` 设为目标画板 token)
- [ ] 每个 media token 不同(对应不同的真实图片)
- [ ] 所有图片尺寸一致(同一画板内统一 width×height)
- [ ] 图片宽高比合理(推荐 3:2,即 240×160)
- [ ] 渲染 PNG 后查看图片内容,确认每张图片与主题相关
- [ ] 未使用随机占位图服务(关键词参数不影响返回内容的图库)
scenes/pyramid.md›
# 金字塔图 (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。
```javascript
const fs = require('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('diagram.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 节点)放右侧
- **配色**:各层颜色应从色板中选取不同颜色以示区分(如蓝→紫→绿→黄递进)
scenes/swimlane.md›
# 泳道图(Swimlane)
适用于:跨角色/跨系统的端到端流程(用户/网关/服务/存储/回调)、多泳道协作流程、系统交互链路图。
支持两种方向:
- **水平泳道**:泳道为横向条带(自上而下排列),流程从左到右推进
- **垂直泳道**:泳道为纵向列(自左向右排列),流程从上到下推进
## Content 约束
- 泳道数(lanes)建议 3-7,超过 7 会显著降低可读性;如必须更多泳道,优先合并同类或拆成两张图
- 阶段数(stages)建议 4-8;超过 8 优先合并相邻阶段或改成“代表性阶段”
- 每个阶段在每条泳道中最多放 1 个“主步骤卡片”;如同一阶段需要多个步骤,放在同一格内做纵向堆叠(2-3 个为上限)
- 节点文本 1-2 行为主;长文本用 `\n` 手动换行,避免单行超长导致卡片过宽
- 仅画必要连线:泳道图的结构已经表达了“属于哪个角色/系统 + 发生顺序”,连线只用于表达跨泳道交互、关键因果关系或异步事件流
## Layout 选型
| 模式 | 适用条件 | 特征 |
|------|---------|------|
| **水平泳道** | 默认推荐;流程天然左→右推进 | lanes=行,stages=列;跨泳道同一阶段严格 x 对齐 |
| **垂直泳道** | 用户明确要求竖版、或画布更适合纵向滚动阅读 | lanes=列,stages=行;跨泳道同一阶段严格 y 对齐 |
## Layout 规则
### 通用规则(两种方向都适用)
1. **网格对齐是第一优先级**:跨泳道同一阶段必须严格对齐(水平对齐 x;垂直对齐 y)。对齐通过“共享阶段标尺(stage ruler / stage slots)”实现,不靠肉眼估算,也不靠逐节点随意手写坐标
2. **只生成真实节点**:为保证跨泳道阶段严格对齐,所有阶段统一保留透明的 **stage cell**;仅在真实阶段的 cell 内生成卡片节点,并按阶段索引映射到对应槽位
3. **泳道底色**:为了增强层级感同时保持界面整洁,**强烈建议所有泳道容器统一使用极浅灰色背景**(如 `fillColor: "#F8F9FA"` 或 `"#FCFCFC"`)。边框使用浅灰色细虚线(`borderDash: "dashed"`, `borderWidth: 1`, `borderColor: "#DEE0E3"`)以明确边界。
4. **步骤卡片**:使用 `rect`。为建立清晰的视觉层级,卡片**必须填充浅色背景**(参考 `elements/style.md` 中的浅色板,如极浅的主题色),边框使用对应的主题主色(`borderWidth: 1-2`),文字使用深色(如 `#1F2329`)以确保可读性。统一圆角;宽高以可读为先,避免过窄导致换行过多
5. **间距**:只要存在 connector 连线,卡片之间的主轴间距必须满足 `gap >= 40`
### 子节点对齐
- **同一阶段必须严格对齐**:所有泳道复用同一套 stage slots;不允许靠卡片自身宽度或肉眼估算来对齐
- **卡片宽度一致**:同一泳道中的步骤卡片应保持统一宽度;推荐使用统一固定宽度,或严格复用同一槽位宽度
- **统一使用 stack 容器**:有内容的阶段统一使用 `layout: "vertical"` 的 stack frame(纵向堆叠 1-3 张卡片);空阶段不生成 stack/卡片,但保留透明 cell 保证对齐
- **垂直居中但不影响对齐**:stage cell 默认 `alignItems: "stretch"`,可用 `justifyContent: "center"` 让卡片在 cell 内居中,以确保左右边界严格对齐
- **不靠底色区分行/列**:阶段网格默认不需要背景色;如需“轻微”的行/列边界提示,优先给 stage cell 加 1px 细边框(`fillColor: "transparent"` 仍保持视觉透明)
### Flex 栅格模式(默认)
- lane body 使用 Flex 布局:水平泳道用 `layout: "horizontal"`,垂直泳道用 `layout: "vertical"`
- 为每个阶段生成一个 **stage cell**(占位单元格);空阶段的 cell 透明但保留;cell 内用 `layout: "vertical"` 的 stack 承载 1-3 张卡片
- 统一参数:`slotWidth: 180-220`(水平泳道 cell 宽度)、`slotHeight: 64-104`(垂直泳道 cell 高度建议档)、`gap: 40-56`(有连线时必须 ≥40)、`stackGap: 8`、`lanePadding: 16`
- 对齐规则:所有泳道复用同一组 `slotWidth/slotHeight/gap`;同一阶段在各泳道上使用相同的 cell 索引保证严格对齐
- 尺寸语义:lane body `width/height` 用 `"fit-content"`(Yoga 自适应);卡片 `height: "fit-content"`;Flex 容器内不写子节点 `x/y`
- 内容密度:卡片文字 1-2 行;同阶段堆叠上限 2-3;超过上限优先拆分到相邻阶段或缩短文本
### 跨泳道间距(lanesGap)
- 根容器承载所有泳道:水平泳道用 `layout: "vertical"`,垂直泳道用 `layout: "horizontal"`
- 缩减跨泳道主轴间距 `lanesGap`(建议 `16-24`),以保持整体图表的紧凑性。避免 `lanesGap` 设置为 `0` 导致边框重叠变粗,也避免间距过大导致视觉涣散。
- 每条泳道作为根容器的子 frame,内部再使用上述 Flex 栅格的 stage cell 布局
- `lanesGap` 与 `lanePadding/stackGap` 独立;lane 内容增减不应影响跨泳道间距
- 4px 基线对齐:`lanesGap`、`lanePadding`、cell 尺寸建议按 4 的倍数对齐
### 水平泳道(lanes=行,stages=列)
- 根容器:`layout: "vertical"`,`gap: lanesGap` 固定;`alignItems: "stretch"`,标题在最上方
- 每条泳道:一个可见 frame(分组容器),内部用 `layout: "horizontal"` 分成两块:
- 左侧 lane label:固定宽度 text(如 100-140),垂直居中;左对齐(`textAlign: "left"`);title 需要比步骤卡片更醒目,优先通过 `fontSize: 18-20` + `fontWeight: "bold"` + 与泳道边框一致的 `textColor` 实现
- 右侧 lane body:`layout: "horizontal"`,包含完整的阶段 **stage cell** 数组;cell 宽度固定为 `slotWidth`,相邻 cell 间 `gap` 统一;空阶段 cell 透明但保留
- 步骤卡片:推荐统一卡片宽度(如 160-220),并在所有泳道复用同一组 `slotWidth / gap`,保证跨泳道阶段严格 x 对齐
### 垂直泳道(lanes=列,stages=行)
- 根容器:`layout: "horizontal"`,`gap: lanesGap` 固定;`alignItems: "stretch"`,标题在最上方
- 每条泳道:一个可见 frame(分组容器),内部 `layout: "vertical"`:
- 顶部 lane label:必须放在单独的 `lane label frame` 中,label frame 使用 `width: "fill-container"`、`alignItems: "center"`、`justifyContent: "center"`,并通过 `paddingTop` 留出与泳道上边的 gap(推荐 `12-16`,按 4px 基线取值,如 `padding: [12, 8, 8, 8]`);内部 text 使用 `width: "fill-container"` + `textAlign: "center"`,确保 title 在整条泳道顶部**水平居中**
- lane body:`layout: "vertical"`,包含完整的阶段 **stage cell** 数组;cell 高度固定为 `slotHeight`,相邻 cell 间 `gap` 统一;空阶段 cell 透明但保留
- 内容居中对齐:stage cell 建议 `alignItems: "center"` + `justifyContent: "center"`,让卡片在每个 cell 内水平/垂直居中;卡片宽度不超过 `slotWidth`(或固定宽度),避免被 `"fill-container"` 拉伸导致“看起来不居中”
- 步骤卡片:推荐统一卡片高度或统一 `slotHeight / gap`,保证跨泳道阶段严格 y 对齐
- 泳道外层容器必须显式写 `fillColor: "#F8F9FA"`(极浅灰)、`borderDash: "dashed"`、`borderWidth: 1`、`borderColor: "#DEE0E3"`(统一浅灰色),否则会被编译为虚拟 frame 导致不渲染
- 统一高度(Flex 自适应,可选):根容器使用 `alignItems: "stretch"`,每个泳道外层 frame 使用 `height: "fill-container"`;泳道内部仍保持 lane label + lane body 的结构
示例:
```json
{
"version": 2,
"nodes": [
{
"type": "frame",
"id": "lanes-root",
"x": 40, "y": 40,
"layout": "horizontal",
"gap": 16,
"alignItems": "stretch",
"children": [
{
"type": "frame",
"id": "lane-left",
"layout": "vertical",
"width": "fit-content",
"height": "fill-container",
"fillColor": "#F8F9FA",
"borderDash": "dashed",
"borderWidth": 1,
"borderColor": "#DEE0E3",
"children": [
{ "type": "frame", "id": "lane-left-label-wrap", "layout": "vertical", "width": "fill-container", "height": "fit-content",
"alignItems": "center", "justifyContent": "center", "padding": [12, 8, 8, 8], "children": [
{ "type": "text", "id": "lane-left-label", "text": "Lane Left", "width": "fill-container", "height": "fit-content",
"textAlign": "center", "verticalAlign": "middle", "fontSize": 18, "fontWeight": "bold", "textColor": "#5178C6" }
] },
{ "type": "frame", "id": "lane-left-body", "layout": "vertical",
"gap": 40, "padding": 16,
"children": [
{ "type": "frame", "id": "stage-1-cell-left", "layout": "vertical", "width": 220, "height": 80, "alignItems": "center", "justifyContent": "center",
"children": [{ "type": "rect", "id": "c-s1", "width": 200, "height": "fit-content", "fillColor": "#E1EAFA", "borderColor": "#5178C6", "borderWidth": 2, "borderRadius": 8 }] },
{ "type": "frame", "id": "stage-2-cell-left", "layout": "vertical", "width": 220, "height": 80, "alignItems": "center", "justifyContent": "center", "children": [] }
] }
]
},
{
"type": "frame",
"id": "lane-right",
"layout": "vertical",
"width": "fit-content",
"height": "fill-container",
"fillColor": "#F8F9FA",
"borderDash": "dashed",
"borderWidth": 1,
"borderColor": "#DEE0E3",
"children": [
{ "type": "frame", "id": "lane-right-label-wrap", "layout": "vertical", "width": "fill-container", "height": "fit-content",
"alignItems": "center", "justifyContent": "center", "padding": [12, 8, 8, 8], "children": [
{ "type": "text", "id": "lane-right-label", "text": "Lane Right", "width": "fill-container", "height": "fit-content",
"textAlign": "center", "verticalAlign": "middle", "fontSize": 18, "fontWeight": "bold", "textColor": "#8569CB" }
] },
{ "type": "frame", "id": "lane-right-body", "layout": "vertical",
"gap": 40, "padding": 16,
"children": [
{ "type": "frame", "id": "stage-1-cell-right", "layout": "vertical", "width": 220, "height": 80, "alignItems": "center", "justifyContent": "center", "children": [] },
{ "type": "frame", "id": "stage-2-cell-right", "layout": "vertical", "width": 220, "height": 80, "alignItems": "center", "justifyContent": "center",
"children": [{ "type": "rect", "id": "d-s2", "width": 200, "height": "fit-content", "fillColor": "#EAE6F3", "borderColor": "#8569CB", "borderWidth": 2, "borderRadius": 8 }] }
] }
]
}
]
},
{ "type": "connector", "connector": { "from": "c-s1", "to": "d-s2",
"lineShape": "polyline", "lineColor": "#BBBFC4", "lineWidth": 2, "endArrow": "arrow" } }
]
}
```
### 泳道配色(默认色板)
- **泳道背景**:所有泳道容器统一使用极浅灰色(如 `fillColor: "#F8F9FA"` 或 `"#FCFCFC"`),以增强物理容器的层级感,并突出内部的彩色卡片。
- **泳道边框**:所有泳道外层容器统一使用浅灰色细虚线(`borderColor: "#DEE0E3"`, `borderWidth: 1`, `borderDash: "dashed"`)。
- **泳道标题**:按 `elements/style.md` 经典色板为每条泳道分配不同的主题色,泳道 title 的 `textColor` 使用该主题色。
- **内容节点(rect)**:采用“浅色底 + 主题色边框”策略。`fillColor` 使用与该泳道主题色对应的极浅色(如浅蓝、浅紫等),`borderColor` 使用对应的主题色,文字 `textColor` 统一使用深色 `#1F2329`。
- **连线(connector)**:连线颜色固定为灰色 `#BBBFC4`,不随泳道颜色变化。当连线带有文字(`label`)时,为防止文字压在边框上难以阅读,必须为连线文字设置纯白背景(`labelFillColor: "#FFFFFF"`)遮挡底纹。
提醒:避免创建“虚拟 frame”(见 `elements/schema.md` 的说明)。lane 外层必须具有可见属性以避免在编译时被跳过。
## 连线规则(强制参考 connectors.md)
泳道图中所有连线的选择与写法必须严格遵循 `elements/connectors.md`,尤其是:
- `connector` 必须放在 `WBDocument.nodes` 顶层,不能嵌套在 `children`
- 默认优先使用自动绕线:`lineShape: "polyline"` / `"rightAngle"`,且不写 `waypoints`
- 未指定 `lineShape` 时默认使用 `"rightAngle"`
- 只有在必要时才强制锚点方向;锚点选择必须与节点相对位置一致
- 有连线时卡片间距必须满足 `gap >= 40`;如果连线包含文字(`label`),主轴间距必须 `gap >= 64`
- 带文字的连线必须设置 `labelFillColor: "#FFFFFF"` 遮挡底纹
泳道图语境下的落地约束:
- **默认不写锚点**,交给引擎自动推断;只有需要强制“左→右推进 / 上→下推进”时才写
- 需要表达“异步/事件流/推送”(如 SSE/Chunk)时:使用 `lineStyle: "dashed"` 并配合 `label` 说明语义;其他参数仍按 connectors.md
- 避免连接“仅用于布局且可能被优化掉的虚拟 frame”,尽量连接具体步骤卡片的节点 id(参考 `elements/schema.md` 的虚拟 frame 陷阱)
## 骨架示例
> 示例展示布局的结构与对齐方法;实际节点的样式满足当前布局规则的前提下参考 `elements/style.md`
- 水平泳道示例:
```json
{
"version": 2,
"nodes": [
{
"type": "frame",
"id": "lanes-root",
"x": 40,
"y": 40,
"layout": "vertical",
"gap": 16,
"alignItems": "stretch",
"padding": 0,
"width": "fit-content",
"height": "fit-content",
"children": [
{
"type": "frame",
"id": "lane-a",
"layout": "horizontal",
"gap": 40,
"padding": 16,
"width": "fit-content",
"height": "fill-container",
"fillColor": "#F8F9FA",
"borderDash": "dashed",
"borderWidth": 1,
"borderColor": "#DEE0E3",
"children": [
{
"type": "text",
"id": "lane-a-label",
"text": "Lane A",
"width": 120,
"height": "fit-content",
"textAlign": "left",
"verticalAlign": "middle",
"fontSize": 18,
"fontWeight": "bold",
"textColor": "#5178C6"
},
{
"type": "frame",
"id": "stage-1-cell-a",
"layout": "vertical",
"gap": 8,
"padding": 0,
"width": 200,
"height": "fit-content",
"fillColor": "transparent",
"alignItems": "stretch",
"justifyContent": "center",
"children": [
{
"type": "rect",
"id": "a-s1",
"width": "fill-container",
"height": "fit-content",
"fillColor": "#E1EAFA",
"borderColor": "#5178C6",
"borderWidth": 2,
"borderRadius": 8,
"text": "[阶段 1 节点]",
"fontSize": 14,
"textColor": "#1F2329",
"textAlign": "center",
"verticalAlign": "middle"
}
]
},
{
"type": "frame",
"id": "stage-2-cell-a",
"layout": "vertical",
"gap": 8,
"padding": 0,
"width": 200,
"height": "fit-content",
"fillColor": "transparent",
"alignItems": "stretch",
"justifyContent": "center",
"children": []
}
]
},
{
"type": "frame",
"id": "lane-b",
"layout": "horizontal",
"gap": 40,
"padding": 16,
"width": "fit-content",
"height": "fill-container",
"fillColor": "#F8F9FA",
"borderDash": "dashed",
"borderWidth": 1,
"borderColor": "#DEE0E3",
"children": [
{
"type": "text",
"id": "lane-b-label",
"text": "Lane B",
"width": 120,
"height": "fit-content",
"textAlign": "left",
"verticalAlign": "middle",
"fontSize": 18,
"fontWeight": "bold",
"textColor": "#8569CB"
},
{
"type": "frame",
"id": "stage-1-cell-b",
"layout": "vertical",
"gap": 8,
"padding": 0,
"width": 200,
"height": "fit-content",
"fillColor": "transparent",
"alignItems": "stretch",
"justifyContent": "center",
"children": []
},
{
"type": "frame",
"id": "stage-2-cell-b",
"layout": "vertical",
"gap": 8,
"padding": 0,
"width": 200,
"height": "fit-content",
"fillColor": "transparent",
"alignItems": "stretch",
"justifyContent": "center",
"children": [
{
"type": "rect",
"id": "b-s2",
"width": "fill-container",
"height": "fit-content",
"fillColor": "#EAE6F3",
"borderColor": "#8569CB",
"borderWidth": 2,
"borderRadius": 8,
"text": "[阶段 2 节点]",
"fontSize": 14,
"textColor": "#1F2329",
"textAlign": "center",
"verticalAlign": "middle"
}
]
}
]
}
]
},
{
"type": "connector",
"connector": {
"from": "a-s1",
"to": "b-s2",
"lineShape": "polyline",
"lineColor": "#BBBFC4",
"lineWidth": 2,
"endArrow": "arrow",
"label": "[跨泳道交互]",
"labelFillColor": "#FFFFFF"
}
}
]
}
```
- 垂直泳道示例:见上文“垂直泳道”
- 全泳道统一 `slotWidth/slotHeight/gap`,并为每个阶段生成占位 **stage cell**(空阶段 cell 透明但保留)
- Flex 容器内不写子节点 `x/y`;对齐通过 cell 索引与统一尺寸实现
- 只有真实阶段才在对应 cell 内生成卡片;空阶段不生成卡片但保留 cell 保证网格完整
- 连线必须放在 `nodes` 顶层,并连接具体步骤卡片 id,不要连接 `lane-*-body` 这类布局容器
- **水平泳道**:根容器用 `layout: "vertical"` 固定 `lanesGap`;lane body 用 `layout: "horizontal"`;cell 固定宽度 `slotWidth`;主轴 `gap` 统一
- **垂直泳道**:根容器用 `layout: "horizontal"` 固定 `lanesGap`;lane body 用 `layout: "vertical"`;cell 固定高度 `slotHeight`;主轴 `gap` 统一
- **泳道 title**:title 比步骤卡片更醒目,但仍只用字号、字重、文字色强调;不要给泳道 title 额外加背景条
## 陷阱
- **各泳道复用的 stage slots 不一致**:会导致同阶段错位;`slotWidth / slotHeight / gap` 必须全泳道统一
- **把 connector 放进 children**:会导致 schema 报错或无法连线(见 connectors.md)
- **把辅助容器画成可见元素**:lane body 或其他支撑 frame 必须保持 `fillColor: "transparent"`,除泳道分组容器外不要额外加边框
- **手写 waypoints 过早**:先让引擎自动绕线;只有在必要时才通过 waypoints 接管
- **连线过多**:按 connectors.md 的连线数量策略降采样,否则跨泳道线会互相遮挡导致不可读
scenes/treemap.md›
# 矩形树图 (Treemap)
## Content 约束
- 分类 3-5 个,每个分类下子项 2-4 个
- 总面积比例需预先计算:每个矩形面积 = 父矩形面积 * (本项数值 / 同级总数值)
- 每个叶子节点标签必须包含数值(如 "{{LABEL}} ({{VALUE}})")
## Layout 选型
- **脚本生成坐标**(推荐):Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.13` 渲染
- 不适合手动心算坐标
## Layout 规则
- 使用交替切分法(Slice-and-Dice):奇数层水平切分 width,偶数层垂直切分 height
- 父矩形内必须为标题预留 30-40px 顶部空间,子矩形从 y + 35 开始放置
- 子节点必须完全落在父矩形范围内
- 水平切分时:子 width = 父 width * (子数值 / 父总数值),子 x 依次累加
- 垂直切分时:子 height = (父 height - 35) * (子数值 / 父总数值),子 y 依次累加(注意扣除父标签预留的 35px)
### 面积比例计算规则
1. **面积与数值严格成正比**:任何层级的节点,其矩形面积 `width * height` 必须与数值成比例
2. **奇数层水平切分**(如第一层分类):
- 父矩形的 `height` 和 `y` 坐标传给所有子节点(扣除标签预留空间后)
- 按子节点数值占父节点的比例切分父矩形的 `width`:`子width = 父width * (子数值 / 父总数值)`
- 子节点的 `x` 坐标依次向右累加
3. **偶数层垂直切分**(如第二层子项):
- 父矩形的 `width` 和 `x` 坐标传给所有子节点
- 按子节点数值占父节点的比例切分父矩形的 `height`:`子height = 父height * (子数值 / 父总数值)`
- 子节点的 `y` 坐标依次向下累加
4. **层层递归**:不断交替水平和垂直切分方向,直到所有叶子节点都被分配了精确的坐标和宽高
### 父标签预留空间
每个非叶子节点的矩形,顶部必须预留 30-40px 放置分类标签。子矩形从父矩形的 `y + 35` 开始放置,可用高度为 `父height - 35`。
示例:父矩形 `{ x: 40, y: 40, height: 700 }`,则:
- 父标签放在 `y: 46`(留 6px 上边距)
- 子矩形从 `y: 75` 开始放置(40 + 35)
- 子矩形可用高度为 `700 - 35 = 665`
## 骨架示例
2 层 treemap:3 个分类(硬件 40、软件 35、服务 25),各含 2 个子项。
根矩形 1100x700,第一层水平切分 width,第二层垂直切分 height。
```json
{
"version": 2,
"nodes": [
{
"type": "rect",
"id": "root",
"x": 40, "y": 40,
"width": 1100, "height": 700,
"borderWidth": 2, "borderRadius": 6
},
{
"type": "text",
"x": 48, "y": 46,
"width": 1084, "height": 24,
"text": "{{ROOT_TITLE}}",
"fontSize": 14
},
{
"type": "rect",
"id": "cat-A",
"x": 40, "y": 75,
"width": 440, "height": 665,
"borderWidth": 2, "borderRadius": 6
},
{
"type": "text",
"x": 48, "y": 81,
"width": 424, "height": 24,
"text": "{{CAT_A}}",
"fontSize": 14
},
{
"type": "rect",
"id": "cat-A-item-1",
"x": 40, "y": 110,
"width": 440, "height": 380,
"borderRadius": 4
},
{
"type": "text",
"x": 48, "y": 116,
"width": 424, "height": 24,
"text": "{{ITEM_A1}} (24)",
"fontSize": 14
},
{
"type": "rect",
"id": "cat-A-item-2",
"x": 40, "y": 490,
"width": 440, "height": 250,
"borderRadius": 4
},
{
"type": "text",
"x": 48, "y": 496,
"width": 424, "height": 24,
"text": "{{ITEM_A2}} (16)",
"fontSize": 14
},
{
"type": "rect",
"id": "cat-B",
"x": 480, "y": 75,
"width": 385, "height": 665,
"borderWidth": 2, "borderRadius": 6
},
{
"type": "text",
"x": 488, "y": 81,
"width": 369, "height": 24,
"text": "{{CAT_B}}",
"fontSize": 14
},
{
"type": "rect",
"id": "cat-B-item-1",
"x": 480, "y": 110,
"width": 385, "height": 380,
"borderRadius": 4
},
{
"type": "text",
"x": 488, "y": 116,
"width": 369, "height": 24,
"text": "{{ITEM_B1}} (20)",
"fontSize": 14
},
{
"type": "rect",
"id": "cat-B-item-2",
"x": 480, "y": 490,
"width": 385, "height": 285,
"borderRadius": 4
},
{
"type": "text",
"x": 488, "y": 496,
"width": 369, "height": 24,
"text": "{{ITEM_B2}} (15)",
"fontSize": 14
},
{
"type": "rect",
"id": "cat-C",
"x": 865, "y": 75,
"width": 275, "height": 665,
"borderWidth": 2, "borderRadius": 6
},
{
"type": "text",
"x": 873, "y": 81,
"width": 259, "height": 24,
"text": "{{CAT_C}}",
"fontSize": 14
},
{
"type": "rect",
"id": "cat-C-item-1",
"x": 865, "y": 110,
"width": 275, "height": 399,
"borderRadius": 4
},
{
"type": "text",
"x": 873, "y": 116,
"width": 259, "height": 24,
"text": "{{ITEM_C1}} (15)",
"fontSize": 14
},
{
"type": "rect",
"id": "cat-C-item-2",
"x": 865, "y": 509,
"width": 275, "height": 231,
"borderRadius": 4
},
{
"type": "text",
"x": 873, "y": 515,
"width": 259, "height": 24,
"text": "{{ITEM_C2}} (10)",
"fontSize": 14
}
]
}
```
面积比例验证(第一层水平切分 width):
- 硬件 40/100 * 1100 = 440,软件 35/100 * 1100 = 385,服务 25/100 * 1100 = 275
- 子矩形从 y=75 开始,可用高度 665
## 陷阱
- **父标签被子矩形遮挡**(最严重):子矩形必须从 y + 35(相对父矩形顶部)开始放置,为父分类标签留出空间
- **分类标签不可见**:分类标签 text 节点必须在其子矩形 rect 节点之前添加(z-index 靠后的节点在上层)
- **面积比例不正确**:必须用脚本预先计算比例,不要心算
- **缺少配色区分**:不同顶层分类必须用不同背景色(从色板选取),所有子节点继承对应色系
此场景必须用 .cjs 脚本生成。Agent 使用时只需修改 `data` 树,其余坐标与矩形面积自动递归计算。
```javascript
const { writeFileSync } = require('fs');
```
SKILL.md›
---
name: lark-whiteboard
version: 1.0.0
description: >
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用多种格式更新画板内容。
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。不负责:飞书云文档内容编辑(lark-doc)、文档内嵌电子表格/Base(lark-sheets / lark-base)。
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli whiteboard --help"
---
> [!IMPORTANT]
> - 运行 `lark-cli --version`,确认可用,无需询问用户。
> - 运行 `npx -y @larksuite/whiteboard-cli@^0.2.13 -v`,确认可用,无需询问用户。
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
---
## 快速决策
**身份**:画板操作默认使用 `--as user`。仅当需要以应用身份上传时使用 `--as bot`。
> 先判断「只读还是写入」,再在对应表内按上到下匹配,**命中即停**。
### A. 只读 · 查看 / 导出(不改画板)
| 用户需求 | 行动 |
|---|---|
| 查看画板内容 / 导出图片 | [`+export --output-type preview`](references/lark-whiteboard-export.md) |
| 导出 SVG 矢量图 | [`+export --output-type svg`](references/lark-whiteboard-export.md) |
| 提取画板的 Mermaid/PlantUML 源码 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
### B. 写入 · 创作 / 编辑(会改画板,命中即停)
| 场景 | 行动 | 写入方式 | 对原内容 |
|---|---|---|---|
| 用户**已提供** Mermaid/PlantUML/SVG 代码,或明确指定用该格式 | 使用该代码 → [`+update`](references/lark-whiteboard-update.md),`--input_format` 取单值 `mermaid` / `plantuml` / `svg`;写入非空已有画板并需要 overwrite 时,先确认会整板重建;若 SVG 用于修改已有画板,先走 [`routes/svg-edit.md`](routes/svg-edit.md) 有损确认 | overwrite / append | 按用户要求 |
| 从零新建复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** | 首次写入 | — |
| 修改 / 增补已有画板 | → **[§ 编辑 Workflow](references/lark-whiteboard-workflow.md#编辑-workflow)** | 见该表 | 见该表 |
## Shortcuts
| Shortcut | 说明 |
|---------------------------------------------------|---|
| [`+export`](references/lark-whiteboard-export.md) | 导出画板为预览图片、SVG 矢量图、代码或原始节点结构。 |
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid、SVG 或 OpenAPI 原生格式 |
---
## 不在本 skill 范围
- 文档内容编辑 → lark-doc [lark-doc](../lark-doc/SKILL.md)
- 在文档中创建画板 → [lark-doc-whiteboard.md](../lark-doc/references/lark-doc-whiteboard.md)
- 表格 / Base 操作 → [lark-sheets](../lark-sheets/SKILL.md) / [lark-base](../lark-base/SKILL.md)