feat: 增加 ECharts 渲染与预览交互增强

- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染
- 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略
- 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步
- 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放
- 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
This commit is contained in:
SkyJourney
2026-07-27 16:05:14 +08:00
parent 1db21c80fc
commit f5a46f79f1
53 changed files with 3998 additions and 221 deletions
@@ -0,0 +1,372 @@
import {
EChartsFenceError,
type JsonValue,
type SupportedEChartsSeriesType
} from "./protocol.js";
type JsonObject = Record<string, JsonValue>;
export interface EChartsSeriesValidationContext {
option: JsonObject;
series: JsonObject;
seriesIndex: number;
hasDataset: boolean;
}
export type EChartsSeriesValidator = (
context: EChartsSeriesValidationContext
) => void;
function fail(path: string, message: string): never {
throw new EChartsFenceError(
"INVALID_OPTION",
`${path} ${message}`
);
}
function isObject(value: JsonValue | undefined): value is JsonObject {
return (
value !== null &&
value !== undefined &&
!Array.isArray(value) &&
typeof value === "object"
);
}
function asObjects(value: JsonValue | undefined): JsonObject[] {
if (Array.isArray(value)) {
return value.filter(isObject);
}
return isObject(value) ? [value] : [];
}
function isNonEmptyData(value: JsonValue | undefined) {
if (Array.isArray(value)) {
return value.length > 0;
}
return isObject(value) && Object.keys(value).length > 0;
}
function datasetHasRows(dataset: JsonObject) {
const source = dataset.source;
if (Array.isArray(source)) {
if (source.length === 0) {
return false;
}
const firstRow = source[0];
const hasHeader =
dataset.sourceHeader === true ||
(Array.isArray(firstRow) &&
firstRow.length > 0 &&
firstRow.every((value) => typeof value === "string"));
return hasHeader ? source.length > 1 : true;
}
if (isObject(source)) {
return Object.values(source).some(
(column) => Array.isArray(column) && column.length > 0
);
}
return false;
}
function datasets(option: JsonObject) {
return asObjects(option.dataset);
}
function datasetHasSource(
option: JsonObject,
datasetIndex: number,
visited = new Set<number>()
): boolean {
const allDatasets = datasets(option);
const dataset = allDatasets[datasetIndex];
if (!dataset || visited.has(datasetIndex)) {
return false;
}
visited.add(datasetIndex);
if (datasetHasRows(dataset)) {
return true;
}
if (dataset.transform === undefined) {
return false;
}
const upstream = dataset.fromDatasetIndex;
if (
typeof upstream === "number" &&
Number.isInteger(upstream) &&
upstream >= 0
) {
return datasetHasSource(option, upstream, visited);
}
return allDatasets.some(
(candidate, index) =>
index !== datasetIndex && datasetHasRows(candidate)
);
}
function seriesHasDataset(option: JsonObject, series: JsonObject) {
const datasetIndex =
typeof series.datasetIndex === "number" &&
Number.isInteger(series.datasetIndex)
? series.datasetIndex
: 0;
return datasetHasSource(option, datasetIndex);
}
function requireData(context: EChartsSeriesValidationContext) {
if (
!isNonEmptyData(context.series.data) &&
!context.hasDataset
) {
fail(
`option.series.${context.seriesIndex}.data`,
"必须是非空数组,或通过 dataset.source 提供数据"
);
}
}
function ensureCartesianAxes(option: JsonObject) {
option.xAxis ??= {};
option.yAxis ??= {};
}
function requireComponent(
option: JsonObject,
name: string,
seriesIndex: number
) {
if (option[name] === undefined) {
fail(
`option.${name}`,
`是 option.series.${seriesIndex} 所用坐标系的必填组件`
);
}
}
function coordinateSystem(series: JsonObject) {
return typeof series.coordinateSystem === "string"
? series.coordinateSystem
: "cartesian2d";
}
function validateCartesianOrPolar(
context: EChartsSeriesValidationContext
) {
const coordinate = coordinateSystem(context.series);
if (coordinate === "cartesian2d") {
ensureCartesianAxes(context.option);
return;
}
if (coordinate === "polar") {
requireComponent(
context.option,
"polar",
context.seriesIndex
);
requireComponent(
context.option,
"angleAxis",
context.seriesIndex
);
requireComponent(
context.option,
"radiusAxis",
context.seriesIndex
);
return;
}
fail(
`option.series.${context.seriesIndex}.coordinateSystem`,
`暂不支持 ${coordinate}`
);
}
function dataValues(series: JsonObject): JsonValue[] {
return Array.isArray(series.data) ? series.data : [];
}
function itemValue(item: JsonValue): JsonValue {
return isObject(item) && item.value !== undefined
? item.value
: item;
}
function requireTupleData(
context: EChartsSeriesValidationContext,
minimumLength: number,
description: string
) {
if (context.hasDataset) {
return;
}
for (const [dataIndex, item] of dataValues(
context.series
).entries()) {
const value = itemValue(item);
if (!Array.isArray(value) || value.length < minimumLength) {
fail(
`option.series.${context.seriesIndex}.data.${dataIndex}`,
`必须是${description}`
);
}
}
}
function validateLineOrBar(
context: EChartsSeriesValidationContext
) {
requireData(context);
validateCartesianOrPolar(context);
}
function validateScatter(
context: EChartsSeriesValidationContext
) {
requireData(context);
const coordinate = coordinateSystem(context.series);
if (coordinate === "calendar") {
requireComponent(
context.option,
"calendar",
context.seriesIndex
);
requireTupleData(context, 2, "至少包含日期和值的数组");
return;
}
validateCartesianOrPolar(context);
requireTupleData(context, 2, "至少包含两个坐标值的数组");
}
function validatePie(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`,
"必须是有限数值"
);
}
}
}
function radarIndicators(option: JsonObject) {
return asObjects(option.radar).flatMap((radar) =>
Array.isArray(radar.indicator)
? radar.indicator.filter(isObject)
: []
);
}
function validateRadar(context: EChartsSeriesValidationContext) {
requireData(context);
const indicators = radarIndicators(context.option);
if (indicators.length === 0) {
fail(
"option.radar.indicator",
"是雷达图的必填非空数组"
);
}
if (context.hasDataset) {
return;
}
requireTupleData(
context,
indicators.length,
`至少包含 ${indicators.length} 个指标值的数组`
);
}
function validateHeatmap(context: EChartsSeriesValidationContext) {
requireData(context);
const coordinate = coordinateSystem(context.series);
if (coordinate === "calendar") {
requireComponent(
context.option,
"calendar",
context.seriesIndex
);
requireTupleData(context, 2, "包含日期和值的数组");
return;
}
if (coordinate !== "cartesian2d") {
fail(
`option.series.${context.seriesIndex}.coordinateSystem`,
`暂不支持 ${coordinate}`
);
}
ensureCartesianAxes(context.option);
requireTupleData(context, 3, "包含 x、y、值的三元数组");
}
function validateBoxplot(
context: EChartsSeriesValidationContext
) {
requireData(context);
ensureCartesianAxes(context.option);
requireTupleData(
context,
5,
"包含最小值、Q1、中位数、Q3、最大值的五元数组"
);
}
function validateCandlestick(
context: EChartsSeriesValidationContext
) {
requireData(context);
ensureCartesianAxes(context.option);
requireTupleData(
context,
4,
"包含开盘、收盘、最低、最高值的四元数组"
);
}
export const echartsSeriesValidators: ReadonlyMap<
SupportedEChartsSeriesType,
EChartsSeriesValidator
> = new Map([
["line", validateLineOrBar],
["bar", validateLineOrBar],
["pie", validatePie],
["scatter", validateScatter],
["radar", validateRadar],
["heatmap", validateHeatmap],
["boxplot", validateBoxplot],
["candlestick", validateCandlestick]
]);
export function validateAndApplyEChartsOptionDefaults(
option: JsonObject
) {
const series = option.series;
if (!Array.isArray(series)) {
return option;
}
for (const [seriesIndex, value] of series.entries()) {
if (!isObject(value) || typeof value.type !== "string") {
continue;
}
const type = value.type as SupportedEChartsSeriesType;
const validator = echartsSeriesValidators.get(type);
validator?.({
option,
series: value,
seriesIndex,
hasDataset: seriesHasDataset(option, value)
});
}
return option;
}