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