Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92a5bfd016 |
+231
-1
@@ -94,9 +94,11 @@ flowchart LR
|
||||
|
||||
## ECharts 图表
|
||||
|
||||
### 基础柱状图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 各模块文档数量
|
||||
height: 72mm
|
||||
height: 58mm
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
@@ -108,6 +110,234 @@ option:
|
||||
data: [28, 18, 15, 22]
|
||||
\`\`\`
|
||||
|
||||
### Tree 树图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 产品能力树
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: tree
|
||||
orient: LR
|
||||
label:
|
||||
position: left
|
||||
data:
|
||||
- name: 文档平台
|
||||
children:
|
||||
- name: Markdown
|
||||
children:
|
||||
- name: Mermaid
|
||||
- name: ECharts
|
||||
- name: PDF
|
||||
children:
|
||||
- name: 快速预览
|
||||
- name: 精确预览
|
||||
\`\`\`
|
||||
|
||||
### Treemap 矩形树图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 模块工作量占比
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: treemap
|
||||
roam: false
|
||||
nodeClick: false
|
||||
breadcrumb:
|
||||
show: false
|
||||
label:
|
||||
show: true
|
||||
formatter: "{b}"
|
||||
color: "#1f2937"
|
||||
fontWeight: bold
|
||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||
borderRadius: 3
|
||||
padding: [3, 6]
|
||||
itemStyle:
|
||||
borderColor: "#ffffff"
|
||||
borderWidth: 2
|
||||
gapWidth: 2
|
||||
levels:
|
||||
- itemStyle:
|
||||
borderWidth: 0
|
||||
gapWidth: 2
|
||||
- upperLabel:
|
||||
show: true
|
||||
height: 28
|
||||
color: "#1f2937"
|
||||
fontWeight: bold
|
||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||
padding: [3, 6]
|
||||
itemStyle:
|
||||
borderColor: "#ffffff"
|
||||
borderWidth: 2
|
||||
gapWidth: 2
|
||||
- label:
|
||||
show: true
|
||||
formatter: "{b}"
|
||||
color: "#1f2937"
|
||||
fontWeight: bold
|
||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||
borderRadius: 3
|
||||
padding: [3, 6]
|
||||
itemStyle:
|
||||
borderColor: "#ffffff"
|
||||
borderWidth: 2
|
||||
gapWidth: 2
|
||||
data:
|
||||
- name: 渲染
|
||||
children:
|
||||
- { name: Markdown, value: 32 }
|
||||
- { name: ECharts, value: 24 }
|
||||
- { name: Mermaid, value: 18 }
|
||||
- name: 导出
|
||||
children:
|
||||
- { name: PDF, value: 28 }
|
||||
- { name: 主题, value: 12 }
|
||||
\`\`\`
|
||||
|
||||
### Sunburst 旭日图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 文档内容层级
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: sunburst
|
||||
radius: [15%, 90%]
|
||||
data:
|
||||
- name: 技术文档
|
||||
children:
|
||||
- { name: 架构, value: 18 }
|
||||
- { name: 接口, value: 24 }
|
||||
- name: 项目文档
|
||||
children:
|
||||
- { name: 周报, value: 16 }
|
||||
- { name: 计划, value: 12 }
|
||||
\`\`\`
|
||||
|
||||
### Graph 关系图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 渲染链路关系
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: graph
|
||||
layout: circular
|
||||
roam: false
|
||||
label:
|
||||
show: true
|
||||
data:
|
||||
- { name: Markdown, symbolSize: 42 }
|
||||
- { name: HTML, symbolSize: 38 }
|
||||
- { name: 预览, symbolSize: 34 }
|
||||
- { name: PDF, symbolSize: 34 }
|
||||
links:
|
||||
- { source: Markdown, target: HTML }
|
||||
- { source: HTML, target: 预览 }
|
||||
- { source: HTML, target: PDF }
|
||||
\`\`\`
|
||||
|
||||
### Sankey 桑基图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 文档流转量
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: sankey
|
||||
data:
|
||||
- { name: Markdown }
|
||||
- { name: HTML }
|
||||
- { name: 快速预览 }
|
||||
- { name: 精确预览 }
|
||||
- { name: PDF }
|
||||
links:
|
||||
- { source: Markdown, target: HTML, value: 100 }
|
||||
- { source: HTML, target: 快速预览, value: 55 }
|
||||
- { source: HTML, target: 精确预览, value: 25 }
|
||||
- { source: HTML, target: PDF, value: 20 }
|
||||
\`\`\`
|
||||
|
||||
### Chord 和弦图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 团队协作关系
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: chord
|
||||
label:
|
||||
show: true
|
||||
data:
|
||||
- { name: 研发 }
|
||||
- { name: 产品 }
|
||||
- { name: 设计 }
|
||||
- { name: 运营 }
|
||||
links:
|
||||
- { source: 研发, target: 产品, value: 12 }
|
||||
- { source: 产品, target: 设计, value: 8 }
|
||||
- { source: 设计, target: 运营, value: 6 }
|
||||
- { source: 运营, target: 研发, value: 4 }
|
||||
\`\`\`
|
||||
|
||||
### Funnel 漏斗图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 文档发布转化
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: funnel
|
||||
sort: descending
|
||||
label:
|
||||
formatter: "{b}: {c}"
|
||||
data:
|
||||
- { name: 草稿, value: 100 }
|
||||
- { name: 评审, value: 76 }
|
||||
- { name: 定稿, value: 58 }
|
||||
- { name: 发布, value: 45 }
|
||||
\`\`\`
|
||||
|
||||
### Gauge 仪表盘
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 发布完成率
|
||||
height: 58mm
|
||||
option:
|
||||
series:
|
||||
- type: gauge
|
||||
min: 0
|
||||
max: 100
|
||||
progress:
|
||||
show: true
|
||||
detail:
|
||||
formatter: "{value}%"
|
||||
data:
|
||||
- { name: 完成率, value: 78 }
|
||||
\`\`\`
|
||||
|
||||
### PictorialBar 象形柱图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 各渠道文档数量
|
||||
height: 58mm
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
data: [知识库, 周报, 手册, 方案]
|
||||
yAxis:
|
||||
type: value
|
||||
series:
|
||||
- type: pictorialBar
|
||||
symbol: "path://M0,10 L5,0 L10,10 Z"
|
||||
symbolRepeat: true
|
||||
symbolSize: [14, 10]
|
||||
data: [12, 20, 16, 24]
|
||||
\`\`\`
|
||||
|
||||
> 预览和 PDF 将共用同一份 HTML 与 CSS。
|
||||
`;
|
||||
|
||||
|
||||
@@ -216,7 +216,6 @@ export class PagedDocumentRuntime {
|
||||
const { renderEChartsBlocks } = await this.loadECharts();
|
||||
const outcomes = await renderEChartsBlocks(container, {
|
||||
outputMode: "inline-svg",
|
||||
animation: false,
|
||||
concurrency: 2,
|
||||
timeoutMs: 5_000
|
||||
});
|
||||
|
||||
+62
-2
@@ -33,6 +33,8 @@ ec48bce chore: 初始化项目骨架
|
||||
|
||||
最新阶段已完成 Markdown ECharts YAML 围栏、统一 SVG 渲染与分页,
|
||||
并完善默认示例、源文本收起、顶栏文档状态和快速页码跳转。
|
||||
当前正在推进 `v0.3.1` ECharts 第二阶段,扩展层级图、关系图和更多
|
||||
业务图表类型。
|
||||
|
||||
## 2. 已完成
|
||||
|
||||
@@ -294,6 +296,29 @@ ec48bce chore: 初始化项目骨架
|
||||
- 快速预览的页码识别和跳转会合并外层滚动位置、iframe 位置、内部分页
|
||||
位置与显示缩放比例,50%~400% 视觉缩放不会破坏页码同步。
|
||||
|
||||
### 3.11 ECharts 第二阶段图表
|
||||
|
||||
- ECharts 系列支持从首版 8 种扩展到 17 种,新增 `tree`、`treemap`、
|
||||
`sunburst`、`graph`、`sankey`、`chord`、`funnel`、`gauge` 和
|
||||
`pictorialBar`。
|
||||
- 当前使用 ECharts 6.1.0,`chord` 直接采用 ECharts 6 官方和弦图及
|
||||
`ChordChart` 按需模块,不引入第三方扩展。
|
||||
- `tree`、`treemap` 和 `sunburst` 校验递归层级结构;矩形树图和旭日图
|
||||
的叶节点必须提供有限数值。
|
||||
- `graph`、`sankey` 和 `chord` 校验节点、关系边、端点引用与边权重;
|
||||
Graph 未指定布局时,有完整坐标则使用 `none`,否则默认使用 `force`。
|
||||
- `funnel`、`gauge` 校验有限数值数据;`pictorialBar` 复用柱图的数据和
|
||||
坐标轴规则,并允许不加载外部资源的 `path://` 内嵌矢量路径。
|
||||
- 安全策略新增层级节点、关系节点和关系边数量限制,继续禁止外部图片、
|
||||
跳转链接、函数、HTML formatter 和原型污染属性。
|
||||
- 默认 Markdown 现在包含基础柱状图及九种第二阶段图表,每种均使用独立
|
||||
YAML 围栏,可直接编辑和观察 SVG、分页及错误占位行为。
|
||||
- Treemap 默认示例关闭节点下钻和面包屑,显式启用父级与叶节点标签,并
|
||||
增加白色分隔边框,避免扁平数据区域缺少辨识信息或底部控件占用空间。
|
||||
- Markdown/PDF 属于确定性的静态输出场景,浏览器引擎会在全局和系列级
|
||||
强制关闭 ECharts 动画、动画延迟和持续时间,不允许 YAML 动画参数使
|
||||
预览或 PDF 停留在动画中间帧。
|
||||
|
||||
## 4. 已执行验证
|
||||
|
||||
2026-07-27 在当前完整工作区成功执行:
|
||||
@@ -307,11 +332,12 @@ git diff --check
|
||||
|
||||
结果:
|
||||
|
||||
- ECharts 围栏测试:27 项通过;
|
||||
- ECharts 围栏测试:36 项通过;
|
||||
- 共享配置测试:9 项通过;
|
||||
- 渲染器测试:10 项通过;
|
||||
- 前端测试:63 项通过;
|
||||
- 后端测试:29 项通过;
|
||||
- 全项目共 147 项测试通过;
|
||||
- 全项目类型检查通过;
|
||||
- 生产构建通过;
|
||||
- `git diff --check` 通过。
|
||||
@@ -356,6 +382,25 @@ git diff --check
|
||||
- Mermaid A/B PDF 页数、逐页文字和抽样视觉结果一致;
|
||||
- 抽查第 2、6、27、53、65 页,Mermaid 尺寸、分页位置和末页内容正常。
|
||||
|
||||
ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
|
||||
- 默认示例生成 10 个 ECharts SVG,包含原柱状图和九种新增系列,错误
|
||||
占位数为 0,浏览器控制台无警告或错误;
|
||||
- 快速预览生成 6 页,第 2~6 页各放置两张完整图表,所有图表边界均位于
|
||||
当前页内容区内,没有跨页切割;
|
||||
- 精确预览同样生成 6 页,逐页跳转正常;Chord、Funnel、Gauge 和
|
||||
PictorialBar 的 PDF Canvas 视觉检查通过;
|
||||
- 使用默认导出配置调用 PDF API 生成 4 页 PDF,响应中的 ECharts 和
|
||||
Mermaid 错误数均为 0;
|
||||
- PDF 中九种新增图表的标题均可搜索;将全部页面渲染为 PNG 后,Tree、
|
||||
Treemap、Sunburst、Graph、Sankey、Chord、Funnel、Gauge 和
|
||||
PictorialBar 均显示完整,未发现裁切、重叠、黑块或空白图表。
|
||||
- 修正 Treemap 示例后再次检查快速与精确预览:底部下钻面包屑已隐藏,
|
||||
五个叶节点边界清晰;SVG 中父级和叶节点共 7 个标签均处于可见状态。
|
||||
- 进一步确认此前 Treemap 标签虽存在于 SVG,但被冻结在入场动画初始帧,
|
||||
`fill-opacity` 约为 `0.000014`,肉眼近似完全透明;全局关闭动画后,
|
||||
快速预览和精确 PDF 中的 2 个父级及 5 个叶节点标签均清晰可见。
|
||||
|
||||
2026-07-27 已重新构建并启动本地发布候选镜像:
|
||||
|
||||
- 使用完整多阶段 `deploy/Dockerfile` 构建
|
||||
@@ -393,7 +438,22 @@ git diff --check
|
||||
- 浏览器快速预览、主题切换、精确 PDF.js 预览和导出交互通过;
|
||||
- 修复 Nginx 缺少 `.mjs` MIME 映射导致精确预览 Worker 加载失败的问题。
|
||||
|
||||
为方便用户继续检查,AIO 容器当前运行在 `http://localhost:8080`。
|
||||
`v0.3.0` 发布验收完成后,已停止并删除本项目 Compose 容器及网络以释放
|
||||
本地资源;`yixiong/md-to-pdf:v0.3.0` 镜像继续保留。
|
||||
|
||||
2026-07-27 已构建并启动 `v0.3.1` 发布候选:
|
||||
|
||||
- 使用完整多阶段 Dockerfile 构建 `yixiong/md-to-pdf:v0.3.1`,镜像
|
||||
大小约 612 MB;
|
||||
- Compose 容器使用该镜像运行并通过健康检查,服务监听
|
||||
`http://localhost:8080`;
|
||||
- 容器首页返回 200,默认 Markdown 解析出 10 个 ECharts 围栏,
|
||||
错误占位和渲染警告均为 0;
|
||||
- 容器生成的默认配置 PDF 为 4 页、约 521 KiB,ECharts 和 Mermaid
|
||||
渲染错误数均为 0;
|
||||
- ECharts 已编译进按需加载的前端浏览器 Bundle,生产运行时不需要保留
|
||||
独立的 `echarts` Node.js 包;
|
||||
- 当前容器保留运行,等待 `v0.3.1` 发布前手动验收。
|
||||
|
||||
## 5. 当前注意事项
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ option:
|
||||
|
||||
## 系列验证规则
|
||||
|
||||
当前首版支持以下系列:
|
||||
当前支持以下系列:
|
||||
|
||||
| 系列 | 主要规则 |
|
||||
| --- | --- |
|
||||
@@ -79,6 +79,15 @@ option:
|
||||
| `heatmap` | 直角坐标数据为 `[x, y, value]`;日历数据为 `[date, value]` |
|
||||
| `boxplot` | 直接数据为 `[min, Q1, median, Q3, max]` |
|
||||
| `candlestick` | 直接数据为 `[open, close, lowest, highest]` |
|
||||
| `tree` | 必须提供非空层级 `data`;递归校验 `children` |
|
||||
| `treemap` | 必须提供非空层级 `data`;叶节点必须具有有限数值 |
|
||||
| `sunburst` | 必须提供非空层级 `data`;叶节点必须具有有限数值 |
|
||||
| `graph` | 必须提供 `data` 或 `nodes`;校验边引用;缺少坐标时默认 `force` 布局 |
|
||||
| `sankey` | 必须提供节点和关系边;边权重必须是非负有限数值 |
|
||||
| `chord` | 使用 ECharts 6 和弦图;必须提供节点、关系边和非负权重 |
|
||||
| `funnel` | 数据项必须是有限数值,或使用有效 `dataset` |
|
||||
| `gauge` | 数据项必须是有限数值,或使用有效 `dataset` |
|
||||
| `pictorialBar` | 需要有效数据和直角坐标系;允许安全的 `path://` 内嵌矢量符号 |
|
||||
|
||||
直角坐标系列缺少 `xAxis` 或 `yAxis` 时会补充空对象,让 ECharts
|
||||
使用自身的轴默认值。极坐标、雷达和日历坐标系表达了明确语义,
|
||||
@@ -87,6 +96,111 @@ option:
|
||||
使用 `dataset` 时,验证器只验证数据源是否存在,不重复实现
|
||||
ECharts 的维度推断和 `encode` 映射规则。
|
||||
|
||||
## 第二阶段系列示例
|
||||
|
||||
### 层级图
|
||||
|
||||
```yaml
|
||||
height: 60mm
|
||||
option:
|
||||
series:
|
||||
- type: treemap
|
||||
nodeClick: false
|
||||
breadcrumb:
|
||||
show: false
|
||||
label:
|
||||
show: true
|
||||
formatter: "{b}"
|
||||
color: "#1f2937"
|
||||
fontWeight: bold
|
||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||
borderRadius: 3
|
||||
padding: [3, 6]
|
||||
itemStyle:
|
||||
borderColor: "#ffffff"
|
||||
borderWidth: 2
|
||||
gapWidth: 2
|
||||
levels:
|
||||
- itemStyle:
|
||||
borderWidth: 0
|
||||
gapWidth: 2
|
||||
- upperLabel:
|
||||
show: true
|
||||
height: 28
|
||||
color: "#1f2937"
|
||||
fontWeight: bold
|
||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||
padding: [3, 6]
|
||||
itemStyle:
|
||||
borderColor: "#ffffff"
|
||||
borderWidth: 2
|
||||
gapWidth: 2
|
||||
- label:
|
||||
show: true
|
||||
formatter: "{b}"
|
||||
color: "#1f2937"
|
||||
fontWeight: bold
|
||||
backgroundColor: "rgba(255, 255, 255, 0.88)"
|
||||
borderRadius: 3
|
||||
padding: [3, 6]
|
||||
itemStyle:
|
||||
borderColor: "#ffffff"
|
||||
borderWidth: 2
|
||||
gapWidth: 2
|
||||
data:
|
||||
- name: 渲染
|
||||
children:
|
||||
- { name: Markdown, value: 32 }
|
||||
- { name: ECharts, value: 24 }
|
||||
- name: 导出
|
||||
children:
|
||||
- { name: PDF, value: 28 }
|
||||
```
|
||||
|
||||
`tree`、`treemap` 和 `sunburst` 共用递归 `children` 结构。
|
||||
`treemap` 与 `sunburst` 的叶节点需要数值;父节点可以省略数值,
|
||||
由 ECharts 根据子节点汇总。
|
||||
|
||||
### 关系图
|
||||
|
||||
```yaml
|
||||
height: 60mm
|
||||
option:
|
||||
series:
|
||||
- type: sankey
|
||||
data:
|
||||
- { name: Markdown }
|
||||
- { name: HTML }
|
||||
- { name: PDF }
|
||||
links:
|
||||
- { source: Markdown, target: HTML, value: 100 }
|
||||
- { source: HTML, target: PDF, value: 40 }
|
||||
```
|
||||
|
||||
`graph` 的关系边可以省略;`sankey` 和 ECharts 6 `chord` 必须
|
||||
具有非空 `links` 或 `edges`。`source`、`target` 必须引用节点
|
||||
名称、ID 或数组索引。
|
||||
|
||||
### 业务图表
|
||||
|
||||
```yaml
|
||||
height: 60mm
|
||||
option:
|
||||
series:
|
||||
- type: gauge
|
||||
min: 0
|
||||
max: 100
|
||||
progress:
|
||||
show: true
|
||||
detail:
|
||||
formatter: "{value}%"
|
||||
data:
|
||||
- { name: 完成率, value: 78 }
|
||||
```
|
||||
|
||||
`funnel` 和 `gauge` 使用有限数值数据;`pictorialBar` 复用柱图
|
||||
坐标系和数据规则,并允许不加载外部资源的 `path://` SVG 路径。
|
||||
|
||||
## 错误行为
|
||||
|
||||
配置错误不会中断整篇 Markdown。对应围栏会变成局部错误占位:
|
||||
@@ -133,7 +247,6 @@ import "@md-to-pdf/markdown-echarts/styles.css";
|
||||
|
||||
const outcomes = await renderEChartsBlocks(document, {
|
||||
outputMode: "inline-svg",
|
||||
animation: false,
|
||||
concurrency: 2,
|
||||
timeoutMs: 5000
|
||||
});
|
||||
@@ -148,6 +261,9 @@ const outcomes = await renderEChartsBlocks(document, {
|
||||
PDF 输出前应等待所有图表、字体和图片完成,再开始分页或调用
|
||||
Chromium `page.pdf()`。
|
||||
|
||||
浏览器引擎会强制关闭全局及各系列动画,Markdown 中的动画参数不会改变
|
||||
静态 SVG、快速预览、精确预览或 PDF,避免输出停留在动画中间帧。
|
||||
|
||||
## 安全边界
|
||||
|
||||
默认策略会拒绝:
|
||||
@@ -158,7 +274,8 @@ Chromium `page.pdf()`。
|
||||
- 原型污染属性;
|
||||
- 未允许的 toolbox 功能;
|
||||
- 非内置数据转换;
|
||||
- 超过节点数、深度、字符串、数组和系列数量上限的配置。
|
||||
- 超过节点数、深度、字符串、数组、系列、层级节点或关系边数量
|
||||
上限的配置。
|
||||
|
||||
这些限制适用于不受信任的 Markdown。宿主如需扩大能力,应在明确
|
||||
风险后扩展验证器,而不是直接执行 YAML 中的 JavaScript。
|
||||
|
||||
@@ -3,11 +3,20 @@ import {
|
||||
BarChart,
|
||||
BoxplotChart,
|
||||
CandlestickChart,
|
||||
ChordChart,
|
||||
FunnelChart,
|
||||
GaugeChart,
|
||||
GraphChart,
|
||||
HeatmapChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
PictorialBarChart,
|
||||
RadarChart,
|
||||
ScatterChart
|
||||
SankeyChart,
|
||||
ScatterChart,
|
||||
SunburstChart,
|
||||
TreeChart,
|
||||
TreemapChart
|
||||
} from "echarts/charts";
|
||||
import {
|
||||
AriaComponent,
|
||||
@@ -44,6 +53,15 @@ echarts.use([
|
||||
HeatmapChart,
|
||||
BoxplotChart,
|
||||
CandlestickChart,
|
||||
TreeChart,
|
||||
TreemapChart,
|
||||
SunburstChart,
|
||||
GraphChart,
|
||||
SankeyChart,
|
||||
ChordChart,
|
||||
FunnelChart,
|
||||
GaugeChart,
|
||||
PictorialBarChart,
|
||||
GridComponent,
|
||||
PolarComponent,
|
||||
RadarComponent,
|
||||
|
||||
@@ -267,15 +267,28 @@ export async function renderEChartsBlock(
|
||||
useDirtyRect: false
|
||||
});
|
||||
|
||||
const shouldAnimate =
|
||||
options.animation ??
|
||||
(outputMode === "interactive"
|
||||
? definition.option.animation
|
||||
: false);
|
||||
const series = Array.isArray(definition.option.series)
|
||||
? definition.option.series.map((item) =>
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? {
|
||||
...item,
|
||||
animation: false,
|
||||
animationDelay: 0,
|
||||
animationDelayUpdate: 0,
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: 0
|
||||
}
|
||||
: item
|
||||
)
|
||||
: definition.option.series;
|
||||
const option = {
|
||||
...definition.option,
|
||||
animation:
|
||||
typeof shouldAnimate === "boolean" ? shouldAnimate : false
|
||||
animation: false,
|
||||
animationDelay: 0,
|
||||
animationDelayUpdate: 0,
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: 0,
|
||||
series
|
||||
};
|
||||
await waitForRender(
|
||||
instance,
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface RenderEChartsBlockOptions {
|
||||
outputMode?: EChartsOutputMode;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
animation?: boolean;
|
||||
resolveTheme?: (
|
||||
theme: NormalizedEChartsFenceSpec["theme"],
|
||||
element: HTMLElement
|
||||
|
||||
@@ -11,7 +11,16 @@ export const supportedEChartsSeriesTypes = [
|
||||
"radar",
|
||||
"heatmap",
|
||||
"boxplot",
|
||||
"candlestick"
|
||||
"candlestick",
|
||||
"tree",
|
||||
"treemap",
|
||||
"sunburst",
|
||||
"graph",
|
||||
"sankey",
|
||||
"chord",
|
||||
"funnel",
|
||||
"gauge",
|
||||
"pictorialBar"
|
||||
] as const;
|
||||
|
||||
export type SupportedEChartsSeriesType =
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface EChartsSecurityLimits {
|
||||
maxArrayLength: number;
|
||||
maxStringLength: number;
|
||||
maxSeries: number;
|
||||
maxHierarchyNodes: number;
|
||||
maxGraphNodes: number;
|
||||
maxGraphEdges: number;
|
||||
}
|
||||
|
||||
export const defaultEChartsSecurityLimits: EChartsSecurityLimits = {
|
||||
@@ -19,7 +22,10 @@ export const defaultEChartsSecurityLimits: EChartsSecurityLimits = {
|
||||
maxObjectKeys: 1_000,
|
||||
maxArrayLength: 20_000,
|
||||
maxStringLength: 100_000,
|
||||
maxSeries: 32
|
||||
maxSeries: 32,
|
||||
maxHierarchyNodes: 5_000,
|
||||
maxGraphNodes: 5_000,
|
||||
maxGraphEdges: 10_000
|
||||
};
|
||||
|
||||
const forbiddenPropertyNames = new Set([
|
||||
@@ -257,6 +263,86 @@ function assertSupportedSeries(
|
||||
`option.series.${index}.type 暂不支持 ${String(type)}`
|
||||
);
|
||||
}
|
||||
assertSeriesStructureLimits(item, index, type, limits);
|
||||
}
|
||||
}
|
||||
|
||||
function countHierarchyNodes(
|
||||
value: JsonValue | undefined,
|
||||
limit: number,
|
||||
path: string
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
let count = 0;
|
||||
const pending = [...value];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
count += 1;
|
||||
if (count > limit) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`${path} 层级节点数量不能超过 ${limit}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
node !== null &&
|
||||
!Array.isArray(node) &&
|
||||
typeof node === "object" &&
|
||||
Array.isArray(node.children)
|
||||
) {
|
||||
pending.push(...node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function arrayLength(value: JsonValue | undefined) {
|
||||
return Array.isArray(value) ? value.length : 0;
|
||||
}
|
||||
|
||||
function assertSeriesStructureLimits(
|
||||
series: Record<string, JsonValue>,
|
||||
seriesIndex: number,
|
||||
type: string,
|
||||
limits: EChartsSecurityLimits
|
||||
) {
|
||||
const path = `option.series.${seriesIndex}`;
|
||||
if (
|
||||
type === "tree" ||
|
||||
type === "treemap" ||
|
||||
type === "sunburst"
|
||||
) {
|
||||
countHierarchyNodes(
|
||||
series.data,
|
||||
limits.maxHierarchyNodes,
|
||||
`${path}.data`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
type !== "graph" &&
|
||||
type !== "sankey" &&
|
||||
type !== "chord"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeCount =
|
||||
arrayLength(series.data) + arrayLength(series.nodes);
|
||||
if (nodeCount > limits.maxGraphNodes) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`${path} 节点数量不能超过 ${limits.maxGraphNodes}`
|
||||
);
|
||||
}
|
||||
const edgeCount =
|
||||
arrayLength(series.links) + arrayLength(series.edges);
|
||||
if (edgeCount > limits.maxGraphEdges) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`${path} 边数量不能超过 ${limits.maxGraphEdges}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -333,6 +333,326 @@ function validateCandlestick(
|
||||
);
|
||||
}
|
||||
|
||||
function requireDirectArray(
|
||||
context: EChartsSeriesValidationContext,
|
||||
key: "data" | "nodes" | "links" | "edges",
|
||||
description: string
|
||||
): JsonValue[] {
|
||||
const value = context.series[key];
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.${key}`,
|
||||
`必须是${description}`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateHierarchyNodes(
|
||||
context: EChartsSeriesValidationContext,
|
||||
requireLeafValue: boolean
|
||||
) {
|
||||
const roots = requireDirectArray(
|
||||
context,
|
||||
"data",
|
||||
"非空层级节点数组"
|
||||
);
|
||||
const pending = roots.map((node, index) => ({
|
||||
node,
|
||||
path: `option.series.${context.seriesIndex}.data.${index}`
|
||||
}));
|
||||
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
if (!isObject(current.node)) {
|
||||
fail(current.path, "必须是层级节点对象");
|
||||
}
|
||||
const children = current.node.children;
|
||||
if (children !== undefined && !Array.isArray(children)) {
|
||||
fail(`${current.path}.children`, "必须是数组");
|
||||
}
|
||||
const childNodes = Array.isArray(children) ? children : [];
|
||||
childNodes.forEach((node, index) =>
|
||||
pending.push({
|
||||
node,
|
||||
path: `${current.path}.children.${index}`
|
||||
})
|
||||
);
|
||||
|
||||
if (!requireLeafValue || childNodes.length > 0) {
|
||||
continue;
|
||||
}
|
||||
const value = current.node.value;
|
||||
const valid =
|
||||
typeof value === "number"
|
||||
? Number.isFinite(value)
|
||||
: Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(item) =>
|
||||
typeof item === "number" && Number.isFinite(item)
|
||||
);
|
||||
if (!valid) {
|
||||
fail(
|
||||
`${current.path}.value`,
|
||||
"必须是有限数值或非空有限数值数组"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateTree(context: EChartsSeriesValidationContext) {
|
||||
validateHierarchyNodes(context, false);
|
||||
}
|
||||
|
||||
function validateTreemapOrSunburst(
|
||||
context: EChartsSeriesValidationContext
|
||||
) {
|
||||
validateHierarchyNodes(context, true);
|
||||
}
|
||||
|
||||
function relationNodes(
|
||||
context: EChartsSeriesValidationContext
|
||||
): { values: JsonValue[]; key: "data" | "nodes" } {
|
||||
if (
|
||||
Array.isArray(context.series.data) &&
|
||||
context.series.data.length > 0
|
||||
) {
|
||||
return { values: context.series.data, key: "data" };
|
||||
}
|
||||
return {
|
||||
values: requireDirectArray(
|
||||
context,
|
||||
"nodes",
|
||||
"非空节点数组"
|
||||
),
|
||||
key: "nodes"
|
||||
};
|
||||
}
|
||||
|
||||
function relationEdges(
|
||||
context: EChartsSeriesValidationContext,
|
||||
required: boolean
|
||||
): { values: JsonValue[]; key: "links" | "edges" } {
|
||||
if (
|
||||
Array.isArray(context.series.links) &&
|
||||
context.series.links.length > 0
|
||||
) {
|
||||
return { values: context.series.links, key: "links" };
|
||||
}
|
||||
if (
|
||||
Array.isArray(context.series.edges) &&
|
||||
context.series.edges.length > 0
|
||||
) {
|
||||
return { values: context.series.edges, key: "edges" };
|
||||
}
|
||||
if (required) {
|
||||
return {
|
||||
values: requireDirectArray(
|
||||
context,
|
||||
"links",
|
||||
"非空关系边数组"
|
||||
),
|
||||
key: "links"
|
||||
};
|
||||
}
|
||||
return { values: [], key: "links" };
|
||||
}
|
||||
|
||||
function nodeIdentifiers(node: JsonValue, index: number) {
|
||||
const identifiers = new Set<string>([`#${index}`]);
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
identifiers.add(String(node));
|
||||
return identifiers;
|
||||
}
|
||||
if (!isObject(node)) {
|
||||
return identifiers;
|
||||
}
|
||||
if (
|
||||
typeof node.id === "string" ||
|
||||
typeof node.id === "number"
|
||||
) {
|
||||
identifiers.add(String(node.id));
|
||||
}
|
||||
if (
|
||||
typeof node.name === "string" ||
|
||||
typeof node.name === "number"
|
||||
) {
|
||||
identifiers.add(String(node.name));
|
||||
}
|
||||
return identifiers;
|
||||
}
|
||||
|
||||
function edgeEndpointExists(
|
||||
value: JsonValue | undefined,
|
||||
identifiers: Set<string>,
|
||||
nodeCount: number
|
||||
) {
|
||||
if (
|
||||
typeof value !== "string" &&
|
||||
typeof value !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 0 &&
|
||||
value < nodeCount
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return identifiers.has(String(value));
|
||||
}
|
||||
|
||||
function validateRelationSeries(
|
||||
context: EChartsSeriesValidationContext,
|
||||
options: {
|
||||
requireEdges: boolean;
|
||||
requireEdgeValue: boolean;
|
||||
defaultGraphLayout?: boolean;
|
||||
}
|
||||
) {
|
||||
const nodes = relationNodes(context);
|
||||
const identifiers = new Set<string>();
|
||||
nodes.values.forEach((node, index) => {
|
||||
if (
|
||||
node === null ||
|
||||
Array.isArray(node) ||
|
||||
(typeof node !== "object" &&
|
||||
typeof node !== "string" &&
|
||||
typeof node !== "number")
|
||||
) {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.${nodes.key}.${index}`,
|
||||
"必须是节点对象、名称或数值"
|
||||
);
|
||||
}
|
||||
nodeIdentifiers(node, index).forEach((identifier) =>
|
||||
identifiers.add(identifier)
|
||||
);
|
||||
});
|
||||
|
||||
if (
|
||||
options.defaultGraphLayout &&
|
||||
context.series.layout === undefined
|
||||
) {
|
||||
const hasCoordinates = nodes.values.every(
|
||||
(node) =>
|
||||
isObject(node) &&
|
||||
typeof node.x === "number" &&
|
||||
Number.isFinite(node.x) &&
|
||||
typeof node.y === "number" &&
|
||||
Number.isFinite(node.y)
|
||||
);
|
||||
context.series.layout = hasCoordinates ? "none" : "force";
|
||||
}
|
||||
|
||||
const edges = relationEdges(context, options.requireEdges);
|
||||
edges.values.forEach((edge, edgeIndex) => {
|
||||
const path = `option.series.${context.seriesIndex}.${edges.key}.${edgeIndex}`;
|
||||
if (!isObject(edge)) {
|
||||
fail(path, "必须是关系边对象");
|
||||
}
|
||||
if (
|
||||
!edgeEndpointExists(
|
||||
edge.source,
|
||||
identifiers,
|
||||
nodes.values.length
|
||||
)
|
||||
) {
|
||||
fail(`${path}.source`, "必须引用已存在的节点");
|
||||
}
|
||||
if (
|
||||
!edgeEndpointExists(
|
||||
edge.target,
|
||||
identifiers,
|
||||
nodes.values.length
|
||||
)
|
||||
) {
|
||||
fail(`${path}.target`, "必须引用已存在的节点");
|
||||
}
|
||||
if (options.requireEdgeValue || edge.value !== undefined) {
|
||||
if (
|
||||
typeof edge.value !== "number" ||
|
||||
!Number.isFinite(edge.value) ||
|
||||
edge.value < 0
|
||||
) {
|
||||
fail(`${path}.value`, "必须是非负有限数值");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateGraph(context: EChartsSeriesValidationContext) {
|
||||
const coordinate = context.series.coordinateSystem;
|
||||
if (coordinate !== undefined && coordinate !== "none") {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||
`暂不支持 ${String(coordinate)}`
|
||||
);
|
||||
}
|
||||
validateRelationSeries(context, {
|
||||
requireEdges: false,
|
||||
requireEdgeValue: false,
|
||||
defaultGraphLayout: true
|
||||
});
|
||||
}
|
||||
|
||||
function validateSankey(context: EChartsSeriesValidationContext) {
|
||||
const coordinate = context.series.coordinateSystem;
|
||||
if (coordinate !== undefined && coordinate !== "view") {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||
`暂不支持 ${String(coordinate)}`
|
||||
);
|
||||
}
|
||||
validateRelationSeries(context, {
|
||||
requireEdges: true,
|
||||
requireEdgeValue: true
|
||||
});
|
||||
}
|
||||
|
||||
function validateChord(context: EChartsSeriesValidationContext) {
|
||||
const coordinate = context.series.coordinateSystem;
|
||||
if (coordinate !== undefined && coordinate !== "none") {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||
`暂不支持 ${String(coordinate)}`
|
||||
);
|
||||
}
|
||||
validateRelationSeries(context, {
|
||||
requireEdges: true,
|
||||
requireEdgeValue: true
|
||||
});
|
||||
}
|
||||
|
||||
function validateFiniteValueData(
|
||||
context: EChartsSeriesValidationContext
|
||||
) {
|
||||
requireData(context);
|
||||
if (context.hasDataset) {
|
||||
return;
|
||||
}
|
||||
for (const [dataIndex, item] of dataValues(
|
||||
context.series
|
||||
).entries()) {
|
||||
const value = itemValue(item);
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isFinite(value)
|
||||
) {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.data.${dataIndex}.value`,
|
||||
"必须是有限数值"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const echartsSeriesValidators: ReadonlyMap<
|
||||
SupportedEChartsSeriesType,
|
||||
EChartsSeriesValidator
|
||||
@@ -344,7 +664,16 @@ export const echartsSeriesValidators: ReadonlyMap<
|
||||
["radar", validateRadar],
|
||||
["heatmap", validateHeatmap],
|
||||
["boxplot", validateBoxplot],
|
||||
["candlestick", validateCandlestick]
|
||||
["candlestick", validateCandlestick],
|
||||
["tree", validateTree],
|
||||
["treemap", validateTreemapOrSunburst],
|
||||
["sunburst", validateTreemapOrSunburst],
|
||||
["graph", validateGraph],
|
||||
["sankey", validateSankey],
|
||||
["chord", validateChord],
|
||||
["funnel", validateFiniteValueData],
|
||||
["gauge", validateFiniteValueData],
|
||||
["pictorialBar", validateLineOrBar]
|
||||
]);
|
||||
|
||||
export function validateAndApplyEChartsOptionDefaults(
|
||||
|
||||
@@ -37,12 +37,14 @@ function fakeEngine(
|
||||
) {
|
||||
const dispose = vi.fn();
|
||||
const resize = vi.fn();
|
||||
const setOption = vi.fn();
|
||||
const init = vi.fn(
|
||||
(container: HTMLElement): EChartsInstanceLike => {
|
||||
dispose.mockImplementation(() => container.replaceChildren());
|
||||
const handlers = new Map<string, () => void>();
|
||||
return {
|
||||
setOption() {
|
||||
setOption(option, setOptionOptions) {
|
||||
setOption(option, setOptionOptions);
|
||||
const svg = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg"
|
||||
@@ -73,7 +75,8 @@ function fakeEngine(
|
||||
engine: { init } satisfies EChartsEngine,
|
||||
init,
|
||||
dispose,
|
||||
resize
|
||||
resize,
|
||||
setOption
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,6 +106,36 @@ describe("浏览器 ECharts 渲染", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("全局及系列级动画始终关闭", async () => {
|
||||
createDocumentWithChart();
|
||||
const { engine, setOption } = fakeEngine();
|
||||
|
||||
await renderEChartsBlocks(document, {
|
||||
engine,
|
||||
outputMode: "interactive"
|
||||
});
|
||||
|
||||
expect(setOption).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
animation: false,
|
||||
animationDelay: 0,
|
||||
animationDelayUpdate: 0,
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: 0,
|
||||
series: [
|
||||
expect.objectContaining({
|
||||
animation: false,
|
||||
animationDelay: 0,
|
||||
animationDelayUpdate: 0,
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: 0
|
||||
})
|
||||
]
|
||||
}),
|
||||
{ notMerge: true }
|
||||
);
|
||||
});
|
||||
|
||||
it("生成 SVG Data URL 图片", async () => {
|
||||
createDocumentWithChart();
|
||||
const { engine } = fakeEngine();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultEChartsSecurityLimits,
|
||||
normalizeAndValidateEChartsOption
|
||||
} from "../src/index.js";
|
||||
|
||||
@@ -47,6 +48,73 @@ describe("normalizeAndValidateEChartsOption", () => {
|
||||
).toThrow(/暂不支持 custom/u);
|
||||
});
|
||||
|
||||
it("接受第二阶段系列和安全的内嵌矢量路径", () => {
|
||||
const types = [
|
||||
"tree",
|
||||
"treemap",
|
||||
"sunburst",
|
||||
"graph",
|
||||
"sankey",
|
||||
"chord",
|
||||
"funnel",
|
||||
"gauge",
|
||||
"pictorialBar"
|
||||
];
|
||||
expect(
|
||||
normalizeAndValidateEChartsOption({
|
||||
series: types.map((type) => ({
|
||||
type,
|
||||
...(type === "pictorialBar"
|
||||
? { symbol: "path://M0,0 L10,0 L10,10 Z" }
|
||||
: {})
|
||||
}))
|
||||
}).series
|
||||
).toHaveLength(types.length);
|
||||
});
|
||||
|
||||
it("限制层级节点和关系边数量", () => {
|
||||
const limits = {
|
||||
...defaultEChartsSecurityLimits,
|
||||
maxHierarchyNodes: 2,
|
||||
maxGraphEdges: 1
|
||||
};
|
||||
expect(() =>
|
||||
normalizeAndValidateEChartsOption(
|
||||
{
|
||||
series: [
|
||||
{
|
||||
type: "tree",
|
||||
data: [
|
||||
{
|
||||
children: [{ value: 1 }, { value: 2 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
limits
|
||||
)
|
||||
).toThrow(/层级节点数量不能超过 2/u);
|
||||
|
||||
expect(() =>
|
||||
normalizeAndValidateEChartsOption(
|
||||
{
|
||||
series: [
|
||||
{
|
||||
type: "graph",
|
||||
data: [{ name: "A" }, { name: "B" }],
|
||||
links: [
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "B", target: "A" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
limits
|
||||
)
|
||||
).toThrow(/边数量不能超过 1/u);
|
||||
});
|
||||
|
||||
it("拒绝外部资源和危险链接", () => {
|
||||
expect(() =>
|
||||
normalizeAndValidateEChartsOption({
|
||||
|
||||
@@ -182,4 +182,184 @@ describe("ECharts 系列语义验证", () => {
|
||||
})
|
||||
).toThrow(/option\.polar/u);
|
||||
});
|
||||
|
||||
it("支持树图并校验递归 children 结构", () => {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "tree",
|
||||
data: [
|
||||
{
|
||||
name: "根节点",
|
||||
children: [{ name: "子节点" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({
|
||||
series: [{ type: "tree" }]
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "tree",
|
||||
data: [{ name: "根节点", children: "错误" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/data\.0\.children 必须是数组/u);
|
||||
});
|
||||
|
||||
it("矩形树图和旭日图要求叶节点数值", () => {
|
||||
for (const type of ["treemap", "sunburst"]) {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [
|
||||
{
|
||||
name: "分类",
|
||||
children: [{ name: "项目", value: 12 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({ series: [{ type }] });
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [{ name: "无数值叶节点" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/data\.0\.value 必须是有限数值/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("关系图默认选择可渲染布局并校验边引用", () => {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "graph",
|
||||
data: [{ name: "A" }, { name: "B" }],
|
||||
links: [{ source: "A", target: "B" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({
|
||||
series: [{ type: "graph", layout: "force" }]
|
||||
});
|
||||
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "graph",
|
||||
data: [
|
||||
{ name: "A", x: 10, y: 20 },
|
||||
{ name: "B", x: 30, y: 40 }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({
|
||||
series: [{ type: "graph", layout: "none" }]
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "graph",
|
||||
nodes: [{ name: "A" }],
|
||||
edges: [{ source: "A", target: "B" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/edges\.0\.target 必须引用已存在的节点/u);
|
||||
});
|
||||
|
||||
it("桑基图和和弦图要求有效节点、关系边和权重", () => {
|
||||
for (const type of ["sankey", "chord"]) {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [{ name: "A" }, { name: "B" }],
|
||||
links: [
|
||||
{ source: "A", target: "B", value: 12 }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({ series: [{ type }] });
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [{ name: "A" }, { name: "B" }],
|
||||
links: [{ source: "A", target: "B" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/links\.0\.value 必须是非负有限数值/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("漏斗图和仪表盘要求有限数值", () => {
|
||||
for (const type of ["funnel", "gauge"]) {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [{ name: "完成率", value: 72 }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({ series: [{ type }] });
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [{ name: "完成率", value: "72" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/data\.0\.value 必须是有限数值/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("象形柱图复用柱图数据和坐标轴默认值", () => {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "pictorialBar",
|
||||
symbol: "path://M0,0 L10,0 L10,10 Z",
|
||||
data: [12, 18]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({
|
||||
xAxis: {},
|
||||
yAxis: {},
|
||||
series: [{ type: "pictorialBar" }]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user