feat: 扩展 ECharts 第二阶段图表支持

新增 tree、treemap、sunburst、graph、sankey、chord、funnel、gauge 和 pictorialBar 系列,并注册对应的 ECharts 按需模块。

完善层级与关系数据校验、节点和边数量限制、默认布局及错误占位规则,同时补充默认 Markdown 与 README 示例。

静态渲染全局关闭 ECharts 动画,修复 Treemap 标签冻结在透明动画帧的问题,并完成快速预览、精确 PDF、容器镜像和 147 项全项目测试验证。
This commit is contained in:
SkyJourney
2026-07-27 17:47:28 +08:00
parent f5a46f79f1
commit 92a5bfd016
13 changed files with 1162 additions and 21 deletions
@@ -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" }]
});
});
});