feat: 增加 ECharts 渲染与预览交互增强
- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染 - 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略 - 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步 - 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放 - 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user