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
+120 -3
View File
@@ -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
+10 -1
View File
@@ -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" }]
});
});
});