This is an automated email from the ASF dual-hosted git repository.
rusackas 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 6966c1f6ccb fix(cartodiagram): share Pie colors across locations in
Explore (#44794)
6966c1f6ccb is described below
commit 6966c1f6ccb590c7ea47e3332c839fa0bb1b0f75
Author: Endi Monan <[email protected]>
AuthorDate: Sat Oct 3 07:17:49 2026 +0200
fix(cartodiagram): share Pie colors across locations in Explore (#44794)
---
docs/developer_docs/contributing/howtos.md | 11 ++
.../src/chart/models/ChartProps.ts | 10 +
.../test/chart/models/ChartProps.test.ts | 22 ++-
.../src/util/transformPropsUtil.ts | 8 +
.../test/util/pieColors.test.ts | 203 +++++++++++++++++++++
.../plugin-chart-echarts/src/Pie/transformProps.ts | 4 +-
6 files changed, 256 insertions(+), 2 deletions(-)
diff --git a/docs/developer_docs/contributing/howtos.md
b/docs/developer_docs/contributing/howtos.md
index f8515759794..581ddc23c2f 100644
--- a/docs/developer_docs/contributing/howtos.md
+++ b/docs/developer_docs/contributing/howtos.md
@@ -107,6 +107,17 @@ npm link superset-plugin-chart-hello-world
7. **Import and register in Superset**:
Edit `superset-frontend/src/visualizations/presets/MainPreset.ts` to
include your plugin.
+### Sharing colors in composed charts
+
+`ChartProps.colorScale` accepts an optional `CategoricalColorScale`. A parent
+visualization can pass the same scale to its child chart transforms so
categories
+use consistent colors across the composition. Create a fresh scale for each
parent
+transformation using the selected color scheme. Child plugins must explicitly
use
+the supplied scale, falling back to their own scale when it is absent.
+
+Cartodiagram uses this contract with Pie charts to coordinate category colors
+across map locations in Explore.
+
## Testing
### Python Testing
diff --git
a/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts
b/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts
index 99519d16e37..6900a375a29 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts
+++ b/superset-frontend/packages/superset-ui-core/src/chart/models/ChartProps.ts
@@ -23,6 +23,7 @@ import { supersetTheme, SupersetTheme } from
'@apache-superset/core/theme';
import {
AppSection,
Behavior,
+ CategoricalColorScale,
convertKeysToCamelCase,
Datasource,
FilterState,
@@ -102,6 +103,8 @@ export type Hooks = {
*/
export interface ChartPropsConfig {
annotationData?: AnnotationData;
+ /** Categorical scale shared by charts composed within one visualization. */
+ colorScale?: CategoricalColorScale;
/** Datasource metadata */
datasource?: SnakeCaseDatasource;
initialValues?: DataRecordFilters;
@@ -147,6 +150,8 @@ export default class ChartProps<FormData extends
RawFormData = RawFormData> {
annotationData: AnnotationData;
+ colorScale?: CategoricalColorScale;
+
datasource: Datasource;
rawDatasource: SnakeCaseDatasource;
@@ -196,6 +201,7 @@ export default class ChartProps<FormData extends
RawFormData = RawFormData> {
) {
const {
annotationData = {},
+ colorScale,
datasource = {},
formData = {} as FormData,
hooks = {},
@@ -219,6 +225,7 @@ export default class ChartProps<FormData extends
RawFormData = RawFormData> {
this.width = width;
this.height = height;
this.annotationData = annotationData;
+ this.colorScale = colorScale;
this.datasource = convertKeysToCamelCase(datasource) as Datasource;
this.rawDatasource = datasource;
this.formData = convertKeysToCamelCase(formData);
@@ -264,6 +271,7 @@ ChartProps.createSelector = function create():
ChartPropsSelector {
input => input.inContextMenu,
input => input.emitCrossFilters,
input => input.theme,
+ input => input.colorScale,
(
annotationData,
datasource,
@@ -285,6 +293,7 @@ ChartProps.createSelector = function create():
ChartPropsSelector {
inContextMenu,
emitCrossFilters,
theme,
+ colorScale,
) =>
new ChartProps({
annotationData,
@@ -307,6 +316,7 @@ ChartProps.createSelector = function create():
ChartPropsSelector {
inContextMenu,
emitCrossFilters,
theme,
+ colorScale,
}),
// Below config is to retain usage of 1-sized `lruMemoize` object in
Reselect v4
// Reselect v5 introduces `weakMapMemoize` which is more performant but
potentially memory-leaky
diff --git
a/superset-frontend/packages/superset-ui-core/test/chart/models/ChartProps.test.ts
b/superset-frontend/packages/superset-ui-core/test/chart/models/ChartProps.test.ts
index 72614935ec6..afa46ded88e 100644
---
a/superset-frontend/packages/superset-ui-core/test/chart/models/ChartProps.test.ts
+++
b/superset-frontend/packages/superset-ui-core/test/chart/models/ChartProps.test.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { Behavior, ChartProps } from '@superset-ui/core';
+import { Behavior, CategoricalColorScale, ChartProps } from
'@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
const RAW_FORM_DATA = {
@@ -150,3 +150,23 @@ describe('ChartProps', () => {
});
});
});
+
+test('preserves an optional shared color scale through construction and
selector updates', () => {
+ const colorScale = new CategoricalColorScale(['#112233', '#445566']);
+ const config = { theme: supersetTheme, colorScale };
+ expect(new ChartProps(config).colorScale).toBe(colorScale);
+ expect(new ChartProps({ theme: supersetTheme }).colorScale).toBeUndefined();
+
+ const selector = ChartProps.createSelector();
+ const first = selector(config);
+ expect(first.colorScale).toBe(colorScale);
+ expect(selector({ ...config })).toBe(first);
+
+ const replacement = new CategoricalColorScale(['#778899']);
+ const next = selector({ ...config, colorScale: replacement });
+ expect(next).not.toBe(first);
+ expect(next.colorScale).toBe(replacement);
+ expect(
+ selector({ ...config, colorScale: undefined }).colorScale,
+ ).toBeUndefined();
+});
diff --git
a/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/transformPropsUtil.ts
b/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/transformPropsUtil.ts
index 0ab23b643c8..6243222c3c0 100644
---
a/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/transformPropsUtil.ts
+++
b/superset-frontend/plugins/plugin-chart-cartodiagram/src/util/transformPropsUtil.ts
@@ -18,9 +18,12 @@
*/
import {
+ CategoricalColorNamespace,
ChartProps,
convertKeysToCamelCase,
DataRecord,
+ getLabelsColorMap,
+ LabelsColorMapSource,
} from '@superset-ui/core';
import { isObject } from 'lodash-es';
import {
@@ -273,6 +276,11 @@ export const getChartConfigs = (
formData: chartFormData,
rawFormData: chartFormDataSnake,
datasource: {},
+ // Dashboard colors are coordinated by the shared labels color map.
+ colorScale:
+ getLabelsColorMap().source === LabelsColorMapSource.Explore
+ ? CategoricalColorNamespace.getScale(chartFormData.colorScheme)
+ : undefined,
};
const { queriesData } = chartProps;
diff --git
a/superset-frontend/plugins/plugin-chart-cartodiagram/test/util/pieColors.test.ts
b/superset-frontend/plugins/plugin-chart-cartodiagram/test/util/pieColors.test.ts
new file mode 100644
index 00000000000..dde29d73eff
--- /dev/null
+++
b/superset-frontend/plugins/plugin-chart-cartodiagram/test/util/pieColors.test.ts
@@ -0,0 +1,203 @@
+/**
+ * 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 {
+ CategoricalColorNamespace,
+ CategoricalScheme,
+ ChartProps,
+ DataRecord,
+ getCategoricalSchemeRegistry,
+ getLabelsColorMap,
+ LabelsColorMapSource,
+ SqlaFormData,
+} from '@superset-ui/core';
+import { supersetTheme } from '@apache-superset/core/theme';
+import pieTransform from
'../../../plugin-chart-echarts/src/Pie/transformProps';
+import { EchartsPieChartProps } from
'../../../plugin-chart-echarts/src/Pie/types';
+import { getChartConfigs } from '../../src/util/transformPropsUtil';
+
+const palette = ['#112233', '#445566', '#778899', '#aabbcc'];
+const alternatePalette = ['#110000', '#220000', '#330000', '#440000'];
+const locations = [
+ JSON.stringify({ type: 'Point', coordinates: [10, 51] }),
+ JSON.stringify({ type: 'Point', coordinates: [9, 50] }),
+];
+const data = [
+ { geom: locations[0], cat: 'bar', total: 1 },
+ { geom: locations[0], cat: 'baz', total: 1 },
+ { geom: locations[1], cat: 'far', total: 1 },
+ { geom: locations[1], cat: 'faz', total: 1 },
+];
+const params: SqlaFormData = {
+ viz_type: 'pie',
+ datasource: '1__table',
+ slice_id: 1,
+ groupby: ['cat'],
+ metric: 'total',
+ color_scheme: 'cartodiagram-test',
+};
+const labelsColorMap = getLabelsColorMap();
+const previousSource = labelsColorMap.source;
+
+beforeAll(() => {
+ getCategoricalSchemeRegistry()
+ .registerValue(
+ 'cartodiagram-test',
+ new CategoricalScheme({ id: 'cartodiagram-test', colors: palette }),
+ )
+ .registerValue(
+ 'cartodiagram-alternate',
+ new CategoricalScheme({
+ id: 'cartodiagram-alternate',
+ colors: alternatePalette,
+ }),
+ );
+});
+beforeEach(() => {
+ labelsColorMap.reset();
+ labelsColorMap.source = LabelsColorMapSource.Explore;
+});
+afterEach(() => {
+ labelsColorMap.reset();
+ labelsColorMap.source = previousSource;
+ CategoricalColorNamespace.getNamespace().resetColors();
+});
+afterAll(() => {
+ getCategoricalSchemeRegistry()
+ .remove('cartodiagram-test')
+ .remove('cartodiagram-alternate');
+});
+
+/** Transform actual Pie charts through the Cartodiagram grouping boundary. */
+function transform(
+ rows: DataRecord[] = data,
+ colorScheme = params.color_scheme,
+) {
+ const formData = { ...params, color_scheme: colorScheme };
+ const props = new ChartProps({
+ theme: supersetTheme,
+ queriesData: [
+ {
+ colnames: ['geom', 'cat', 'total'],
+ coltypes: [1, 1, 0],
+ data: rows,
+ },
+ ],
+ });
+ return getChartConfigs(
+ { viz_type: 'pie', params: formData },
+ 'geom',
+ props,
+ (child: EchartsPieChartProps) => pieTransform(child),
+ ).features.map(feature => {
+ const options = feature.properties.echartOptions as {
+ series: { data: { name: string; itemStyle: { color: string } }[] }[];
+ };
+ return options.series[0].data.map(item => ({
+ name: item.name,
+ color: item.itemStyle.color,
+ }));
+ });
+}
+
+test('assigns distinct colors to different categories across locations in
Explore', () => {
+ const groups = transform();
+ expect(groups.map(group => group.map(item => item.name))).toEqual([
+ ['bar', 'baz'],
+ ['far', 'faz'],
+ ]);
+ expect(groups.flat().map(item => item.color)).toEqual(palette);
+});
+
+test('keeps repeated categories consistent when their order differs between
locations', () => {
+ const groups = transform([
+ ...data.slice(0, 2),
+ { geom: locations[1], cat: 'baz', total: 1 },
+ { geom: locations[1], cat: 'bar', total: 1 },
+ ]);
+ expect(groups[1]).toEqual([groups[0][1], groups[0][0]]);
+});
+
+test('starts a fresh scale for a separate Cartodiagram transformation', () => {
+ transform();
+ expect(transform(data.slice(2))[0].map(item => item.color)).toEqual(
+ palette.slice(0, 2),
+ );
+});
+
+test('uses the selected Pie palette after it changes', () => {
+ transform();
+ expect(
+ transform(data, 'cartodiagram-alternate')
+ .flat()
+ .map(item => item.color),
+ ).toEqual(alternatePalette);
+});
+
+test('preserves explicit label colors', () => {
+ CategoricalColorNamespace.getNamespace().setColor('far', '#abcdef');
+ expect(transform()[1][0]).toEqual({ name: 'far', color: '#abcdef' });
+});
+
+test('preserves existing Dashboard label colors', () => {
+ labelsColorMap.source = LabelsColorMapSource.Dashboard;
+ labelsColorMap.colorMap.set('bar', '#abcdef');
+ labelsColorMap.colorMap.set('far', '#fedcba');
+ const groups = transform();
+ expect(groups[0][0]).toEqual({ name: 'bar', color: '#abcdef' });
+ expect(groups[1][0]).toEqual({ name: 'far', color: '#fedcba' });
+});
+
+test('returns no charts for an empty query result', () => {
+ expect(transform([])).toEqual([]);
+});
+
+test('keeps standalone Pie charts on their own selected palette', () => {
+ transform();
+ const props = new ChartProps({
+ theme: supersetTheme,
+ formData: params,
+ queriesData: [{ data: data.slice(2) }],
+ });
+ const options = pieTransform(props as EchartsPieChartProps).echartOptions as
{
+ series: { data: { itemStyle: { color: string } }[] }[];
+ };
+ expect(options.series[0].data.map(item => item.itemStyle.color)).toEqual(
+ palette.slice(0, 2),
+ );
+});
+
+test('retains existing Dashboard colors when different labels already share a
color', () => {
+ labelsColorMap.source = LabelsColorMapSource.Dashboard;
+ labelsColorMap.colorMap.set('bar', palette[0]);
+ labelsColorMap.colorMap.set('far', palette[0]);
+ const thirdLocation = JSON.stringify({ type: 'Point', coordinates: [8, 49]
});
+ const groups = transform([
+ data[0],
+ data[2],
+ { geom: thirdLocation, cat: 'bar', total: 1 },
+ ]);
+ expect(groups.flat().map(item => item.color)).toEqual([
+ palette[0],
+ palette[0],
+ palette[0],
+ ]);
+ expect(labelsColorMap.colorMap.get('bar')).toBe(palette[0]);
+ expect(labelsColorMap.colorMap.get('far')).toBe(palette[0]);
+});
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
index e7429ff42a6..89f8fc2398c 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
@@ -376,7 +376,9 @@ export default function transformProps(
onLegendStateChanged,
onLegendScroll,
} = hooks;
- const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
+ const colorFn =
+ chartProps.colorScale ??
+ CategoricalColorNamespace.getScale(colorScheme as string);
let totalValue = 0;