/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ /** * Unified ECharts Options Schema * * This file serves as the single source of truth for: * 1. Runtime validation (Zod schema) * 2. TypeScript types (inferred from Zod) * * Reference: https://echarts.apache.org/en/option.html */ import { z } from 'zod'; import { sanitizeHtml } from '@superset-ui/core'; // ============================================================================= // Common Schemas // ============================================================================= /** Color value - hex, rgb, rgba, or named color */ const colorSchema = z.string(); /** Numeric or percentage string (e.g., '50%') */ const numberOrPercentSchema = z.union([z.number(), z.string()]); /** Line type */ const lineTypeSchema = z.union([ z.enum(['solid', 'dashed', 'dotted']), z.array(z.number()), ]); /** Font weight */ const fontWeightSchema = z.union([ z.enum(['normal', 'bold', 'bolder', 'lighter']), z.number().min(100).max(900), ]); /** Font style */ const fontStyleSchema = z.enum(['normal', 'italic', 'oblique']); /** Symbol type */ const symbolTypeSchema = z.string(); /** * With the ECharts default renderMode 'html', a string tooltip formatter is * assigned to the tooltip DOM element via innerHTML. ECharts formatter * strings commonly rely on inline markup (e.g. '{b}
{c}') for layout, so * rejecting every '<' would break that supported usage; instead the value is * run through the same allowlist sanitizer used for other tooltip HTML, * which keeps presentational tags and strips anything else. */ const sanitizedFormatterSchema = z .string() .transform(value => sanitizeHtml(value)); /** * ECharts navigates to title.link/sublink on click, so restrict them to * http(s) and same-origin relative paths. */ const safeLinkSchema = z .string() .refine( value => /^https?:\/\//i.test(value) || (value.startsWith('/') && !value.startsWith('//')), { message: 'Only http(s) or same-origin relative URLs are allowed', }, ); // ============================================================================= // Text Style Schema // ============================================================================= export const textStyleSchema = z.object({ color: colorSchema.optional(), fontStyle: fontStyleSchema.optional(), fontWeight: fontWeightSchema.optional(), fontFamily: z.string().optional(), fontSize: z.number().optional(), lineHeight: z.number().optional(), width: numberOrPercentSchema.optional(), height: numberOrPercentSchema.optional(), textBorderColor: colorSchema.optional(), textBorderWidth: z.number().optional(), textBorderType: lineTypeSchema.optional(), textBorderDashOffset: z.number().optional(), textShadowColor: colorSchema.optional(), textShadowBlur: z.number().optional(), textShadowOffsetX: z.number().optional(), textShadowOffsetY: z.number().optional(), overflow: z.enum(['none', 'truncate', 'break', 'breakAll']).optional(), ellipsis: z.string().optional(), }); // ============================================================================= // Style Schemas // ============================================================================= /** Repeating tile pattern painted over a fill, e.g. hatching */ export const decalSchema = z.object({ symbol: z.union([symbolTypeSchema, z.array(symbolTypeSchema)]).optional(), symbolSize: z.number().optional(), symbolKeepAspect: z.boolean().optional(), color: colorSchema.optional(), backgroundColor: colorSchema.optional(), dashArrayX: z .union([z.number(), z.array(z.union([z.number(), z.array(z.number())]))]) .optional(), dashArrayY: z.union([z.number(), z.array(z.number())]).optional(), /** Radians, not degrees. */ rotation: z.number().optional(), maxTileWidth: z.number().optional(), maxTileHeight: z.number().optional(), }); export const lineStyleSchema = z.object({ color: colorSchema.optional(), width: z.number().optional(), type: lineTypeSchema.optional(), dashOffset: z.number().optional(), cap: z.enum(['butt', 'round', 'square']).optional(), join: z.enum(['bevel', 'round', 'miter']).optional(), miterLimit: z.number().optional(), shadowBlur: z.number().optional(), shadowColor: colorSchema.optional(), shadowOffsetX: z.number().optional(), shadowOffsetY: z.number().optional(), opacity: z.number().min(0).max(1).optional(), }); export const areaStyleSchema = z.object({ color: z.union([colorSchema, z.array(colorSchema)]).optional(), origin: z.union([z.enum(['auto', 'start', 'end']), z.number()]).optional(), shadowBlur: z.number().optional(), shadowColor: colorSchema.optional(), shadowOffsetX: z.number().optional(), shadowOffsetY: z.number().optional(), opacity: z.number().min(0).max(1).optional(), }); export const itemStyleSchema = z.object({ color: colorSchema.optional(), borderColor: colorSchema.optional(), borderWidth: z.number().optional(), borderType: lineTypeSchema.optional(), borderRadius: z.union([z.number(), z.array(z.number())]).optional(), shadowBlur: z.number().optional(), shadowColor: colorSchema.optional(), shadowOffsetX: z.number().optional(), shadowOffsetY: z.number().optional(), opacity: z.number().min(0).max(1).optional(), decal: decalSchema.optional(), }); // ============================================================================= // Label Schema // ============================================================================= export const labelSchema = z.object({ show: z.boolean().optional(), position: z .enum([ 'top', 'left', 'right', 'bottom', 'inside', 'insideLeft', 'insideRight', 'insideTop', 'insideBottom', 'insideTopLeft', 'insideBottomLeft', 'insideTopRight', 'insideBottomRight', 'outside', ]) .optional(), distance: z.number().optional(), rotate: z.number().optional(), offset: z.array(z.number()).optional(), formatter: z.string().optional(), // Only string formatters allowed, not functions color: colorSchema.optional(), fontStyle: fontStyleSchema.optional(), fontWeight: fontWeightSchema.optional(), fontFamily: z.string().optional(), fontSize: z.number().optional(), lineHeight: z.number().optional(), }); // ============================================================================= // Title Schema // ============================================================================= export const titleSchema = z.object({ id: z.string().optional(), show: z.boolean().optional(), text: z.string().optional(), link: safeLinkSchema.optional(), target: z.enum(['self', 'blank']).optional(), textStyle: textStyleSchema.optional(), subtext: z.string().optional(), sublink: safeLinkSchema.optional(), subtarget: z.enum(['self', 'blank']).optional(), subtextStyle: textStyleSchema.optional(), textAlign: z.enum(['left', 'center', 'right']).optional(), textVerticalAlign: z.enum(['top', 'middle', 'bottom']).optional(), triggerEvent: z.boolean().optional(), padding: z.union([z.number(), z.array(z.number())]).optional(), itemGap: z.number().optional(), zlevel: z.number().optional(), z: z.number().optional(), left: numberOrPercentSchema.optional(), top: numberOrPercentSchema.optional(), right: numberOrPercentSchema.optional(), bottom: numberOrPercentSchema.optional(), backgroundColor: colorSchema.optional(), borderColor: colorSchema.optional(), borderWidth: z.number().optional(), borderRadius: z.union([z.number(), z.array(z.number())]).optional(), shadowBlur: z.number().optional(), shadowColor: colorSchema.optional(), shadowOffsetX: z.number().optional(), shadowOffsetY: z.number().optional(), }); // ============================================================================= // Legend Schema // ============================================================================= export const legendSchema = z.object({ id: z.string().optional(), type: z.enum(['plain', 'scroll']).optional(), show: z.boolean().optional(), zlevel: z.number().optional(), z: z.number().optional(), left: numberOrPercentSchema.optional(), top: numberOrPercentSchema.optional(), right: numberOrPercentSchema.optional(), bottom: numberOrPercentSchema.optional(), width: numberOrPercentSchema.optional(), height: numberOrPercentSchema.optional(), orient: z.enum(['horizontal', 'vertical']).optional(), align: z.enum(['auto', 'left', 'right']).optional(), padding: z.union([z.number(), z.array(z.number())]).optional(), itemGap: z.number().optional(), itemWidth: z.number().optional(), itemHeight: z.number().optional(), itemStyle: itemStyleSchema.optional(), lineStyle: lineStyleSchema.optional(), textStyle: textStyleSchema.optional(), icon: symbolTypeSchema.optional(), selectedMode: z .union([z.boolean(), z.enum(['single', 'multiple', 'series'])]) .optional(), inactiveColor: colorSchema.optional(), inactiveBorderColor: colorSchema.optional(), inactiveBorderWidth: z.number().optional(), backgroundColor: colorSchema.optional(), borderColor: colorSchema.optional(), borderWidth: z.number().optional(), borderRadius: z.union([z.number(), z.array(z.number())]).optional(), shadowBlur: z.number().optional(), shadowColor: colorSchema.optional(), shadowOffsetX: z.number().optional(), shadowOffsetY: z.number().optional(), pageButtonItemGap: z.number().optional(), pageButtonGap: z.number().optional(), pageButtonPosition: z.enum(['start', 'end']).optional(), pageIconColor: colorSchema.optional(), pageIconInactiveColor: colorSchema.optional(), pageIconSize: z.union([z.number(), z.array(z.number())]).optional(), pageTextStyle: textStyleSchema.optional(), }); // ============================================================================= // Grid Schema // ============================================================================= export const gridSchema = z.object({ id: z.string().optional(), show: z.boolean().optional(), zlevel: z.number().optional(), z: z.number().optional(), left: numberOrPercentSchema.optional(), top: numberOrPercentSchema.optional(), right: numberOrPercentSchema.optional(), bottom: numberOrPercentSchema.optional(), width: numberOrPercentSchema.optional(), height: numberOrPercentSchema.optional(), containLabel: z.boolean().optional(), backgroundColor: colorSchema.optional(), borderColor: colorSchema.optional(), borderWidth: z.number().optional(), shadowBlur: z.number().optional(), shadowColor: colorSchema.optional(), shadowOffsetX: z.number().optional(), shadowOffsetY: z.number().optional(), }); // ============================================================================= // Axis Schemas // ============================================================================= const axisLineSchema = z.object({ show: z.boolean().optional(), onZero: z.boolean().optional(), onZeroAxisIndex: z.number().optional(), symbol: z.union([z.string(), z.array(z.string())]).optional(), symbolSize: z.array(z.number()).optional(), symbolOffset: z.union([z.number(), z.array(z.number())]).optional(), lineStyle: lineStyleSchema.optional(), }); const axisTickSchema = z.object({ show: z.boolean().optional(), alignWithLabel: z.boolean().optional(), interval: z.union([z.number(), z.literal('auto')]).optional(), inside: z.boolean().optional(), length: z.number().optional(), lineStyle: lineStyleSchema.optional(), }); const axisLabelSchema = z.object({ show: z.boolean().optional(), interval: z.union([z.number(), z.literal('auto')]).optional(), inside: z.boolean().optional(), rotate: z.number().optional(), margin: z.number().optional(), formatter: z.string().optional(), // Only string formatters showMinLabel: z.boolean().optional(), showMaxLabel: z.boolean().optional(), hideOverlap: z.boolean().optional(), color: colorSchema.optional(), fontStyle: fontStyleSchema.optional(), fontWeight: fontWeightSchema.optional(), fontFamily: z.string().optional(), fontSize: z.number().optional(), align: z.enum(['left', 'center', 'right']).optional(), verticalAlign: z.enum(['top', 'middle', 'bottom']).optional(), }); const splitLineSchema = z.object({ show: z.boolean().optional(), interval: z.union([z.number(), z.literal('auto')]).optional(), lineStyle: lineStyleSchema.optional(), }); const splitAreaSchema = z.object({ show: z.boolean().optional(), interval: z.union([z.number(), z.literal('auto')]).optional(), areaStyle: areaStyleSchema.optional(), }); export const axisSchema = z.object({ id: z.string().optional(), show: z.boolean().optional(), gridIndex: z.number().optional(), alignTicks: z.boolean().optional(), position: z.enum(['top', 'bottom', 'left', 'right']).optional(), offset: z.number().optional(), type: z.enum(['value', 'category', 'time', 'log']).optional(), name: z.string().optional(), nameLocation: z.enum(['start', 'middle', 'center', 'end']).optional(), nameTextStyle: textStyleSchema.optional(), nameGap: z.number().optional(), nameRotate: z.number().optional(), inverse: z.boolean().optional(), boundaryGap: z .union([z.boolean(), z.array(z.union([z.string(), z.number()]))]) .optional(), min: z.union([z.number(), z.string(), z.literal('dataMin')]).optional(), max: z.union([z.number(), z.string(), z.literal('dataMax')]).optional(), scale: z.boolean().optional(), splitNumber: z.number().optional(), minInterval: z.number().optional(), maxInterval: z.number().optional(), interval: z.number().optional(), logBase: z.number().optional(), silent: z.boolean().optional(), triggerEvent: z.boolean().optional(), axisLine: axisLineSchema.optional(), axisTick: axisTickSchema.optional(), minorTick: axisTickSchema.optional(), axisLabel: axisLabelSchema.optional(), splitLine: splitLineSchema.optional(), minorSplitLine: splitLineSchema.optional(), splitArea: splitAreaSchema.optional(), zlevel: z.number().optional(), z: z.number().optional(), }); // ============================================================================= // Tooltip Schema // ============================================================================= export const tooltipSchema = z.object({ show: z.boolean().optional(), trigger: z.enum(['item', 'axis', 'none']).optional(), triggerOn: z .enum(['mousemove', 'click', 'mousemove|click', 'none']) .optional(), alwaysShowContent: z.boolean().optional(), showDelay: z.number().optional(), hideDelay: z.number().optional(), enterable: z.boolean().optional(), renderMode: z.enum(['html', 'richText']).optional(), confine: z.boolean().optional(), appendToBody: z.boolean().optional(), transitionDuration: z.number().optional(), position: z .union([ z.enum(['inside', 'top', 'left', 'right', 'bottom']), z.array(z.union([z.number(), z.string()])), ]) .optional(), // Only string formatters: a string tooltip formatter is rendered via // innerHTML (default renderMode 'html'), so it is sanitized above. formatter: sanitizedFormatterSchema.optional(), padding: z.union([z.number(), z.array(z.number())]).optional(), backgroundColor: colorSchema.optional(), borderColor: colorSchema.optional(), borderWidth: z.number().optional(), borderRadius: z.number().optional(), shadowBlur: z.number().optional(), shadowColor: colorSchema.optional(), shadowOffsetX: z.number().optional(), shadowOffsetY: z.number().optional(), textStyle: textStyleSchema.optional(), // `extraCssText` is intentionally not accepted; unknown keys are // stripped by the schema, so configs that still carry it keep working // minus the raw CSS. order: z .enum(['seriesAsc', 'seriesDesc', 'valueAsc', 'valueDesc']) .optional(), }); // ============================================================================= // DataZoom Schema // ============================================================================= export const dataZoomSchema = z.object({ type: z.enum(['slider', 'inside']).optional(), id: z.string().optional(), show: z.boolean().optional(), disabled: z.boolean().optional(), xAxisIndex: z.union([z.number(), z.array(z.number())]).optional(), yAxisIndex: z.union([z.number(), z.array(z.number())]).optional(), filterMode: z.enum(['filter', 'weakFilter', 'empty', 'none']).optional(), start: z.number().optional(), end: z.number().optional(), startValue: z.union([z.number(), z.string()]).optional(), endValue: z.union([z.number(), z.string()]).optional(), minSpan: z.number().optional(), maxSpan: z.number().optional(), minValueSpan: z.union([z.number(), z.string()]).optional(), maxValueSpan: z.union([z.number(), z.string()]).optional(), orient: z.enum(['horizontal', 'vertical']).optional(), zoomLock: z.boolean().optional(), throttle: z.number().optional(), rangeMode: z.array(z.enum(['value', 'percent'])).optional(), zlevel: z.number().optional(), z: z.number().optional(), left: numberOrPercentSchema.optional(), top: numberOrPercentSchema.optional(), right: numberOrPercentSchema.optional(), bottom: numberOrPercentSchema.optional(), width: numberOrPercentSchema.optional(), height: numberOrPercentSchema.optional(), backgroundColor: colorSchema.optional(), borderColor: colorSchema.optional(), borderRadius: z.number().optional(), fillerColor: colorSchema.optional(), handleSize: numberOrPercentSchema.optional(), handleStyle: itemStyleSchema.optional(), moveHandleSize: z.number().optional(), moveHandleStyle: itemStyleSchema.optional(), labelPrecision: z.union([z.number(), z.literal('auto')]).optional(), textStyle: textStyleSchema.optional(), realtime: z.boolean().optional(), showDetail: z.boolean().optional(), showDataShadow: z.union([z.boolean(), z.literal('auto')]).optional(), zoomOnMouseWheel: z .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])]) .optional(), moveOnMouseMove: z .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])]) .optional(), moveOnMouseWheel: z .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])]) .optional(), preventDefaultMouseMove: z.boolean().optional(), }); // ============================================================================= // Toolbox Schema // ============================================================================= export const toolboxSchema = z.object({ id: z.string().optional(), show: z.boolean().optional(), orient: z.enum(['horizontal', 'vertical']).optional(), itemSize: z.number().optional(), itemGap: z.number().optional(), showTitle: z.boolean().optional(), feature: z.record(z.string(), z.unknown()).optional(), iconStyle: itemStyleSchema.optional(), emphasis: z .object({ iconStyle: itemStyleSchema.optional(), }) .optional(), zlevel: z.number().optional(), z: z.number().optional(), left: numberOrPercentSchema.optional(), top: numberOrPercentSchema.optional(), right: numberOrPercentSchema.optional(), bottom: numberOrPercentSchema.optional(), width: numberOrPercentSchema.optional(), height: numberOrPercentSchema.optional(), }); // ============================================================================= // VisualMap Schema // ============================================================================= export const visualMapSchema = z.object({ type: z.enum(['continuous', 'piecewise']).optional(), id: z.string().optional(), min: z.number().optional(), max: z.number().optional(), range: z.array(z.number()).optional(), calculable: z.boolean().optional(), realtime: z.boolean().optional(), inverse: z.boolean().optional(), precision: z.number().optional(), itemWidth: z.number().optional(), itemHeight: z.number().optional(), align: z.enum(['auto', 'left', 'right', 'top', 'bottom']).optional(), text: z.array(z.string()).optional(), textGap: z.number().optional(), show: z.boolean().optional(), dimension: z.union([z.number(), z.string()]).optional(), seriesIndex: z.union([z.number(), z.array(z.number())]).optional(), hoverLink: z.boolean().optional(), inRange: z.record(z.string(), z.unknown()).optional(), outOfRange: z.record(z.string(), z.unknown()).optional(), zlevel: z.number().optional(), z: z.number().optional(), left: numberOrPercentSchema.optional(), top: numberOrPercentSchema.optional(), right: numberOrPercentSchema.optional(), bottom: numberOrPercentSchema.optional(), orient: z.enum(['horizontal', 'vertical']).optional(), padding: z.union([z.number(), z.array(z.number())]).optional(), backgroundColor: colorSchema.optional(), borderColor: colorSchema.optional(), borderWidth: z.number().optional(), color: z.array(colorSchema).optional(), textStyle: textStyleSchema.optional(), splitNumber: z.number().optional(), pieces: z.array(z.record(z.string(), z.unknown())).optional(), categories: z.array(z.string()).optional(), minOpen: z.boolean().optional(), maxOpen: z.boolean().optional(), selectedMode: z .union([z.boolean(), z.enum(['single', 'multiple'])]) .optional(), showLabel: z.boolean().optional(), itemGap: z.number().optional(), itemSymbol: symbolTypeSchema.optional(), }); // ============================================================================= // Series Schema // ============================================================================= const emphasisSchema = z.object({ disabled: z.boolean().optional(), focus: z .enum(['none', 'self', 'series', 'ancestor', 'descendant', 'relative']) .optional(), blurScope: z.enum(['coordinateSystem', 'series', 'global']).optional(), scale: z.union([z.boolean(), z.number()]).optional(), label: labelSchema.optional(), itemStyle: itemStyleSchema.optional(), lineStyle: lineStyleSchema.optional(), areaStyle: areaStyleSchema.optional(), }); const stateSchema = z.object({ label: labelSchema.optional(), itemStyle: itemStyleSchema.optional(), lineStyle: lineStyleSchema.optional(), areaStyle: areaStyleSchema.optional(), }); export const seriesSchema = z.object({ type: z.string().optional(), id: z.string().optional(), name: z.string().optional(), colorBy: z.enum(['series', 'data']).optional(), legendHoverLink: z.boolean().optional(), coordinateSystem: z.string().optional(), xAxisIndex: z.number().optional(), yAxisIndex: z.number().optional(), polarIndex: z.number().optional(), geoIndex: z.number().optional(), calendarIndex: z.number().optional(), // Per-series `tooltip` is intentionally not admitted; the schema // strips unknown keys. If per-series tooltips are ever admitted, reuse // tooltipSchema so the formatter sanitization applies. label: labelSchema.optional(), labelLine: z .object({ show: z.boolean().optional(), showAbove: z.boolean().optional(), length: z.number().optional(), length2: z.number().optional(), smooth: z.union([z.boolean(), z.number()]).optional(), minTurnAngle: z.number().optional(), lineStyle: lineStyleSchema.optional(), }) .optional(), labelLayout: z .object({ hideOverlap: z.boolean().optional(), moveOverlap: z.enum(['shiftX', 'shiftY']).optional(), }) .optional(), itemStyle: itemStyleSchema.optional(), lineStyle: lineStyleSchema.optional(), areaStyle: areaStyleSchema.optional(), emphasis: emphasisSchema.optional(), blur: stateSchema.optional(), select: stateSchema.optional(), selectedMode: z .union([z.boolean(), z.enum(['single', 'multiple', 'series'])]) .optional(), zlevel: z.number().optional(), z: z.number().optional(), silent: z.boolean().optional(), cursor: z.string().optional(), animation: z.boolean().optional(), animationThreshold: z.number().optional(), animationDuration: z.number().optional(), animationEasing: z.string().optional(), animationDelay: z.number().optional(), animationDurationUpdate: z.number().optional(), animationEasingUpdate: z.string().optional(), animationDelayUpdate: z.number().optional(), }); // ============================================================================= // Graphic Schema // ============================================================================= export const graphicElementSchema = z.object({ type: z .enum([ 'group', 'image', 'text', 'rect', 'circle', 'ring', 'sector', 'arc', 'polygon', 'polyline', 'line', 'bezierCurve', ]) .optional(), id: z.string().optional(), $action: z.enum(['merge', 'replace', 'remove']).optional(), left: numberOrPercentSchema.optional(), top: numberOrPercentSchema.optional(), right: numberOrPercentSchema.optional(), bottom: numberOrPercentSchema.optional(), bounding: z.enum(['all', 'raw']).optional(), z: z.number().optional(), zlevel: z.number().optional(), silent: z.boolean().optional(), invisible: z.boolean().optional(), cursor: z.string().optional(), draggable: z .union([z.boolean(), z.enum(['horizontal', 'vertical'])]) .optional(), progressive: z.boolean().optional(), width: z.number().optional(), height: z.number().optional(), shape: z.record(z.string(), z.unknown()).optional(), style: z.record(z.string(), z.unknown()).optional(), rotation: z.number().optional(), scaleX: z.number().optional(), scaleY: z.number().optional(), originX: z.number().optional(), originY: z.number().optional(), children: z.array(z.record(z.string(), z.unknown())).optional(), }); // ============================================================================= // AxisPointer Schema // ============================================================================= export const axisPointerSchema = z.object({ id: z.string().optional(), show: z.boolean().optional(), type: z.enum(['line', 'shadow', 'none']).optional(), axis: z.enum(['x', 'y']).optional(), snap: z.boolean().optional(), z: z.number().optional(), label: z .object({ show: z.boolean().optional(), precision: z.union([z.number(), z.literal('auto')]).optional(), margin: z.number().optional(), color: colorSchema.optional(), fontStyle: fontStyleSchema.optional(), fontWeight: fontWeightSchema.optional(), fontFamily: z.string().optional(), fontSize: z.number().optional(), lineHeight: z.number().optional(), backgroundColor: colorSchema.optional(), borderColor: colorSchema.optional(), borderWidth: z.number().optional(), borderRadius: z.number().optional(), padding: z.union([z.number(), z.array(z.number())]).optional(), shadowBlur: z.number().optional(), shadowColor: colorSchema.optional(), shadowOffsetX: z.number().optional(), shadowOffsetY: z.number().optional(), }) .optional(), lineStyle: lineStyleSchema.optional(), shadowStyle: areaStyleSchema.optional(), triggerTooltip: z.boolean().optional(), value: z.number().optional(), status: z.enum(['show', 'hide']).optional(), handle: z .object({ show: z.boolean().optional(), icon: z.string().optional(), size: z.union([z.number(), z.array(z.number())]).optional(), margin: z.number().optional(), color: colorSchema.optional(), throttle: z.number().optional(), shadowBlur: z.number().optional(), shadowColor: colorSchema.optional(), shadowOffsetX: z.number().optional(), shadowOffsetY: z.number().optional(), }) .optional(), link: z.array(z.record(z.string(), z.unknown())).optional(), }); // ============================================================================= // Root Schema - CustomEChartOptions // ============================================================================= /** * Helper to create schema that accepts object or array of objects */ function objectOrArray(schema: T) { return z.union([schema, z.array(schema)]); } /** * Main ECharts Options Schema * * This schema validates user-provided custom ECharts options. * It intentionally excludes function callbacks for security. */ export const customEChartOptionsSchema = z.object({ // Global options backgroundColor: colorSchema.optional(), darkMode: z.union([z.boolean(), z.literal('auto')]).optional(), textStyle: textStyleSchema.optional(), useUTC: z.boolean().optional(), // Animation options animation: z.boolean().optional(), animationThreshold: z.number().optional(), animationDuration: z.number().optional(), animationEasing: z.string().optional(), animationDelay: z.number().optional(), animationDurationUpdate: z.number().optional(), animationEasingUpdate: z.string().optional(), animationDelayUpdate: z.number().optional(), stateAnimation: z .object({ duration: z.number().optional(), easing: z.string().optional(), }) .optional(), // Component options (can be object or array) title: objectOrArray(titleSchema).optional(), legend: objectOrArray(legendSchema).optional(), grid: objectOrArray(gridSchema).optional(), xAxis: objectOrArray(axisSchema).optional(), yAxis: objectOrArray(axisSchema).optional(), tooltip: tooltipSchema.optional(), toolbox: toolboxSchema.optional(), dataZoom: objectOrArray(dataZoomSchema).optional(), visualMap: objectOrArray(visualMapSchema).optional(), axisPointer: axisPointerSchema.optional(), graphic: objectOrArray(graphicElementSchema).optional(), series: objectOrArray(seriesSchema).optional(), }); // ============================================================================= // Type Exports (inferred from Zod schemas) // ============================================================================= export type TextStyleOption = z.infer; export type LineStyleOption = z.infer; export type AreaStyleOption = z.infer; export type ItemStyleOption = z.infer; export type DecalOption = z.infer; export type LabelOption = z.infer; export type TitleOption = z.infer; export type LegendOption = z.infer; export type GridOption = z.infer; export type AxisOption = z.infer; export type TooltipOption = z.infer; export type DataZoomOption = z.infer; export type ToolboxOption = z.infer; export type VisualMapOption = z.infer; export type SeriesOption = z.infer; export type GraphicElementOption = z.infer; export type AxisPointerOption = z.infer; /** Main custom ECharts options type */ export type CustomEChartOptions = z.infer; // ============================================================================= // Validation Functions // ============================================================================= /** * Validates custom EChart options against the schema. * Returns a result object with success status and validated data or errors. */ export function validateEChartOptions(data: unknown) { return customEChartOptionsSchema.safeParse(data); } /** * Validates the options and returns them if valid. * Returns an empty object if validation fails. */ export function parseEChartOptionsStrict(data: unknown): CustomEChartOptions { const result = customEChartOptionsSchema.safeParse(data); if (result.success) { return result.data; } // Return empty object on failure return {}; } export default customEChartOptionsSchema;