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
@@ -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(