You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/02/13 16:33:08 UTC

(superset) branch fix/echarts-series-theme-overrides created (now cfc9e0a64fb)

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

rusackas pushed a change to branch fix/echarts-series-theme-overrides
in repository https://gitbox.apache.org/repos/asf/superset.git


      at cfc9e0a64fb feat(theme): enable generalized ECharts theme overrides for array properties

This branch includes the following new commits:

     new cfc9e0a64fb feat(theme): enable generalized ECharts theme overrides for array properties

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(superset) 01/01: feat(theme): enable generalized ECharts theme overrides for array properties

Posted by ru...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch fix/echarts-series-theme-overrides
in repository https://gitbox.apache.org/repos/asf/superset.git

commit cfc9e0a64fb82f215736ab9dc373bccad7a00e15
Author: Evan Rusackas <ev...@rusackas.com>
AuthorDate: Thu Feb 12 20:44:04 2026 -0800

    feat(theme): enable generalized ECharts theme overrides for array properties
    
    Adds a custom merge function `mergeEchartsThemeOverrides` that handles
    ECharts theme overrides with special array-to-object merge behavior:
    
    1. Arrays in source values replace destination arrays entirely (backward compat)
    2. When source is a plain object and destination is an array, the object
       is merged into each array item (allowing default styles for all items)
    
    This enables theme authors to write intuitive overrides like:
    
    ```js
    echartsOptionsOverridesByChartType: {
      echarts_bar: {
        series: { itemStyle: { borderRadius: 4 } },  // Applied to ALL series
        yAxis: { axisLabel: { rotate: 45 } }         // Applied to ALL y-axes
      }
    }
    ```
    
    Works for any array-based ECharts option: series, xAxis, yAxis, dataZoom,
    visualMap, etc. - no need to handle each property specially.
    
    Co-Authored-By: Claude Opus 4.5 <no...@anthropic.com>
---
 .../plugin-chart-echarts/src/components/Echart.tsx |   4 +-
 .../src/utils/themeOverrides.test.ts               | 617 ++++++++++++++-------
 .../src/utils/themeOverrides.ts                    |  91 +++
 3 files changed, 498 insertions(+), 214 deletions(-)

diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
index c62f5535a30..2e3d5617e96 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
@@ -29,7 +29,6 @@ import {
 } from 'react';
 import { useSelector } from 'react-redux';
 
-import { mergeReplaceArrays } from '@superset-ui/core';
 import { styled, useTheme } from '@apache-superset/core/ui';
 import { use, init, EChartsType, registerLocale } from 'echarts/core';
 import {
@@ -66,6 +65,7 @@ import {
 import { LabelLayout } from 'echarts/features';
 import { EchartsHandler, EchartsProps, EchartsStylesProps } from '../types';
 import { DEFAULT_LOCALE } from '../constants';
+import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
 
 // Define this interface here to avoid creating a dependency back to superset-frontend,
 // TODO: to move the type to @superset-ui/core
@@ -244,7 +244,7 @@ function Echart(
         ? theme.echartsOptionsOverridesByChartType?.[vizType] || {}
         : {};
 
-      const themedEchartOptions = mergeReplaceArrays(
+      const themedEchartOptions = mergeEchartsThemeOverrides(
         baseTheme,
         echartOptions,
         globalOverrides,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.test.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.test.ts
index 931f026cb5f..5ab80512977 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.test.ts
@@ -16,248 +16,441 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { mergeReplaceArrays } from '@superset-ui/core';
-
-describe('Theme Override Deep Merge Behavior', () => {
-  test('should merge nested objects correctly', () => {
-    const baseOptions = {
-      grid: {
-        left: '5%',
-        right: '5%',
-        top: '10%',
-      },
-      xAxis: {
-        type: 'category',
-        axisLabel: {
-          fontSize: 12,
-        },
-      },
-    };
+import { mergeEchartsThemeOverrides } from './themeOverrides';
 
-    const globalOverrides = {
-      grid: {
-        left: '10%',
-        bottom: '15%',
-      },
-      xAxis: {
-        axisLabel: {
-          color: '#333',
-          rotate: 45,
-        },
-      },
-    };
+// =============================================================================
+// Basic Deep Merge Behavior
+// =============================================================================
 
-    const result = mergeReplaceArrays(baseOptions, globalOverrides);
+test('merges nested objects correctly', () => {
+  const baseOptions = {
+    grid: { left: '5%', right: '5%', top: '10%' },
+    xAxis: { type: 'category', axisLabel: { fontSize: 12 } },
+  };
 
-    expect(result).toEqual({
-      grid: {
-        left: '10%', // overridden
-        right: '5%', // preserved
-        top: '10%', // preserved
-        bottom: '15%', // added
-      },
-      xAxis: {
-        type: 'category', // preserved
-        axisLabel: {
-          fontSize: 12, // preserved
-          color: '#333', // added
-          rotate: 45, // added
-        },
+  const overrides = {
+    grid: { left: '10%', bottom: '15%' },
+    xAxis: { axisLabel: { color: '#333', rotate: 45 } },
+  };
+
+  const result = mergeEchartsThemeOverrides(baseOptions, overrides);
+
+  expect(result).toEqual({
+    grid: {
+      left: '10%', // overridden
+      right: '5%', // preserved
+      top: '10%', // preserved
+      bottom: '15%', // added
+    },
+    xAxis: {
+      type: 'category', // preserved
+      axisLabel: {
+        fontSize: 12, // preserved
+        color: '#333', // added
+        rotate: 45, // added
       },
-    });
+    },
   });
+});
+
+test('handles override precedence correctly (rightmost wins)', () => {
+  const baseTheme = { textStyle: { color: '#000', fontSize: 12 } };
+  const pluginOptions = { textStyle: { fontSize: 14 }, title: { text: 'Chart' } };
+  const globalOverrides = { textStyle: { color: '#333' }, grid: { left: '10%' } };
+  const chartOverrides = { textStyle: { color: '#666', fontWeight: 'bold' } };
+
+  const result = mergeEchartsThemeOverrides(
+    baseTheme,
+    pluginOptions,
+    globalOverrides,
+    chartOverrides,
+  );
 
-  test('should replace arrays instead of merging them', () => {
-    const baseOptions = {
-      series: [
-        { name: 'Series 1', type: 'line' },
-        { name: 'Series 2', type: 'bar' },
-      ],
-    };
+  expect(result).toEqual({
+    textStyle: {
+      color: '#666', // chart override wins
+      fontSize: 14, // from plugin options
+      fontWeight: 'bold', // from chart override
+    },
+    title: { text: 'Chart' },
+    grid: { left: '10%' },
+  });
+});
 
-    const overrides = {
-      series: [{ name: 'New Series', type: 'pie' }],
-    };
+test('handles null values correctly', () => {
+  const base = { grid: { left: '5%', right: '5%' } };
+  const overrides = { grid: { left: null, bottom: '20%' } };
 
-    const result = mergeReplaceArrays(baseOptions, overrides);
+  const result = mergeEchartsThemeOverrides(base, overrides);
 
-    // Arrays are replaced entirely, not merged by index
-    expect(result.series).toEqual([{ name: 'New Series', type: 'pie' }]);
-    expect(result.series).toHaveLength(1);
+  expect(result.grid).toEqual({
+    left: null,
+    right: '5%',
+    bottom: '20%',
   });
+});
 
-  test('should handle null overrides correctly', () => {
-    const baseOptions = {
-      grid: {
-        left: '5%',
-        right: '5%',
-        top: '10%',
-      },
-      tooltip: {
-        show: true,
-        backgroundColor: '#fff',
-      },
-    };
+test('handles function values correctly', () => {
+  const original = (v: number) => `${v}%`;
+  const override = (v: number) => `$${v}`;
 
-    const overrides = {
-      grid: {
-        left: null,
-        bottom: '20%',
-      },
-      tooltip: {
-        backgroundColor: null,
-        borderColor: '#ccc',
-      },
-    };
+  const base = { yAxis: { axisLabel: { formatter: original } } };
+  const overrides = { yAxis: { axisLabel: { formatter: override } } };
 
-    const result = mergeReplaceArrays(baseOptions, overrides);
+  const result = mergeEchartsThemeOverrides(base, overrides);
 
-    expect(result).toEqual({
-      grid: {
-        left: null, // overridden with null
-        right: '5%', // preserved (undefined values are ignored by lodash merge)
-        top: '10%', // preserved
-        bottom: '20%', // added
-      },
-      tooltip: {
-        show: true, // preserved
-        backgroundColor: null, // overridden with null
-        borderColor: '#ccc', // added
-      },
-    });
+  expect(result.yAxis.axisLabel.formatter).toBe(override);
+  expect(result.yAxis.axisLabel.formatter(100)).toBe('$100');
+});
+
+// =============================================================================
+// Array Replacement (Backward Compatibility)
+// =============================================================================
+
+test('replaces arrays entirely when override is an array', () => {
+  const base = {
+    series: [
+      { name: 'Series 1', type: 'line' },
+      { name: 'Series 2', type: 'bar' },
+    ],
+  };
+
+  const overrides = {
+    series: [{ name: 'New Series', type: 'pie' }],
+  };
+
+  const result = mergeEchartsThemeOverrides(base, overrides);
+
+  expect(result.series).toEqual([{ name: 'New Series', type: 'pie' }]);
+  expect(result.series).toHaveLength(1);
+});
+
+test('empty array override replaces existing array', () => {
+  const base = { series: [{ name: 'Test', data: [1, 2, 3] }] };
+  const overrides = { series: [] };
+
+  const result = mergeEchartsThemeOverrides(base, overrides);
+
+  expect(result.series).toEqual([]);
+});
+
+// =============================================================================
+// Object-to-Array Merging (NEW FEATURE)
+// =============================================================================
+
+test('merges object override into each series array item', () => {
+  const chartOptions = {
+    series: [
+      { type: 'bar', name: 'Revenue', data: [1, 2, 3] },
+      { type: 'bar', name: 'Profit', data: [4, 5, 6] },
+    ],
+  };
+
+  const override = {
+    series: { itemStyle: { borderRadius: 4 } },
+  };
+
+  const result = mergeEchartsThemeOverrides(chartOptions, override);
+
+  expect(result.series).toHaveLength(2);
+  expect(result.series[0]).toEqual({
+    type: 'bar',
+    name: 'Revenue',
+    data: [1, 2, 3],
+    itemStyle: { borderRadius: 4 },
   });
+  expect(result.series[1]).toEqual({
+    type: 'bar',
+    name: 'Profit',
+    data: [4, 5, 6],
+    itemStyle: { borderRadius: 4 },
+  });
+});
 
-  test('should handle override precedence correctly', () => {
-    const baseTheme = {
-      textStyle: { color: '#000', fontSize: 12 },
-    };
-
-    const pluginOptions = {
-      textStyle: { fontSize: 14 },
-      title: { text: 'Chart Title' },
-    };
-
-    const globalOverrides = {
-      textStyle: { color: '#333' },
-      grid: { left: '10%' },
-    };
-
-    const chartOverrides = {
-      textStyle: { color: '#666', fontWeight: 'bold' },
-      legend: { orient: 'vertical' },
-    };
-
-    // Simulate the merge order in Echart.tsx
-    const result = mergeReplaceArrays(
-      baseTheme,
-      pluginOptions,
-      globalOverrides,
-      chartOverrides,
-    );
-
-    expect(result).toEqual({
-      textStyle: {
-        color: '#666', // chart override wins
-        fontSize: 14, // from plugin options
-        fontWeight: 'bold', // from chart override
-      },
-      title: { text: 'Chart Title' }, // from plugin options
-      grid: { left: '10%' }, // from global override
-      legend: { orient: 'vertical' }, // from chart override
-    });
+test('merges object override into each xAxis array item', () => {
+  const chartOptions = {
+    xAxis: [
+      { type: 'category', data: ['Mon', 'Tue'] },
+      { type: 'value', position: 'top' },
+    ],
+  };
+
+  const override = {
+    xAxis: { axisLabel: { rotate: 45 } },
+  };
+
+  const result = mergeEchartsThemeOverrides(chartOptions, override);
+
+  expect(result.xAxis).toHaveLength(2);
+  expect(result.xAxis[0]).toEqual({
+    type: 'category',
+    data: ['Mon', 'Tue'],
+    axisLabel: { rotate: 45 },
   });
+  expect(result.xAxis[1]).toEqual({
+    type: 'value',
+    position: 'top',
+    axisLabel: { rotate: 45 },
+  });
+});
 
-  test('should preserve deep nested structures', () => {
-    const baseOptions = {
-      xAxis: {
-        axisLabel: {
-          textStyle: {
-            color: '#000',
-            fontSize: 12,
-            fontFamily: 'Arial',
-          },
-        },
-      },
-    };
-
-    const overrides = {
-      xAxis: {
-        axisLabel: {
-          textStyle: {
-            color: '#333',
-            fontWeight: 'bold',
-          },
-          rotate: 45,
-        },
-        splitLine: {
-          show: true,
-        },
-      },
-    };
-
-    const result = mergeReplaceArrays(baseOptions, overrides);
-
-    expect(result).toEqual({
-      xAxis: {
-        axisLabel: {
-          textStyle: {
-            color: '#333', // overridden
-            fontSize: 12, // preserved
-            fontFamily: 'Arial', // preserved
-            fontWeight: 'bold', // added
-          },
-          rotate: 45, // added
-        },
-        splitLine: {
-          show: true, // added
-        },
-      },
-    });
+test('merges object override into each yAxis array item', () => {
+  const chartOptions = {
+    yAxis: [
+      { type: 'value', name: 'Revenue' },
+      { type: 'value', name: 'Count' },
+    ],
+  };
+
+  const override = {
+    yAxis: { axisLine: { show: true }, splitLine: { show: false } },
+  };
+
+  const result = mergeEchartsThemeOverrides(chartOptions, override);
+
+  expect(result.yAxis).toHaveLength(2);
+  expect(result.yAxis[0]).toEqual({
+    type: 'value',
+    name: 'Revenue',
+    axisLine: { show: true },
+    splitLine: { show: false },
+  });
+  expect(result.yAxis[1]).toEqual({
+    type: 'value',
+    name: 'Count',
+    axisLine: { show: true },
+    splitLine: { show: false },
   });
+});
+
+test('merges object override into dataZoom array items', () => {
+  const chartOptions = {
+    dataZoom: [
+      { type: 'inside', xAxisIndex: 0 },
+      { type: 'slider', xAxisIndex: 0 },
+    ],
+  };
+
+  const override = {
+    dataZoom: { filterMode: 'filter' },
+  };
+
+  const result = mergeEchartsThemeOverrides(chartOptions, override);
 
-  test('should handle function values correctly', () => {
-    const formatFunction = (value: any) => `${value}%`;
-    const overrideFunction = (value: any) => `$${value}`;
+  expect(result.dataZoom).toHaveLength(2);
+  expect(result.dataZoom[0]).toEqual({
+    type: 'inside',
+    xAxisIndex: 0,
+    filterMode: 'filter',
+  });
+  expect(result.dataZoom[1]).toEqual({
+    type: 'slider',
+    xAxisIndex: 0,
+    filterMode: 'filter',
+  });
+});
 
-    const baseOptions = {
-      yAxis: {
-        axisLabel: {
-          formatter: formatFunction,
-        },
+test('preserves existing properties when merging into array items', () => {
+  const chartOptions = {
+    series: [
+      {
+        type: 'bar',
+        itemStyle: { color: 'red', borderWidth: 2 },
       },
-    };
+    ],
+  };
+
+  const override = {
+    series: { itemStyle: { borderRadius: 4 } },
+  };
+
+  const result = mergeEchartsThemeOverrides(chartOptions, override);
+
+  expect(result.series[0].itemStyle).toEqual({
+    color: 'red', // preserved
+    borderWidth: 2, // preserved
+    borderRadius: 4, // added
+  });
+});
+
+test('applies multiple object overrides in order', () => {
+  const chartOptions = {
+    series: [{ type: 'bar' }],
+  };
 
-    const overrides = {
-      yAxis: {
-        axisLabel: {
-          formatter: overrideFunction,
-        },
+  const globalOverride = {
+    series: { itemStyle: { borderRadius: 2, color: 'blue' } },
+  };
+
+  const chartOverride = {
+    series: { itemStyle: { borderRadius: 8 } },
+  };
+
+  const result = mergeEchartsThemeOverrides(
+    chartOptions,
+    globalOverride,
+    chartOverride,
+  );
+
+  expect(result.series[0].itemStyle).toEqual({
+    borderRadius: 8, // chart override wins
+    color: 'blue', // global override preserved
+  });
+});
+
+test('handles deeply nested overrides in array items', () => {
+  const chartOptions = {
+    series: [
+      {
+        type: 'bar',
+        label: { show: true, position: 'top' },
       },
-    };
+    ],
+  };
 
-    const result = mergeReplaceArrays(baseOptions, overrides);
+  const override = {
+    series: {
+      label: { formatter: '{c}', fontSize: 14 },
+      itemStyle: { borderRadius: [4, 4, 0, 0] },
+    },
+  };
 
-    expect(result.yAxis.axisLabel.formatter).toBe(overrideFunction);
-    expect(result.yAxis.axisLabel.formatter('100')).toBe('$100');
+  const result = mergeEchartsThemeOverrides(chartOptions, override);
+
+  expect(result.series[0]).toEqual({
+    type: 'bar',
+    label: {
+      show: true, // preserved
+      position: 'top', // preserved
+      formatter: '{c}', // added
+      fontSize: 14, // added
+    },
+    itemStyle: { borderRadius: [4, 4, 0, 0] },
   });
+});
+
+// =============================================================================
+// Edge Cases
+// =============================================================================
 
-  test('should handle empty objects and arrays', () => {
-    const baseOptions = {
-      series: [{ name: 'Test', data: [1, 2, 3] }],
-      grid: { left: '5%' },
-    };
+test('handles single object xAxis (not array) normally', () => {
+  const chartOptions = {
+    xAxis: { type: 'category', data: ['Mon', 'Tue'] },
+  };
 
-    const emptyOverrides = {};
-    const arrayOverride = { series: [] };
-    const objectOverride = { grid: {} };
+  const override = {
+    xAxis: { axisLabel: { rotate: 45 } },
+  };
 
-    const resultEmpty = mergeReplaceArrays(baseOptions, emptyOverrides);
-    const resultArray = mergeReplaceArrays(baseOptions, arrayOverride);
-    const resultObject = mergeReplaceArrays(baseOptions, objectOverride);
+  const result = mergeEchartsThemeOverrides(chartOptions, override);
+
+  expect(result.xAxis).toEqual({
+    type: 'category',
+    data: ['Mon', 'Tue'],
+    axisLabel: { rotate: 45 },
+  });
+});
 
-    expect(resultEmpty).toEqual(baseOptions);
-    // Empty array completely replaces existing array
-    expect(resultArray.series).toEqual([]);
-    expect(resultObject.grid).toEqual({ left: '5%' });
+test('skips non-object array items when merging', () => {
+  const chartOptions = {
+    series: [
+      { type: 'bar' },
+      'invalid', // non-object item
+      null, // null item
+      { type: 'line' },
+    ],
+  };
+
+  const override = {
+    series: { itemStyle: { borderRadius: 4 } },
+  };
+
+  const result = mergeEchartsThemeOverrides(chartOptions, override);
+
+  expect(result.series).toHaveLength(4);
+  expect(result.series[0]).toEqual({
+    type: 'bar',
+    itemStyle: { borderRadius: 4 },
+  });
+  expect(result.series[1]).toBe('invalid'); // unchanged
+  expect(result.series[2]).toBe(null); // unchanged
+  expect(result.series[3]).toEqual({
+    type: 'line',
+    itemStyle: { borderRadius: 4 },
+  });
+});
+
+test('handles empty overrides gracefully', () => {
+  const chartOptions = {
+    series: [{ type: 'bar' }],
+  };
+
+  const result = mergeEchartsThemeOverrides(chartOptions, {});
+
+  expect(result).toEqual(chartOptions);
+});
+
+test('handles missing array property in base', () => {
+  const chartOptions = {
+    grid: { left: '10%' },
+  };
+
+  const override = {
+    series: { itemStyle: { borderRadius: 4 } },
+  };
+
+  const result = mergeEchartsThemeOverrides(chartOptions, override);
+
+  // series override is just added as-is since there's no array to merge into
+  expect(result).toEqual({
+    grid: { left: '10%' },
+    series: { itemStyle: { borderRadius: 4 } },
+  });
+});
+
+test('works with the full Echart.tsx merge pattern', () => {
+  const baseTheme = {
+    textStyle: { color: '#333' },
+  };
+
+  const echartOptions = {
+    series: [
+      { type: 'bar', data: [1, 2, 3] },
+      { type: 'bar', data: [4, 5, 6] },
+    ],
+    xAxis: { type: 'category' },
+  };
+
+  const globalOverrides = {
+    series: { itemStyle: { opacity: 0.8 } },
+  };
+
+  const chartOverrides = {
+    series: { itemStyle: { borderRadius: 4 } },
+    xAxis: { axisLabel: { rotate: 45 } },
+  };
+
+  const result = mergeEchartsThemeOverrides(
+    baseTheme,
+    echartOptions,
+    globalOverrides,
+    chartOverrides,
+  );
+
+  expect(result.textStyle).toEqual({ color: '#333' });
+  expect(result.xAxis).toEqual({
+    type: 'category',
+    axisLabel: { rotate: 45 },
+  });
+  expect(result.series).toHaveLength(2);
+  expect(result.series[0]).toEqual({
+    type: 'bar',
+    data: [1, 2, 3],
+    itemStyle: { opacity: 0.8, borderRadius: 4 },
+  });
+  expect(result.series[1]).toEqual({
+    type: 'bar',
+    data: [4, 5, 6],
+    itemStyle: { opacity: 0.8, borderRadius: 4 },
   });
 });
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.ts
new file mode 100644
index 00000000000..79423dd726b
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.ts
@@ -0,0 +1,91 @@
+/**
+ * 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 { mergeWith, isPlainObject } from 'lodash';
+
+/**
+ * Custom merge function for ECharts theme overrides.
+ *
+ * This function extends lodash's mergeWith with special handling:
+ * 1. Arrays in source values replace destination arrays entirely (backward compatibility)
+ * 2. When source is a plain object and destination is an array, the object is merged
+ *    into each array item (allowing default styles to be applied to all items)
+ *
+ * This enables theme authors to write intuitive overrides like:
+ * ```js
+ * echartsOptionsOverridesByChartType: {
+ *   echarts_bar: {
+ *     series: { itemStyle: { borderRadius: 4 } },  // Applied to ALL series
+ *     yAxis: { axisLabel: { rotate: 45 } }         // Applied to ALL y-axes
+ *   }
+ * }
+ * ```
+ *
+ * Without this special handling, specifying `series` or `yAxis` as objects would
+ * fail because the chart's actual values are arrays, and standard object merging
+ * doesn't make sense for array-to-object merges.
+ *
+ * @param sources - Objects to merge (rightmost wins, with special array handling)
+ * @returns Merged object with the custom array-object merge behavior
+ *
+ * @example
+ * // Chart has multiple series:
+ * const chartOptions = {
+ *   series: [
+ *     { type: 'bar', name: 'Revenue', data: [1, 2, 3] },
+ *     { type: 'bar', name: 'Profit', data: [4, 5, 6] }
+ *   ]
+ * };
+ *
+ * // Theme override with object (not array):
+ * const override = {
+ *   series: { itemStyle: { borderRadius: 4 } }
+ * };
+ *
+ * // Result: borderRadius applied to EACH series
+ * mergeEchartsThemeOverrides(chartOptions, override);
+ * // {
+ * //   series: [
+ * //     { type: 'bar', name: 'Revenue', data: [1, 2, 3], itemStyle: { borderRadius: 4 } },
+ * //     { type: 'bar', name: 'Profit', data: [4, 5, 6], itemStyle: { borderRadius: 4 } }
+ * //   ]
+ * // }
+ */
+export function mergeEchartsThemeOverrides<T = any>(...sources: any[]): T {
+  const customizer = (objValue: any, srcValue: any): any => {
+    // If source is an array, replace entirely (backward compatibility)
+    if (Array.isArray(srcValue)) {
+      return srcValue;
+    }
+
+    // If destination is an array and source is a plain object,
+    // merge the object into each array item (apply defaults to all items)
+    if (Array.isArray(objValue) && isPlainObject(srcValue)) {
+      return objValue.map(item =>
+        isPlainObject(item)
+          ? mergeWith({}, item, srcValue, customizer)
+          : item,
+      );
+    }
+
+    // Let lodash handle other cases (deep object merge, primitives, etc.)
+    return undefined;
+  };
+
+  return mergeWith({}, ...sources, customizer);
+}