feat: Add ECharts options overrides to theme system (#34876)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Maxime Beauchemin
2025-09-15 13:52:38 -07:00
committed by GitHub
parent 088ecdd0bf
commit c2534f9155
15 changed files with 615 additions and 11 deletions

View File

@@ -165,6 +165,206 @@ Or in the CRUD interface theme JSON:
This feature works with the stock Docker image - no custom build required!
## ECharts Configuration Overrides
:::note
Available since Superset 6.0
:::
Superset provides fine-grained control over ECharts visualizations through theme-level configuration overrides. This allows you to customize the appearance and behavior of all ECharts-based charts without modifying individual chart configurations.
### Global ECharts Overrides
Apply settings to all ECharts visualizations using `echartsOptionsOverrides`:
```python
THEME_DEFAULT = {
"token": {
"colorPrimary": "#2893B3",
# ... other Ant Design tokens
},
"echartsOptionsOverrides": {
"grid": {
"left": "10%",
"right": "10%",
"top": "15%",
"bottom": "15%"
},
"tooltip": {
"backgroundColor": "rgba(0, 0, 0, 0.8)",
"borderColor": "#ccc",
"textStyle": {
"color": "#fff"
}
},
"legend": {
"textStyle": {
"fontSize": 14,
"fontWeight": "bold"
}
}
}
}
```
### Chart-Specific Overrides
Target specific chart types using `echartsOptionsOverridesByChartType`:
```python
THEME_DEFAULT = {
"token": {
"colorPrimary": "#2893B3",
# ... other tokens
},
"echartsOptionsOverridesByChartType": {
"echarts_pie": {
"legend": {
"orient": "vertical",
"right": 10,
"top": "center"
}
},
"echarts_timeseries": {
"xAxis": {
"axisLabel": {
"rotate": 45,
"fontSize": 12
}
},
"dataZoom": [{
"type": "slider",
"show": True,
"start": 0,
"end": 100
}]
},
"echarts_bubble": {
"grid": {
"left": "15%",
"bottom": "20%"
}
}
}
}
```
### UI Configuration
You can also configure ECharts overrides through the theme CRUD interface:
```json
{
"token": {
"colorPrimary": "#2893B3"
},
"echartsOptionsOverrides": {
"grid": {
"left": "10%",
"right": "10%"
},
"tooltip": {
"backgroundColor": "rgba(0, 0, 0, 0.8)"
}
},
"echartsOptionsOverridesByChartType": {
"echarts_pie": {
"legend": {
"orient": "vertical",
"right": 10
}
}
}
}
```
### Override Precedence
The system applies overrides in the following order (last wins):
1. **Base ECharts theme** - Default Superset styling
2. **Plugin options** - Chart-specific configurations
3. **Global overrides** - `echartsOptionsOverrides`
4. **Chart-specific overrides** - `echartsOptionsOverridesByChartType[chartType]`
This ensures chart-specific overrides take precedence over global ones.
### Common Chart Types
Available chart types for `echartsOptionsOverridesByChartType`:
- `echarts_timeseries` - Time series/line charts
- `echarts_pie` - Pie and donut charts
- `echarts_bubble` - Bubble/scatter charts
- `echarts_funnel` - Funnel charts
- `echarts_gauge` - Gauge charts
- `echarts_radar` - Radar charts
- `echarts_boxplot` - Box plot charts
- `echarts_treemap` - Treemap charts
- `echarts_sunburst` - Sunburst charts
- `echarts_graph` - Network/graph charts
- `echarts_sankey` - Sankey diagrams
- `echarts_heatmap` - Heatmaps
- `echarts_mixed_timeseries` - Mixed time series
### Best Practices
1. **Start with global overrides** for consistent styling across all charts
2. **Use chart-specific overrides** for unique requirements per visualization type
3. **Test thoroughly** as overrides use deep merge - nested objects are combined, but arrays are completely replaced
4. **Document your overrides** to help team members understand custom styling
5. **Consider performance** - complex overrides may impact chart rendering speed
### Example: Corporate Branding
```python
# Complete corporate theme with ECharts customization
THEME_DEFAULT = {
"token": {
"colorPrimary": "#1B4D3E",
"fontFamily": "Corporate Sans, Arial, sans-serif"
},
"echartsOptionsOverrides": {
"grid": {
"left": "8%",
"right": "8%",
"top": "12%",
"bottom": "12%"
},
"textStyle": {
"fontFamily": "Corporate Sans, Arial, sans-serif"
},
"title": {
"textStyle": {
"color": "#1B4D3E",
"fontSize": 18,
"fontWeight": "bold"
}
}
},
"echartsOptionsOverridesByChartType": {
"echarts_timeseries": {
"xAxis": {
"axisLabel": {
"color": "#666",
"fontSize": 11
}
}
},
"echarts_pie": {
"legend": {
"textStyle": {
"fontSize": 12
},
"itemGap": 20
}
}
}
}
```
This feature provides powerful theming capabilities while maintaining the flexibility of ECharts' extensive configuration options.
## Advanced Features
- **System Themes**: Manage system-wide default and dark themes via UI or configuration

View File

@@ -127,6 +127,15 @@ export interface SupersetSpecificTokens {
// Spinner-related
brandSpinnerUrl?: string;
brandSpinnerSvg?: string;
// ECharts-related
/** Global ECharts configuration overrides applied to all chart types */
echartsOptionsOverrides?: any;
/** Chart-specific ECharts configuration overrides keyed by viz_type */
echartsOptionsOverridesByChartType?: {
[chartType: string]: any;
};
}
/**

View File

@@ -33,3 +33,4 @@ export * from './random';
export * from './typedMemo';
export * from './html';
export * from './tooltip';
export * from './merge';

View File

@@ -0,0 +1,61 @@
/**
* 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 { mergeReplaceArrays } from './merge';
describe('lodash utilities', () => {
describe('mergeReplaceArrays', () => {
it('should merge objects and replace arrays', () => {
const obj1 = { a: [1, 2], b: { c: 3 } };
const obj2 = { a: [4, 5], b: { d: 6 } };
const result = mergeReplaceArrays(obj1, obj2);
expect(result).toEqual({
a: [4, 5], // array replaced
b: { c: 3, d: 6 }, // objects merged
});
});
it('should handle precedence with multiple sources', () => {
const base = { x: { y: 1 }, z: [1] };
const override1 = { x: { y: 2 }, z: [2, 3] };
const override2 = { x: { y: 3 }, z: [4] };
const result = mergeReplaceArrays(base, override1, override2);
expect(result).toEqual({
x: { y: 3 }, // last wins
z: [4], // array replaced by last
});
});
it('should handle empty and null values', () => {
const base = { a: [1], b: { x: 1 } };
const override = { a: [], b: { x: null } };
const result = mergeReplaceArrays(base, override);
expect(result).toEqual({
a: [], // empty array replaces
b: { x: null }, // null overrides
});
});
});
});

View File

@@ -0,0 +1,52 @@
/**
* 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 } from 'lodash';
/**
* Merges objects using lodash.mergeWith, but replaces arrays instead of concatenating them.
* This is useful for configuration objects where you want to completely override array values
* rather than merge them by index.
*
* @example
* const obj1 = { a: [1, 2], b: { c: 3 } };
* const obj2 = { a: [4, 5], b: { d: 6 } };
* mergeReplaceArrays(obj1, obj2);
* // Result: { a: [4, 5], b: { c: 3, d: 6 } }
*
* @example
* // ECharts configuration merging
* const baseConfig = { series: [{ type: 'line' }], grid: { left: '10%' } };
* const overrides = { series: [{ type: 'bar' }], grid: { right: '10%' } };
* mergeReplaceArrays(baseConfig, overrides);
* // Result: { series: [{ type: 'bar' }], grid: { left: '10%', right: '10%' } }
*
* @param sources - Objects to merge (rightmost wins for arrays, deep merge for objects)
* @returns Merged object with arrays replaced, not concatenated
*/
export function mergeReplaceArrays<T = any>(...sources: any[]): T {
const replaceArrays = (objValue: any, srcValue: any) => {
if (Array.isArray(srcValue)) {
return srcValue; // Replace arrays entirely
}
return undefined; // Let lodash handle object merging for non-arrays
};
return mergeWith({}, ...sources, replaceArrays);
}

View File

@@ -20,13 +20,14 @@ import { BubbleChartTransformedProps } from './types';
import Echart from '../components/Echart';
export default function EchartsBubble(props: BubbleChartTransformedProps) {
const { height, width, echartOptions, refs } = props;
const { height, width, echartOptions, refs, formData } = props;
return (
<Echart
height={height}
width={width}
echartOptions={echartOptions}
refs={refs}
vizType={formData.vizType}
/>
);
}

View File

@@ -21,7 +21,8 @@ import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsFunnel(props: FunnelChartTransformedProps) {
const { height, width, echartOptions, selectedValues, refs } = props;
const { height, width, echartOptions, selectedValues, refs, formData } =
props;
const eventHandlers = allEventHandlers(props);
@@ -33,6 +34,7 @@ export default function EchartsFunnel(props: FunnelChartTransformedProps) {
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}
/>
);
}

View File

@@ -21,7 +21,8 @@ import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsGauge(props: GaugeChartTransformedProps) {
const { height, width, echartOptions, selectedValues, refs } = props;
const { height, width, echartOptions, selectedValues, refs, formData } =
props;
const eventHandlers = allEventHandlers(props);
@@ -33,6 +34,7 @@ export default function EchartsGauge(props: GaugeChartTransformedProps) {
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}
/>
);
}

View File

@@ -21,7 +21,8 @@ import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsPie(props: PieChartTransformedProps) {
const { height, width, echartOptions, selectedValues, refs } = props;
const { height, width, echartOptions, selectedValues, refs, formData } =
props;
const eventHandlers = allEventHandlers(props);
@@ -33,6 +34,7 @@ export default function EchartsPie(props: PieChartTransformedProps) {
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}
/>
);
}

View File

@@ -21,7 +21,8 @@ import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsRadar(props: RadarChartTransformedProps) {
const { height, width, echartOptions, selectedValues, refs } = props;
const { height, width, echartOptions, selectedValues, refs, formData } =
props;
const eventHandlers = allEventHandlers(props);
return (
@@ -32,6 +33,7 @@ export default function EchartsRadar(props: RadarChartTransformedProps) {
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}
/>
);
}

View File

@@ -282,6 +282,7 @@ export default function EchartsTimeseries({
eventHandlers={eventHandlers}
zrEventHandlers={zrEventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}
/>
</>
);

View File

@@ -27,11 +27,9 @@ import {
Ref,
useState,
} from 'react';
import { merge } from 'lodash';
import { useSelector } from 'react-redux';
import { styled, useTheme } from '@superset-ui/core';
import { styled, useTheme, mergeReplaceArrays } from '@superset-ui/core';
import { use, init, EChartsType, registerLocale } from 'echarts/core';
import {
SankeyChart,
@@ -131,6 +129,7 @@ function Echart(
zrEventHandlers,
selectedValues = {},
refs,
vizType,
}: EchartsProps,
ref: Ref<EchartsHandler>,
) {
@@ -237,11 +236,19 @@ function Echart(
return echartsTheme;
};
const themedEchartOptions = merge(
{},
getEchartsTheme(echartOptions),
const baseTheme = getEchartsTheme(echartOptions);
const globalOverrides = theme.echartsOptionsOverrides || {};
const chartOverrides = vizType
? theme.echartsOptionsOverridesByChartType?.[vizType] || {}
: {};
const themedEchartOptions = mergeReplaceArrays(
baseTheme,
echartOptions,
globalOverrides,
chartOverrides,
);
chartRef.current?.setOption(themedEchartOptions, true);
}
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme]);

View File

@@ -55,6 +55,7 @@ export interface EchartsProps {
selectedValues?: Record<number, string>;
forceClear?: boolean;
refs: Refs;
vizType?: string;
}
export interface EchartsHandler {

View File

@@ -0,0 +1,263 @@
/**
* 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 { 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,
},
},
};
const globalOverrides = {
grid: {
left: '10%',
bottom: '15%',
},
xAxis: {
axisLabel: {
color: '#333',
rotate: 45,
},
},
};
const result = mergeReplaceArrays(baseOptions, globalOverrides);
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('should replace arrays instead of merging them', () => {
const baseOptions = {
series: [
{ name: 'Series 1', type: 'line' },
{ name: 'Series 2', type: 'bar' },
],
};
const overrides = {
series: [{ name: 'New Series', type: 'pie' }],
};
const result = mergeReplaceArrays(baseOptions, overrides);
// Arrays are replaced entirely, not merged by index
expect(result.series).toEqual([{ name: 'New Series', type: 'pie' }]);
expect(result.series).toHaveLength(1);
});
test('should handle null overrides correctly', () => {
const baseOptions = {
grid: {
left: '5%',
right: '5%',
top: '10%',
},
tooltip: {
show: true,
backgroundColor: '#fff',
},
};
const overrides = {
grid: {
left: null,
bottom: '20%',
},
tooltip: {
backgroundColor: null,
borderColor: '#ccc',
},
};
const result = mergeReplaceArrays(baseOptions, 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
},
});
});
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('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('should handle function values correctly', () => {
const formatFunction = (value: any) => `${value}%`;
const overrideFunction = (value: any) => `$${value}`;
const baseOptions = {
yAxis: {
axisLabel: {
formatter: formatFunction,
},
},
};
const overrides = {
yAxis: {
axisLabel: {
formatter: overrideFunction,
},
},
};
const result = mergeReplaceArrays(baseOptions, overrides);
expect(result.yAxis.axisLabel.formatter).toBe(overrideFunction);
expect(result.yAxis.axisLabel.formatter('100')).toBe('$100');
});
test('should handle empty objects and arrays', () => {
const baseOptions = {
series: [{ name: 'Test', data: [1, 2, 3] }],
grid: { left: '5%' },
};
const emptyOverrides = {};
const arrayOverride = { series: [] };
const objectOverride = { grid: {} };
const resultEmpty = mergeReplaceArrays(baseOptions, emptyOverrides);
const resultArray = mergeReplaceArrays(baseOptions, arrayOverride);
const resultObject = mergeReplaceArrays(baseOptions, objectOverride);
expect(resultEmpty).toEqual(baseOptions);
// Empty array completely replaces existing array
expect(resultArray.series).toEqual([]);
expect(resultObject.grid).toEqual({ left: '5%' });
});
});

0
superset-frontend/scripts/build.js Normal file → Executable file
View File