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;
 

Reply via email to