This is an automated email from the ASF dual-hosted git repository.

justinpark pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 35d0aad854 feat(explore): Add Echarts option editor (#37868)
35d0aad854 is described below

commit 35d0aad854a176a25718a1774c5f28a86da1e27b
Author: JUST.in DO IT <[email protected]>
AuthorDate: Wed Mar 4 08:34:34 2026 -0800

    feat(explore): Add Echarts option editor (#37868)
---
 superset-frontend/package-lock.json                |  29 +-
 .../packages/superset-core/src/api/editors.ts      |  10 +-
 .../src/shared-controls/sharedControls.tsx         |  15 +
 .../superset-ui-chart-controls/src/types.ts        |   1 +
 .../src/components/AsyncAceEditor/index.tsx        |   6 +
 .../superset-ui-core/src/components/index.ts       |   1 +
 .../plugins/plugin-chart-echarts/package.json      |   4 +-
 .../src/MixedTimeseries/controlPanel.tsx           |   1 +
 .../src/MixedTimeseries/transformProps.ts          |  20 +-
 .../src/Timeseries/Area/controlPanel.tsx           |   1 +
 .../src/Timeseries/Regular/Bar/controlPanel.tsx    |   1 +
 .../src/Timeseries/Regular/Line/controlPanel.tsx   |   1 +
 .../Timeseries/Regular/SmoothLine/controlPanel.tsx |   1 +
 .../src/Timeseries/transformProps.ts               |  21 +-
 .../plugins/plugin-chart-echarts/src/index.ts      |   3 +
 .../src/utils/eChartOptionsSchema.ts               | 827 +++++++++++++++++++++
 .../src/utils/mergeCustomEChartOptions.test.ts     | 163 ++++
 .../src/utils/mergeCustomEChartOptions.ts          |  79 ++
 .../src/utils/safeEChartOptionsParser.test.ts      | 525 +++++++++++++
 .../src/utils/safeEChartOptionsParser.ts           | 477 ++++++++++++
 .../src/core/editors/AceEditorProvider.tsx         |   3 +
 .../components/controls/JSEditorControl.test.tsx   | 125 ++++
 .../components/controls/JSEditorControl.tsx        | 105 +++
 .../src/explore/components/controls/index.ts       |   2 +
 24 files changed, 2413 insertions(+), 8 deletions(-)

diff --git a/superset-frontend/package-lock.json 
b/superset-frontend/package-lock.json
index fe9c92dc49..52dd41c7e4 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -51377,6 +51377,15 @@
         "numcodecs": "^0.3.2"
       }
     },
+    "node_modules/zod": {
+      "version": "4.3.6",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz";,
+      "integrity": 
"sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks";
+      }
+    },
     "node_modules/zrender": {
       "version": "5.6.1",
       "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz";,
@@ -51752,12 +51761,14 @@
         "@testing-library/react": "^12.1.5",
         "@testing-library/react-hooks": "*",
         "@testing-library/user-event": "*",
+        "acorn": "*",
         "ace-builds": "^1.4.14",
         "brace": "^0.11.1",
         "memoize-one": "^5.1.1",
         "react": "^17.0.2",
         "react-ace": "^10.1.0",
-        "react-dom": "^17.0.2"
+        "react-dom": "^17.0.2",
+        "zod": "*"
       }
     },
     "packages/superset-ui-core": {
@@ -53233,8 +53244,10 @@
       "dependencies": {
         "@types/d3-array": "^3.2.2",
         "@types/react-redux": "^7.1.34",
+        "acorn": "^8.9.0",
         "d3-array": "^3.2.4",
-        "lodash": "^4.17.23"
+        "lodash": "^4.17.23",
+        "zod": "^4.3.6"
       },
       "peerDependencies": {
         "@apache-superset/core": "*",
@@ -53246,6 +53259,18 @@
         "react": "^17.0.2"
       }
     },
+    "plugins/plugin-chart-echarts/node_modules/acorn": {
+      "version": "8.9.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz";,
+      "integrity": 
"sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==",
+      "license": "MIT",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
     "plugins/plugin-chart-echarts/node_modules/d3-array": {
       "version": "3.2.4",
       "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz";,
diff --git a/superset-frontend/packages/superset-core/src/api/editors.ts 
b/superset-frontend/packages/superset-core/src/api/editors.ts
index 334f28a49e..1113bc8d72 100644
--- a/superset-frontend/packages/superset-core/src/api/editors.ts
+++ b/superset-frontend/packages/superset-core/src/api/editors.ts
@@ -40,7 +40,15 @@ import type { SupersetTheme } from '../ui';
 /**
  * Supported editor languages.
  */
-export type EditorLanguage = 'sql' | 'json' | 'yaml' | 'markdown' | 'css';
+export type EditorLanguage =
+  | 'sql'
+  | 'json'
+  | 'yaml'
+  | 'markdown'
+  | 'css'
+  | 'python'
+  | 'text'
+  | 'javascript';
 
 /**
  * Describes an editor that can be contributed to the application.
diff --git 
a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
 
b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
index bf087565ab..0ec021d11f 100644
--- 
a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
+++ 
b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
@@ -453,6 +453,20 @@ const order_by_cols: SharedControlConfig<'SelectControl'> 
= {
   resetOnHide: false,
 };
 
+const echart_options: SharedControlConfig<'JSEditorControl'> = {
+  type: 'JSEditorControl',
+  label: t('ECharts Options (JS object literals)'),
+  description: t(
+    'A JavaScript object that adheres to the ECharts options specification, ' +
+      'overriding other control options with higher precedence. ' +
+      '(i.e. { title: { text: "My Chart" }, tooltip: { trigger: "item" } }). ' 
+
+      'Details: https://echarts.apache.org/en/option.html. ',
+  ),
+  default: '{}',
+  renderTrigger: true,
+  validators: [],
+};
+
 const sharedControls: Record<string, SharedControlConfig<any>> = {
   metrics: dndAdhocMetricsControl,
   metric: dndAdhocMetricControl,
@@ -499,6 +513,7 @@ const sharedControls: Record<string, 
SharedControlConfig<any>> = {
   currency_format,
   sort_by_metric,
   order_by_cols,
+  echart_options,
 
   // Add all Matrixify controls
   ...matrixifyControls,
diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts 
b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
index b501b7c9bf..8825f5aca6 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
+++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
@@ -170,6 +170,7 @@ export type InternalControlType =
   | 'FixedOrMetricControl'
   | 'ColorBreakpointsControl'
   | 'HiddenControl'
+  | 'JSEditorControl'
   | 'SelectAsyncControl'
   | 'SelectControl'
   | 'SliderControl'
diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
 
b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
index 90a2cafcc9..6ec452d5a9 100644
--- 
a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
+++ 
b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
@@ -502,3 +502,9 @@ export const ConfigEditor = AsyncAceEditor([
   'mode/yaml',
   'theme/github',
 ]);
+
+export const JSEditor = AsyncAceEditor([
+  'mode/javascript',
+  'mode/json',
+  'theme/github',
+]);
diff --git 
a/superset-frontend/packages/superset-ui-core/src/components/index.ts 
b/superset-frontend/packages/superset-ui-core/src/components/index.ts
index 30bad84f4d..048504ccfd 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts
@@ -39,6 +39,7 @@ export {
   AsyncAceEditor,
   CssEditor,
   JsonEditor,
+  JSEditor,
   SQLEditor,
   FullSQLEditor,
   MarkdownEditor,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/package.json 
b/superset-frontend/plugins/plugin-chart-echarts/package.json
index 3bba8cfea3..c56ac50513 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/package.json
+++ b/superset-frontend/plugins/plugin-chart-echarts/package.json
@@ -26,8 +26,10 @@
   "dependencies": {
     "@types/d3-array": "^3.2.2",
     "@types/react-redux": "^7.1.34",
+    "acorn": "^8.9.0",
     "d3-array": "^3.2.4",
-    "lodash": "^4.17.23"
+    "lodash": "^4.17.23",
+    "zod": "^4.3.6"
   },
   "peerDependencies": {
     "@apache-superset/core": "*",
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx
 
b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx
index 0ec6080c50..ea5c8ca005 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx
@@ -505,6 +505,7 @@ const config: ControlPanelConfig = {
             },
           },
         ],
+        ['echart_options'],
       ],
     },
   ],
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
 
b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
index 26094b3a10..0d58f82532 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
@@ -58,6 +58,7 @@ import {
   Refs,
 } from '../types';
 import { parseAxisBound } from '../utils/controls';
+import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
 import {
   dedupSeries,
   extractDataTotalValues,
@@ -99,6 +100,7 @@ import {
   getYAxisFormatter,
 } from '../utils/formatters';
 import { getMetricDisplayName } from '../utils/metricDisplayName';
+import { mergeCustomEChartOptions } from '../utils/mergeCustomEChartOptions';
 
 const getFormatter = (
   customFormatters: Record<string, ValueFormatter>,
@@ -122,7 +124,7 @@ export default function transformProps(
   const {
     width,
     height,
-    formData,
+    formData: { echartOptions: _echartOptions, ...formData },
     queriesData,
     hooks,
     filterState,
@@ -803,11 +805,25 @@ export default function transformProps(
     focusedSeries = seriesName;
   };
 
+  let customEchartOptions;
+  try {
+    // Parse custom EChart options safely using AST analysis
+    // This replaces the unsafe `new Function()` approach with a secure parser
+    // that only allows static data structures (no function callbacks)
+    customEchartOptions = safeParseEChartOptions(_echartOptions);
+  } catch (_) {
+    customEchartOptions = undefined;
+  }
+
+  const mergedEchartOptions = customEchartOptions
+    ? mergeCustomEChartOptions(echartOptions, customEchartOptions)
+    : echartOptions;
+
   return {
     formData,
     width,
     height,
-    echartOptions,
+    echartOptions: mergedEchartOptions,
     setDataMask,
     emitCrossFilters,
     labelMap,
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx
 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx
index 56048de1d2..8eb4295de5 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx
@@ -251,6 +251,7 @@ const config: ControlPanelConfig = {
             },
           },
         ],
+        ['echart_options'],
       ],
     },
   ],
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
index b8f62d42f8..9a1a92d12b 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
@@ -374,6 +374,7 @@ const config: ControlPanelConfig = {
         ...richTooltipSection,
         [<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
         ...createAxisControl('y'),
+        ['echart_options'],
       ],
     },
   ],
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
index 1a45e7d23d..2c339e70f3 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
@@ -216,6 +216,7 @@ const config: ControlPanelConfig = {
             },
           },
         ],
+        ['echart_options'],
       ],
     },
   ],
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx
 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx
index 62f805531b..c5c2f31bea 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx
@@ -183,6 +183,7 @@ const config: ControlPanelConfig = {
             },
           },
         ],
+        ['echart_options'],
       ],
     },
   ],
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
index ab9abfb792..b2fcb7b7cd 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -113,6 +113,8 @@ import {
   getXAxisFormatter,
   getYAxisFormatter,
 } from '../utils/formatters';
+import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
+import { mergeCustomEChartOptions } from '../utils/mergeCustomEChartOptions';
 
 export default function transformProps(
   chartProps: EchartsTimeseriesChartProps,
@@ -122,7 +124,7 @@ export default function transformProps(
     height,
     filterState,
     legendState,
-    formData,
+    formData: { echartOptions: _echartOptions, ...formData },
     hooks,
     queriesData,
     datasource,
@@ -967,8 +969,23 @@ export default function transformProps(
   const onFocusedSeries = (seriesName: string | null) => {
     focusedSeries = seriesName;
   };
+
+  let customEchartOptions;
+  try {
+    // Parse custom EChart options safely using AST analysis
+    // This replaces the unsafe `new Function()` approach with a secure parser
+    // that only allows static data structures (no function callbacks)
+    customEchartOptions = safeParseEChartOptions(_echartOptions);
+  } catch (_) {
+    customEchartOptions = undefined;
+  }
+
+  const mergedEchartOptions = customEchartOptions
+    ? mergeCustomEChartOptions(echartOptions, customEchartOptions)
+    : echartOptions;
+
   return {
-    echartOptions,
+    echartOptions: mergedEchartOptions,
     emitCrossFilters,
     formData,
     groupby: groupBy,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts 
b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
index 14fcb1eb45..abb57d9e6e 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
@@ -65,6 +65,9 @@ export { default as GanttTransformProps } from 
'./Gantt/transformProps';
 
 export { DEFAULT_FORM_DATA as TimeseriesDefaultFormData } from 
'./Timeseries/constants';
 
+export * from './utils/eChartOptionsSchema';
+export * from './utils/safeEChartOptionsParser';
+
 export * from './types';
 
 /**
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/eChartOptionsSchema.ts
 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/eChartOptionsSchema.ts
new file mode 100644
index 0000000000..f8318dd857
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/eChartOptionsSchema.ts
@@ -0,0 +1,827 @@
+/**
+ * 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';
+
+// 
=============================================================================
+// 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();
+
+// 
=============================================================================
+// 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
+// 
=============================================================================
+
+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(),
+});
+
+// 
=============================================================================
+// 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: z.string().optional(),
+  target: z.enum(['self', 'blank']).optional(),
+  textStyle: textStyleSchema.optional(),
+  subtext: z.string().optional(),
+  sublink: z.string().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(),
+  formatter: z.string().optional(), // Only string formatters
+  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: z.string().optional(),
+  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(),
+  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<T extends z.ZodTypeAny>(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<typeof textStyleSchema>;
+export type LineStyleOption = z.infer<typeof lineStyleSchema>;
+export type AreaStyleOption = z.infer<typeof areaStyleSchema>;
+export type ItemStyleOption = z.infer<typeof itemStyleSchema>;
+export type LabelOption = z.infer<typeof labelSchema>;
+export type TitleOption = z.infer<typeof titleSchema>;
+export type LegendOption = z.infer<typeof legendSchema>;
+export type GridOption = z.infer<typeof gridSchema>;
+export type AxisOption = z.infer<typeof axisSchema>;
+export type TooltipOption = z.infer<typeof tooltipSchema>;
+export type DataZoomOption = z.infer<typeof dataZoomSchema>;
+export type ToolboxOption = z.infer<typeof toolboxSchema>;
+export type VisualMapOption = z.infer<typeof visualMapSchema>;
+export type SeriesOption = z.infer<typeof seriesSchema>;
+export type GraphicElementOption = z.infer<typeof graphicElementSchema>;
+export type AxisPointerOption = z.infer<typeof axisPointerSchema>;
+
+/** Main custom ECharts options type */
+export type CustomEChartOptions = z.infer<typeof customEChartOptionsSchema>;
+
+// 
=============================================================================
+// 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;
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.test.ts
 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.test.ts
new file mode 100644
index 0000000000..d818b99ace
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.test.ts
@@ -0,0 +1,163 @@
+/**
+ * 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.
+ */
+
+import { mergeCustomEChartOptions } from './mergeCustomEChartOptions';
+import type { CustomEChartOptions } from './eChartOptionsSchema';
+
+test('mergeCustomEChartOptions returns base options when custom is undefined', 
() => {
+  const base = { title: { text: 'Base Title' } };
+  const result = mergeCustomEChartOptions(base, undefined);
+
+  expect(result).toEqual(base);
+});
+
+test('mergeCustomEChartOptions merges simple properties', () => {
+  const base = { backgroundColor: '#fff' };
+  const custom: CustomEChartOptions = { backgroundColor: '#000' };
+
+  const result = mergeCustomEChartOptions(base, custom);
+
+  expect(result.backgroundColor).toBe('#000');
+});
+
+test('mergeCustomEChartOptions deep merges nested objects', () => {
+  const base = {
+    title: {
+      text: 'Base Title',
+      textStyle: {
+        color: '#333',
+        fontSize: 14,
+      },
+    },
+  };
+  const custom: CustomEChartOptions = {
+    title: {
+      textStyle: {
+        color: '#ff0000',
+      },
+    },
+  };
+
+  const result = mergeCustomEChartOptions(base, custom);
+
+  expect(result.title).toEqual({
+    text: 'Base Title',
+    textStyle: {
+      color: '#ff0000',
+      fontSize: 14,
+    },
+  });
+});
+
+test('mergeCustomEChartOptions replaces arrays entirely', () => {
+  const base = {
+    series: [{ name: 'A', type: 'line' }],
+  };
+  const custom: CustomEChartOptions = {
+    series: [
+      { name: 'B', type: 'bar' },
+      { name: 'C', type: 'pie' },
+    ],
+  };
+
+  const result = mergeCustomEChartOptions(base, custom);
+
+  expect(result.series).toEqual([
+    { name: 'B', type: 'bar' },
+    { name: 'C', type: 'pie' },
+  ]);
+});
+
+test('mergeCustomEChartOptions does not mutate original base object', () => {
+  const base = {
+    title: { text: 'Original' },
+    grid: { top: 10 },
+  };
+  const originalBase = JSON.parse(JSON.stringify(base));
+  const custom: CustomEChartOptions = {
+    title: { text: 'Modified' },
+  };
+
+  mergeCustomEChartOptions(base, custom);
+
+  expect(base).toEqual(originalBase);
+});
+
+test('mergeCustomEChartOptions handles complex nested structures', () => {
+  const base = {
+    grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
+    xAxis: { type: 'category', axisLine: { lineStyle: { color: '#999' } } },
+    yAxis: { type: 'value', splitLine: { lineStyle: { type: 'solid' } } },
+    tooltip: { trigger: 'axis' },
+  };
+  const custom: CustomEChartOptions = {
+    grid: { top: 50 },
+    xAxis: { axisLine: { lineStyle: { width: 2 } } },
+    tooltip: { backgroundColor: 'rgba(0,0,0,0.8)' },
+  };
+
+  const result = mergeCustomEChartOptions(base, custom);
+
+  expect(result.grid).toEqual({
+    left: '3%',
+    right: '4%',
+    bottom: '3%',
+    containLabel: true,
+    top: 50,
+  });
+  expect(result.xAxis).toEqual({
+    type: 'category',
+    axisLine: { lineStyle: { color: '#999', width: 2 } },
+  });
+  expect(result.tooltip).toEqual({
+    trigger: 'axis',
+    backgroundColor: 'rgba(0,0,0,0.8)',
+  });
+});
+
+test('mergeCustomEChartOptions handles null values in custom options', () => {
+  const base = { title: { text: 'Title' }, legend: { show: true } };
+  const custom: CustomEChartOptions = { title: { text: 'New Title' } };
+
+  const result = mergeCustomEChartOptions(base, custom);
+
+  expect(result.title).toEqual({ text: 'New Title' });
+  expect(result.legend).toEqual({ show: true });
+});
+
+test('mergeCustomEChartOptions adds new properties from custom', () => {
+  const base = { title: { text: 'Title' } };
+  const custom: CustomEChartOptions = {
+    legend: { show: true, orient: 'horizontal' },
+  };
+
+  const result = mergeCustomEChartOptions(base, custom);
+
+  expect(result.title).toEqual({ text: 'Title' });
+  expect(result.legend).toEqual({ show: true, orient: 'horizontal' });
+});
+
+test('mergeCustomEChartOptions handles empty custom options', () => {
+  const base = { title: { text: 'Title' } };
+  const custom: CustomEChartOptions = {};
+
+  const result = mergeCustomEChartOptions(base, custom);
+
+  expect(result).toEqual(base);
+});
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.ts
 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.ts
new file mode 100644
index 0000000000..fdb75a70c1
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.ts
@@ -0,0 +1,79 @@
+/**
+ * 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.
+ */
+
+import type { EChartsCoreOption } from 'echarts/core';
+import type { CustomEChartOptions } from './eChartOptionsSchema';
+
+type PlainObject = Record<string, unknown>;
+
+function isPlainObject(value: unknown): value is PlainObject {
+  return (
+    typeof value === 'object' &&
+    value !== null &&
+    !Array.isArray(value) &&
+    Object.prototype.toString.call(value) === '[object Object]'
+  );
+}
+
+/**
+ * Deep merges custom EChart options into base options.
+ * Arrays are replaced entirely, objects are merged recursively.
+ *
+ * @param baseOptions - The base ECharts options object
+ * @param customOptions - Custom options to merge (from safeParseEChartOptions)
+ * @returns Merged ECharts options
+ */
+export function mergeCustomEChartOptions<T extends EChartsCoreOption>(
+  baseOptions: T,
+  customOptions: CustomEChartOptions | undefined,
+): T & Partial<CustomEChartOptions> {
+  type MergedResult = T & Partial<CustomEChartOptions>;
+
+  if (!customOptions) {
+    return baseOptions as MergedResult;
+  }
+
+  const result = { ...baseOptions } as MergedResult;
+
+  for (const key of Object.keys(customOptions) as Array<
+    keyof typeof customOptions
+  >) {
+    const customValue = customOptions[key];
+    const baseValue = result[key as keyof T];
+
+    if (customValue === undefined) {
+      continue;
+    }
+
+    if (isPlainObject(customValue) && isPlainObject(baseValue)) {
+      // Recursively merge nested objects
+      (result as PlainObject)[key] = mergeCustomEChartOptions(
+        baseValue as EChartsCoreOption,
+        customValue as CustomEChartOptions,
+      );
+    } else {
+      // Replace arrays and primitive values directly
+      (result as PlainObject)[key] = customValue;
+    }
+  }
+
+  return result;
+}
+
+export default mergeCustomEChartOptions;
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.test.ts
 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.test.ts
new file mode 100644
index 0000000000..59c351f328
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.test.ts
@@ -0,0 +1,525 @@
+/**
+ * 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.
+ */
+
+import {
+  parseEChartOptions,
+  safeParseEChartOptions,
+  EChartOptionsParseError,
+} from './safeEChartOptionsParser';
+
+test('parseEChartOptions returns undefined for empty input', () => {
+  expect(parseEChartOptions(undefined)).toEqual({
+    success: true,
+    data: undefined,
+  });
+  expect(parseEChartOptions('')).toEqual({ success: true, data: undefined });
+  expect(parseEChartOptions('   ')).toEqual({ success: true, data: undefined 
});
+});
+
+test('parseEChartOptions parses simple object literals', () => {
+  const input = `{ title: { text: 'My Chart' } }`;
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data).toEqual({
+    title: { text: 'My Chart' },
+  });
+});
+
+test('parseEChartOptions parses nested objects and arrays', () => {
+  const input = `{
+    grid: { top: 50, bottom: 50, left: '10%', right: '10%' },
+    xAxis: { type: 'category' },
+    yAxis: [{ type: 'value' }, { type: 'log' }],
+    series: [
+      { name: 'Series 1', type: 'line' },
+      { name: 'Series 2', type: 'bar' }
+    ]
+  }`;
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data).toEqual({
+    grid: { top: 50, bottom: 50, left: '10%', right: '10%' },
+    xAxis: { type: 'category' },
+    yAxis: [{ type: 'value' }, { type: 'log' }],
+    series: [
+      { name: 'Series 1', type: 'line' },
+      { name: 'Series 2', type: 'bar' },
+    ],
+  });
+});
+
+test('parseEChartOptions handles negative numbers in valid properties', () => {
+  const input = `{ xAxis: { nameRotate: -45, offset: -10 } }`;
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data).toEqual({ xAxis: { nameRotate: -45, offset: -10 } });
+});
+
+test('parseEChartOptions handles boolean values in valid properties', () => {
+  const input = `{ animation: true, useUTC: false }`;
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data).toEqual({ animation: true, useUTC: false });
+});
+
+test('parseEChartOptions throws for special numeric values like -Infinity', () 
=> {
+  // Special values like -Infinity are not valid JSON-serializable values
+  const input = `{ xAxis: { min: -Infinity, splitNumber: 5 } }`;
+
+  // -Infinity is not a valid value for 'min' in the schema
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+});
+
+test('parseEChartOptions throws for function expressions', () => {
+  const input = `{ formatter: function(value) { return value; } }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+  try {
+    parseEChartOptions(input);
+  } catch (error) {
+    expect(error).toBeInstanceOf(EChartOptionsParseError);
+    expect((error as 
EChartOptionsParseError).errorType).toBe('security_error');
+  }
+});
+
+test('parseEChartOptions throws for arrow functions', () => {
+  const input = `{ formatter: (value) => value }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+  try {
+    parseEChartOptions(input);
+  } catch (error) {
+    expect(error).toBeInstanceOf(EChartOptionsParseError);
+    expect((error as 
EChartOptionsParseError).errorType).toBe('security_error');
+  }
+});
+
+test('parseEChartOptions throws for function calls', () => {
+  const input = `{ value: eval('1+1') }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+  try {
+    parseEChartOptions(input);
+  } catch (error) {
+    expect(error).toBeInstanceOf(EChartOptionsParseError);
+    expect((error as 
EChartOptionsParseError).errorType).toBe('security_error');
+  }
+});
+
+test('parseEChartOptions throws for dangerous identifiers', () => {
+  const dangerousInputs = [
+    `{ x: window }`,
+    `{ x: document }`,
+    `{ x: globalThis }`,
+    `{ x: process }`,
+    `{ x: require }`,
+    `{ x: constructor }`,
+    `{ x: __proto__ }`,
+  ];
+
+  dangerousInputs.forEach(input => {
+    expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+    try {
+      parseEChartOptions(input);
+    } catch (error) {
+      expect(error).toBeInstanceOf(EChartOptionsParseError);
+      expect((error as EChartOptionsParseError).errorType).toBe(
+        'security_error',
+      );
+    }
+  });
+});
+
+test('parseEChartOptions throws for computed properties', () => {
+  const input = `{ [dynamicKey]: 'value' }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+});
+
+test('parseEChartOptions throws for template literals with expressions', () => 
{
+  const input = '{ text: `Hello ${name}` }';
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+});
+
+test('parseEChartOptions allows simple template literals in valid properties', 
() => {
+  const input = '{ title: { text: `Hello World` } }';
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data).toEqual({ title: { text: 'Hello World' } });
+});
+
+test('parseEChartOptions throws for new expressions', () => {
+  const input = `{ date: new Date() }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+});
+
+test('parseEChartOptions throws for member expressions', () => {
+  const input = `{ value: Math.PI }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+});
+
+test('parseEChartOptions handles trailing commas (JSON5-like)', () => {
+  const input = `{
+    title: { text: 'Chart', },
+    series: [
+      { name: 'A', },
+      { name: 'B', },
+    ],
+  }`;
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data).toEqual({
+    title: { text: 'Chart' },
+    series: [{ name: 'A' }, { name: 'B' }],
+  });
+});
+
+test('parseEChartOptions handles unquoted keys in nested objects', () => {
+  // Unknown top-level keys are filtered, but valid nested keys work
+  const input = `{ title: { text: 'value', show: true } }`;
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data).toEqual({
+    title: { text: 'value', show: true },
+  });
+});
+
+test('safeParseEChartOptions throws on parse error', () => {
+  expect(() => safeParseEChartOptions('{ invalid')).toThrow(
+    EChartOptionsParseError,
+  );
+});
+
+test('safeParseEChartOptions throws on security error', () => {
+  expect(() => safeParseEChartOptions('{ fn: () => {} }')).toThrow(
+    EChartOptionsParseError,
+  );
+});
+
+test('safeParseEChartOptions returns data on success', () => {
+  const result = safeParseEChartOptions(`{ title: { text: 'Test' } }`);
+  expect(result).toEqual({ title: { text: 'Test' } });
+});
+
+test('parseEChartOptions handles complex real-world EChart options', () => {
+  const input = `{
+    title: {
+      text: 'Sales Overview',
+      subtext: 'Monthly Data',
+      left: 'center',
+      textStyle: {
+        color: '#333',
+        fontSize: 18,
+        fontWeight: 'bold'
+      }
+    },
+    legend: {
+      orient: 'vertical',
+      left: 'left',
+      top: 50
+    },
+    grid: {
+      left: '3%',
+      right: '4%',
+      bottom: '3%',
+      containLabel: true
+    },
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: 'rgba(255, 255, 255, 0.9)',
+      borderColor: '#ccc',
+      borderWidth: 1,
+      textStyle: {
+        color: '#333'
+      }
+    },
+    xAxis: {
+      type: 'category',
+      boundaryGap: false,
+      axisLine: {
+        lineStyle: {
+          color: '#999'
+        }
+      }
+    },
+    yAxis: {
+      type: 'value',
+      splitLine: {
+        lineStyle: {
+          type: 'dashed'
+        }
+      }
+    },
+    dataZoom: [
+      {
+        type: 'slider',
+        start: 0,
+        end: 100
+      },
+      {
+        type: 'inside'
+      }
+    ]
+  }`;
+
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data?.title).toBeDefined();
+  expect(result.data?.legend).toBeDefined();
+  expect(result.data?.grid).toBeDefined();
+  expect(result.data?.tooltip).toBeDefined();
+  expect(result.data?.xAxis).toBeDefined();
+  expect(result.data?.yAxis).toBeDefined();
+  expect(result.data?.dataZoom).toHaveLength(2);
+});
+
+// 
=============================================================================
+// Schema Validation Tests
+// 
=============================================================================
+
+test('parseEChartOptions throws when title is a string instead of object', () 
=> {
+  // title should be TitleOption (object), not string
+  const input = `{ title: 'text' }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+  try {
+    parseEChartOptions(input);
+  } catch (error) {
+    expect(error).toBeInstanceOf(EChartOptionsParseError);
+    expect((error as EChartOptionsParseError).errorType).toBe(
+      'validation_error',
+    );
+    expect(
+      (error as EChartOptionsParseError).validationErrors.length,
+    ).toBeGreaterThan(0);
+  }
+});
+
+test('parseEChartOptions throws when grid is a string instead of object', () 
=> {
+  const input = `{ grid: 'invalid' }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+  try {
+    parseEChartOptions(input);
+  } catch (error) {
+    expect(error).toBeInstanceOf(EChartOptionsParseError);
+    expect((error as EChartOptionsParseError).errorType).toBe(
+      'validation_error',
+    );
+  }
+});
+
+test('parseEChartOptions throws when nested property has wrong type', () => {
+  // textStyle should be object, not string - this invalidates the entire title
+  const input = `{ title: { text: 'Chart', textStyle: 'invalid' } }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+  try {
+    parseEChartOptions(input);
+  } catch (error) {
+    expect(error).toBeInstanceOf(EChartOptionsParseError);
+    expect((error as EChartOptionsParseError).errorType).toBe(
+      'validation_error',
+    );
+  }
+});
+
+test('parseEChartOptions keeps valid nested objects', () => {
+  const input = `{ title: { text: 'Chart', textStyle: { color: '#333', 
fontSize: 14 } } }`;
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data?.title).toEqual({
+    text: 'Chart',
+    textStyle: { color: '#333', fontSize: 14 },
+  });
+});
+
+test('parseEChartOptions throws when some properties are invalid', () => {
+  const input = `{
+    title: { text: 'Valid Title' },
+    legend: 'invalid',
+    grid: { top: 50 }
+  }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+  try {
+    parseEChartOptions(input);
+  } catch (error) {
+    expect(error).toBeInstanceOf(EChartOptionsParseError);
+    expect((error as EChartOptionsParseError).errorType).toBe(
+      'validation_error',
+    );
+    expect(
+      (error as EChartOptionsParseError).validationErrors.some(e =>
+        e.includes('legend'),
+      ),
+    ).toBe(true);
+  }
+});
+
+test('parseEChartOptions ignores unknown top-level properties', () => {
+  const input = `{
+    title: { text: 'Chart' },
+    unknownProperty: 'should be filtered',
+    anotherUnknown: { nested: 'value' }
+  }`;
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data?.title).toEqual({ text: 'Chart' });
+  expect(
+    (result.data as Record<string, unknown>)?.unknownProperty,
+  ).toBeUndefined();
+  expect(
+    (result.data as Record<string, unknown>)?.anotherUnknown,
+  ).toBeUndefined();
+});
+
+test('parseEChartOptions throws when array has invalid items', () => {
+  // dataZoom array should contain objects, not strings
+  const input = `{
+    dataZoom: [
+      { type: 'slider', start: 0 },
+      'invalid',
+      { type: 'inside' }
+    ]
+  }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+  try {
+    parseEChartOptions(input);
+  } catch (error) {
+    expect(error).toBeInstanceOf(EChartOptionsParseError);
+    expect((error as EChartOptionsParseError).errorType).toBe(
+      'validation_error',
+    );
+    expect(
+      (error as EChartOptionsParseError).validationErrors.some(e =>
+        e.includes('dataZoom'),
+      ),
+    ).toBe(true);
+  }
+});
+
+test('parseEChartOptions validates tooltip properties', () => {
+  const input = `{
+    tooltip: {
+      trigger: 'axis',
+      show: true,
+      padding: 10
+    }
+  }`;
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data?.tooltip).toEqual({
+    trigger: 'axis',
+    show: true,
+    padding: 10,
+  });
+});
+
+test('parseEChartOptions validates xAxis type property', () => {
+  const input = `{
+    xAxis: {
+      type: 'category',
+      name: 'X Axis',
+      axisLabel: {
+        rotate: 45,
+        fontSize: 12
+      }
+    }
+  }`;
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data?.xAxis).toEqual({
+    type: 'category',
+    name: 'X Axis',
+    axisLabel: {
+      rotate: 45,
+      fontSize: 12,
+    },
+  });
+});
+
+test('parseEChartOptions throws when number is used where string expected', () 
=> {
+  // backgroundColor should be string, not number
+  const input = `{ backgroundColor: 123 }`;
+
+  expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+  try {
+    parseEChartOptions(input);
+  } catch (error) {
+    expect(error).toBeInstanceOf(EChartOptionsParseError);
+    expect((error as EChartOptionsParseError).errorType).toBe(
+      'validation_error',
+    );
+  }
+});
+
+test('parseEChartOptions accepts valid animation options', () => {
+  const input = `{
+    animation: true,
+    animationDuration: 1000,
+    animationEasing: 'cubicOut',
+    animationDelay: 100
+  }`;
+  const result = parseEChartOptions(input);
+
+  expect(result.success).toBe(true);
+  expect(result.data).toEqual({
+    animation: true,
+    animationDuration: 1000,
+    animationEasing: 'cubicOut',
+    animationDelay: 100,
+  });
+});
+
+test('EChartOptionsParseError contains validation error details', () => {
+  const input = `{ title: 'invalid', grid: 123 }`;
+
+  expect.assertions(5);
+  try {
+    parseEChartOptions(input);
+  } catch (error) {
+    expect(error).toBeInstanceOf(EChartOptionsParseError);
+    const parseError = error as EChartOptionsParseError;
+    expect(parseError.errorType).toBe('validation_error');
+    expect(parseError.validationErrors.length).toBe(2);
+    expect(parseError.validationErrors.some(e => e.includes('title'))).toBe(
+      true,
+    );
+    expect(parseError.validationErrors.some(e => e.includes('grid'))).toBe(
+      true,
+    );
+  }
+});
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.ts
 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.ts
new file mode 100644
index 0000000000..528a602199
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.ts
@@ -0,0 +1,477 @@
+/**
+ * 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.
+ */
+
+import { parse } from 'acorn';
+import type { Node } from 'acorn';
+import type { z } from 'zod';
+import {
+  customEChartOptionsSchema,
+  titleSchema,
+  legendSchema,
+  gridSchema,
+  axisSchema,
+  tooltipSchema,
+  dataZoomSchema,
+  toolboxSchema,
+  visualMapSchema,
+  seriesSchema,
+  graphicElementSchema,
+  axisPointerSchema,
+  textStyleSchema,
+  type CustomEChartOptions,
+} from './eChartOptionsSchema';
+
+// 
=============================================================================
+// Custom Error Class
+// 
=============================================================================
+
+/**
+ * Custom error class for EChart options parsing errors
+ */
+export class EChartOptionsParseError extends Error {
+  public readonly errorType:
+    | 'parse_error'
+    | 'security_error'
+    | 'validation_error';
+
+  public readonly validationErrors: string[];
+
+  public readonly location?: { line: number; column: number };
+
+  constructor(
+    message: string,
+    errorType:
+      | 'parse_error'
+      | 'security_error'
+      | 'validation_error' = 'parse_error',
+    validationErrors: string[] = [],
+    location?: { line: number; column: number },
+  ) {
+    super(message);
+    this.name = 'EChartOptionsParseError';
+    this.errorType = errorType;
+    this.validationErrors = validationErrors;
+    this.location = location;
+  }
+}
+
+// 
=============================================================================
+// Partial Validation Helper
+// 
=============================================================================
+
+/**
+ * Maps top-level property names to their Zod schemas for partial validation
+ */
+const propertySchemas: Record<string, z.ZodTypeAny> = {
+  title: titleSchema,
+  legend: legendSchema,
+  grid: gridSchema,
+  xAxis: axisSchema,
+  yAxis: axisSchema,
+  tooltip: tooltipSchema,
+  dataZoom: dataZoomSchema,
+  toolbox: toolboxSchema,
+  visualMap: visualMapSchema,
+  series: seriesSchema,
+  graphic: graphicElementSchema,
+  axisPointer: axisPointerSchema,
+  textStyle: textStyleSchema,
+};
+
+/**
+ * Validates each property individually and returns only valid properties.
+ * This allows partial validation where invalid properties are filtered out
+ * while valid ones are kept. Throws an error if any validation issues are 
found.
+ */
+function validatePartial(data: Record<string, unknown>): CustomEChartOptions {
+  const result: Record<string, unknown> = {};
+  const validationErrors: string[] = [];
+
+  for (const [key, value] of Object.entries(data)) {
+    if (value === undefined) continue;
+
+    const schema = propertySchemas[key];
+
+    if (schema) {
+      // For properties with known schemas, validate them
+      if (Array.isArray(value)) {
+        // Validate array items individually
+        const validItems = value
+          .map((item, index) => {
+            const itemResult = schema.safeParse(item);
+            if (itemResult.success) {
+              return itemResult.data;
+            }
+            validationErrors.push(
+              `Invalid array item in "${key}[${index}]": 
${itemResult.error.issues.map(e => e.message).join(', ')}`,
+            );
+            return null;
+          })
+          .filter(item => item !== null);
+
+        if (validItems.length > 0) {
+          result[key] = validItems;
+        }
+      } else {
+        // Validate single object
+        const propResult = schema.safeParse(value);
+        if (propResult.success) {
+          result[key] = propResult.data;
+        } else {
+          validationErrors.push(
+            `Invalid property "${key}": ${propResult.error.issues.map(e => 
e.message).join(', ')}`,
+          );
+        }
+      }
+    } else {
+      // For primitive properties (animation, backgroundColor, etc.), validate 
with full schema
+      const primitiveResult =
+        customEChartOptionsSchema.shape[
+          key as keyof typeof customEChartOptionsSchema.shape
+        ]?.safeParse(value);
+
+      if (primitiveResult?.success) {
+        result[key] = primitiveResult.data;
+      } else if (primitiveResult) {
+        validationErrors.push(
+          `Invalid property "${key}": ${primitiveResult.error?.issues.map(e => 
e.message).join(', ') ?? 'Invalid value'}`,
+        );
+      }
+      // Unknown properties are silently ignored
+    }
+  }
+
+  if (validationErrors.length > 0) {
+    throw new EChartOptionsParseError(
+      'EChart options validation failed',
+      'validation_error',
+      validationErrors,
+    );
+  }
+
+  return result as CustomEChartOptions;
+}
+
+// 
=============================================================================
+// AST Safety Validation
+// 
=============================================================================
+
+/**
+ * Safe AST node types that are allowed in EChart options.
+ * These represent static data structures without executable code.
+ */
+const SAFE_NODE_TYPES = new Set([
+  'ObjectExpression',
+  'ArrayExpression',
+  'Literal',
+  'Property',
+  'Identifier',
+  'UnaryExpression',
+  'TemplateLiteral',
+  'TemplateElement',
+]);
+
+const ALLOWED_UNARY_OPERATORS = new Set(['-', '+']);
+
+const DANGEROUS_IDENTIFIERS = new Set([
+  'eval',
+  'Function',
+  'constructor',
+  'prototype',
+  '__proto__',
+  'window',
+  'document',
+  'globalThis',
+  'process',
+  'require',
+  'import',
+  'module',
+  'exports',
+]);
+
+/**
+ * Recursively validates that an AST node contains only safe constructs.
+ * Throws an error if any unsafe patterns are detected.
+ */
+function validateNode(node: Node, path: string[] = []): void {
+  if (!node || typeof node !== 'object') {
+    return;
+  }
+
+  const nodeType = node.type;
+
+  if (!SAFE_NODE_TYPES.has(nodeType)) {
+    throw new Error(
+      `Unsafe node type "${nodeType}" at path: ${path.join('.')}. ` +
+        `Only static data structures are allowed.`,
+    );
+  }
+
+  switch (nodeType) {
+    case 'Identifier': {
+      const identNode = node as Node & { name: string };
+      if (DANGEROUS_IDENTIFIERS.has(identNode.name)) {
+        throw new Error(
+          `Dangerous identifier "${identNode.name}" detected at path: 
${path.join('.')}`,
+        );
+      }
+      break;
+    }
+
+    case 'UnaryExpression': {
+      const unaryNode = node as Node & { operator: string; argument: Node };
+      if (!ALLOWED_UNARY_OPERATORS.has(unaryNode.operator)) {
+        throw new Error(
+          `Unsafe unary operator "${unaryNode.operator}" at path: 
${path.join('.')}`,
+        );
+      }
+      validateNode(unaryNode.argument, [...path, 'argument']);
+      break;
+    }
+
+    case 'ObjectExpression': {
+      const objNode = node as Node & { properties: Node[] };
+      objNode.properties.forEach((prop, index) => {
+        validateNode(prop, [...path, `property[${index}]`]);
+      });
+      break;
+    }
+
+    case 'ArrayExpression': {
+      const arrNode = node as Node & { elements: (Node | null)[] };
+      arrNode.elements.forEach((elem, index) => {
+        if (elem) {
+          validateNode(elem, [...path, `element[${index}]`]);
+        }
+      });
+      break;
+    }
+
+    case 'Property': {
+      const propNode = node as Node & {
+        key: Node;
+        value: Node;
+        computed: boolean;
+      };
+      if (propNode.computed) {
+        throw new Error(
+          `Computed properties are not allowed at path: ${path.join('.')}`,
+        );
+      }
+      validateNode(propNode.key, [...path, 'key']);
+      validateNode(propNode.value, [...path, 'value']);
+      break;
+    }
+
+    case 'TemplateLiteral': {
+      const templateNode = node as Node & {
+        expressions: Node[];
+        quasis: Node[];
+      };
+      if (templateNode.expressions.length > 0) {
+        throw new Error(
+          `Template literals with expressions are not allowed at path: 
${path.join('.')}`,
+        );
+      }
+      templateNode.quasis.forEach((quasi, index) => {
+        validateNode(quasi, [...path, `quasi[${index}]`]);
+      });
+      break;
+    }
+
+    case 'Literal':
+    case 'TemplateElement':
+      break;
+
+    default:
+      throw new Error(`Unhandled node type: ${nodeType}`);
+  }
+}
+
+/**
+ * Converts a validated AST node to a JavaScript value.
+ */
+function astToValue(node: Node): unknown {
+  switch (node.type) {
+    case 'Literal': {
+      const litNode = node as Node & { value: unknown };
+      return litNode.value;
+    }
+
+    case 'UnaryExpression': {
+      const unaryNode = node as Node & { operator: string; argument: Node };
+      const argValue = astToValue(unaryNode.argument) as number;
+      return unaryNode.operator === '-' ? -argValue : +argValue;
+    }
+
+    case 'Identifier': {
+      const identNode = node as Node & { name: string };
+      if (identNode.name === 'undefined') return undefined;
+      if (identNode.name === 'null') return null;
+      if (identNode.name === 'true') return true;
+      if (identNode.name === 'false') return false;
+      if (identNode.name === 'NaN') return NaN;
+      if (identNode.name === 'Infinity') return Infinity;
+      return identNode.name;
+    }
+
+    case 'ObjectExpression': {
+      const objNode = node as Node & { properties: Node[] };
+      const objResult: Record<string, unknown> = {};
+      objNode.properties.forEach(prop => {
+        const propNode = prop as Node & { key: Node; value: Node };
+        const key = astToValue(propNode.key) as string;
+        const value = astToValue(propNode.value);
+        objResult[key] = value;
+      });
+      return objResult;
+    }
+
+    case 'ArrayExpression': {
+      const arrNode = node as Node & { elements: (Node | null)[] };
+      return arrNode.elements.map(elem => (elem ? astToValue(elem) : null));
+    }
+
+    case 'TemplateLiteral': {
+      const templateNode = node as Node & {
+        quasis: Array<Node & { value: { cooked: string } }>;
+      };
+      return templateNode.quasis.map(q => q.value.cooked).join('');
+    }
+
+    default:
+      throw new Error(`Cannot convert node type: ${node.type}`);
+  }
+}
+
+// 
=============================================================================
+// Parse Result Types
+// 
=============================================================================
+
+interface ParseResult {
+  success: boolean;
+  data?: CustomEChartOptions;
+}
+
+// 
=============================================================================
+// Public API
+// 
=============================================================================
+
+/**
+ * Safely parses a JavaScript object literal string into an ECharts options 
object.
+ *
+ * This function performs two-stage validation:
+ * 1. AST analysis for security (blocks functions, eval, etc.)
+ * 2. Zod schema validation for type correctness
+ *
+ * @param input - A string containing a JavaScript object literal
+ * @returns ParseResult with either the parsed/validated data or throws 
EChartOptionsParseError
+ * @throws {EChartOptionsParseError} When parsing fails or validation errors 
occur
+ *
+ * @example
+ * ```typescript
+ * // Valid input
+ * const result = parseEChartOptions(`{
+ *   title: { text: 'My Chart', left: 'center' },
+ *   grid: { top: 50, bottom: 50 }
+ * }`);
+ *
+ * // Invalid input (title should be object, not string)
+ * // Throws EChartOptionsParseError with validation errors
+ * const result2 = parseEChartOptions(`{ title: 'text' }`);
+ * ```
+ */
+export function parseEChartOptions(input: string | undefined): ParseResult {
+  if (!input || typeof input !== 'string') {
+    return { success: true, data: undefined };
+  }
+
+  const trimmed = input.trim();
+  if (!trimmed) {
+    return { success: true, data: undefined };
+  }
+
+  // Step 1: Parse into AST
+  const wrappedInput = `(${trimmed})`;
+  let ast: Node & { body: Array<Node & { expression: Node }> };
+
+  try {
+    ast = parse(wrappedInput, {
+      ecmaVersion: 2020,
+      sourceType: 'script',
+    }) as Node & { body: Array<Node & { expression: Node }> };
+  } catch (error) {
+    const err = error as Error & { loc?: { line: number; column: number } };
+    throw new EChartOptionsParseError(err.message, 'parse_error', [], err.loc);
+  }
+
+  if (
+    !ast.body ||
+    ast.body.length !== 1 ||
+    ast.body[0].type !== 'ExpressionStatement'
+  ) {
+    throw new EChartOptionsParseError(
+      'Input must be a single object literal expression (e.g., { key: value 
})',
+      'parse_error',
+    );
+  }
+
+  const { expression } = ast.body[0];
+
+  if (expression.type !== 'ObjectExpression') {
+    throw new EChartOptionsParseError(
+      `Expected an object literal, but got: ${expression.type}`,
+      'parse_error',
+    );
+  }
+
+  // Step 2: Validate AST for security (no functions, eval, etc.)
+  try {
+    validateNode(expression);
+  } catch (error) {
+    const err = error as Error;
+    throw new EChartOptionsParseError(err.message, 'security_error');
+  }
+
+  // Step 3: Convert AST to JavaScript object
+  const rawData = astToValue(expression);
+
+  // Step 4: Validate against Zod schema with partial/lenient mode
+  // This will throw EChartOptionsParseError if validation fails
+  const validatedData = validatePartial(rawData as Record<string, unknown>);
+
+  return { success: true, data: validatedData };
+}
+
+/**
+ * Validates and parses EChart options.
+ * Throws EChartOptionsParseError on failure for the caller to handle.
+ *
+ * @param input - A string containing a JavaScript object literal
+ * @returns The parsed and validated EChart options, or undefined for empty 
input
+ * @throws {EChartOptionsParseError} When parsing fails or validation errors 
occur
+ */
+export function safeParseEChartOptions(
+  input: string | undefined,
+): CustomEChartOptions | undefined {
+  const result = parseEChartOptions(input);
+  return result.data;
+}
+
+export default parseEChartOptions;
diff --git a/superset-frontend/src/core/editors/AceEditorProvider.tsx 
b/superset-frontend/src/core/editors/AceEditorProvider.tsx
index 89c8a57911..9236403a3b 100644
--- a/superset-frontend/src/core/editors/AceEditorProvider.tsx
+++ b/superset-frontend/src/core/editors/AceEditorProvider.tsx
@@ -42,6 +42,7 @@ import {
   MarkdownEditor,
   CssEditor,
   ConfigEditor,
+  JSEditor,
   type AceCompleterKeyword,
 } from '@superset-ui/core/components';
 import { Disposable } from '../models';
@@ -70,6 +71,8 @@ const getEditorComponent = (language: string) => {
       return CssEditor;
     case 'yaml':
       return ConfigEditor;
+    case 'javascript':
+      return JSEditor;
     default:
       console.warn(
         `Unknown editor language "${language}", falling back to SQL editor`,
diff --git 
a/superset-frontend/src/explore/components/controls/JSEditorControl.test.tsx 
b/superset-frontend/src/explore/components/controls/JSEditorControl.test.tsx
new file mode 100644
index 0000000000..5a083702b9
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/JSEditorControl.test.tsx
@@ -0,0 +1,125 @@
+/**
+ * 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.
+ */
+import type { ReactNode } from 'react';
+import { render, screen, userEvent } from 'spec/helpers/testing-library';
+import JSEditorControl from 'src/explore/components/controls/JSEditorControl';
+
+jest.mock('react-virtualized-auto-sizer', () => ({
+  __esModule: true,
+  default: ({
+    children,
+  }: {
+    children: (params: { width: number; height: number }) => ReactNode;
+  }) => children({ width: 500, height: 250 }),
+}));
+
+jest.mock('src/core/editors', () => ({
+  EditorHost: ({
+    value,
+    onChange,
+  }: {
+    value: string;
+    onChange: (v: string) => void;
+  }) => (
+    <textarea
+      data-test="js-editor"
+      defaultValue={value}
+      onChange={e => onChange?.(e.target.value)}
+    />
+  ),
+}));
+
+jest.mock('src/hooks/useDebounceValue', () => ({
+  useDebounceValue: (value: string) => value,
+}));
+
+const defaultProps = {
+  name: 'echartOptions',
+  label: 'EChart Options',
+  onChange: jest.fn(),
+  value: '',
+};
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+test('renders the control with label', () => {
+  render(<JSEditorControl {...defaultProps} />);
+  expect(screen.getByText('EChart Options')).toBeInTheDocument();
+});
+
+test('renders the editor', () => {
+  render(<JSEditorControl {...defaultProps} />);
+  expect(screen.getByTestId('js-editor')).toBeInTheDocument();
+});
+
+test('renders with initial value', () => {
+  const value = "{ title: { text: 'Test' } }";
+  render(<JSEditorControl {...defaultProps} value={value} />);
+  const editor = screen.getByTestId('js-editor');
+  expect(editor).toHaveValue(value);
+});
+
+test('calls onChange when editor content changes', async () => {
+  render(<JSEditorControl {...defaultProps} />);
+  const editor = screen.getByTestId('js-editor');
+  await userEvent.type(editor, '{ }');
+  expect(defaultProps.onChange).toHaveBeenCalled();
+});
+
+test('displays validation error for invalid syntax', () => {
+  const invalidValue = '{ invalid syntax';
+  render(<JSEditorControl {...defaultProps} value={invalidValue} />);
+  expect(screen.getByTestId('error-tooltip')).toBeInTheDocument();
+});
+
+test('displays validation error for function expressions', () => {
+  const valueWithFunction = '{ formatter: () => {} }';
+  render(<JSEditorControl {...defaultProps} value={valueWithFunction} />);
+  expect(screen.getByTestId('error-tooltip')).toBeInTheDocument();
+});
+
+test('does not display error for valid EChart options', () => {
+  const validValue = "{ title: { text: 'Valid Chart' }, grid: { top: 50 } }";
+  render(<JSEditorControl {...defaultProps} value={validValue} />);
+  expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument();
+});
+
+test('does not display error for empty value', () => {
+  render(<JSEditorControl {...defaultProps} value="" />);
+  expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument();
+});
+
+test('does not display error for undefined value', () => {
+  render(<JSEditorControl {...defaultProps} value={undefined} />);
+  expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument();
+});
+
+test('renders with description tooltip', () => {
+  const description = 'Custom EChart configuration options';
+  render(<JSEditorControl {...defaultProps} description={description} />);
+  expect(screen.getByText('EChart Options')).toBeInTheDocument();
+});
+
+test('uses name as label when label is not provided', () => {
+  const props = { ...defaultProps, label: undefined };
+  render(<JSEditorControl {...props} />);
+  expect(screen.getByText('echartOptions')).toBeInTheDocument();
+});
diff --git 
a/superset-frontend/src/explore/components/controls/JSEditorControl.tsx 
b/superset-frontend/src/explore/components/controls/JSEditorControl.tsx
new file mode 100644
index 0000000000..f25d641953
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/JSEditorControl.tsx
@@ -0,0 +1,105 @@
+/**
+ * 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.
+ */
+import { useMemo } from 'react';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import ControlHeader, {
+  ControlHeaderProps,
+} from 'src/explore/components/ControlHeader';
+import { styled } from '@apache-superset/core';
+import { ControlComponentProps } from '@superset-ui/chart-controls';
+import {
+  safeParseEChartOptions,
+  EChartOptionsParseError,
+} from '@superset-ui/plugin-chart-echarts';
+import { EditorHost } from 'src/core/editors';
+import { useDebounceValue } from 'src/hooks/useDebounceValue';
+
+const Container = styled.div`
+  border: 1px solid ${({ theme }) => theme.colorBorder};
+  border-radius: ${({ theme }) => theme.borderRadius}px;
+  overflow: hidden;
+`;
+
+const ErrorMessage = styled.div`
+  color: ${({ theme }) => theme.colorErrorText};
+`;
+
+export default function JSEditorControl({
+  name,
+  label,
+  description,
+  renderTrigger,
+  hovered,
+  tooltipOnClick,
+  onChange,
+  value,
+}: ControlHeaderProps & ControlComponentProps<string>) {
+  const debouncedValue = useDebounceValue(value);
+  const error = useMemo(() => {
+    try {
+      safeParseEChartOptions(debouncedValue ?? '');
+      return null;
+    } catch (err) {
+      if (err instanceof EChartOptionsParseError) {
+        return err;
+      }
+      throw err;
+    }
+  }, [debouncedValue]);
+  const headerProps = {
+    name,
+    label: label ?? name,
+    description,
+    renderTrigger,
+    validationErrors: error?.message ? [error.message] : undefined,
+    hovered,
+    tooltipOnClick,
+  };
+
+  return (
+    <>
+      <ControlHeader {...headerProps} />
+      <Container>
+        <AutoSizer disableHeight>
+          {({ width }) => (
+            <EditorHost
+              id="echart-js-editor"
+              value={value ?? ''}
+              onChange={val => onChange?.(val)}
+              language="javascript"
+              tabSize={2}
+              lineNumbers
+              width={`${width}px`}
+              height="250px"
+            />
+          )}
+        </AutoSizer>
+      </Container>
+      {error && (
+        <ErrorMessage>
+          {error.validationErrors.length > 0 ? (
+            error.validationErrors.map((err, idx) => <div 
key={idx}>{err}</div>)
+          ) : (
+            <div>{error.message}</div>
+          )}
+        </ErrorMessage>
+      )}
+    </>
+  );
+}
diff --git a/superset-frontend/src/explore/components/controls/index.ts 
b/superset-frontend/src/explore/components/controls/index.ts
index 46b70ac27d..326da43ce8 100644
--- a/superset-frontend/src/explore/components/controls/index.ts
+++ b/superset-frontend/src/explore/components/controls/index.ts
@@ -59,6 +59,7 @@ import NumberControl from './NumberControl';
 import TimeRangeControl from './TimeRangeControl';
 import ColorBreakpointsControl from './ColorBreakpointsControl';
 import MatrixifyDimensionControl from './MatrixifyDimensionControl';
+import JSEditorControl from './JSEditorControl';
 
 const extensionsRegistry = getExtensionsRegistry();
 const DateFilterControlExtension = extensionsRegistry.get(
@@ -85,6 +86,7 @@ const controlMap = {
   FixedOrMetricControl,
   ColorBreakpointsControl,
   HiddenControl,
+  JSEditorControl,
   LayerConfigsControl,
   MapViewControl,
   SelectAsyncControl,


Reply via email to