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 9315430e4ad fix(deck.gl): scatterplot stops rendering points with
empty value of categorical color (#45585)
9315430e4ad is described below
commit 9315430e4ad546287625157fb2c65c578494596e
Author: Evan Rusackas <[email protected]>
AuthorDate: Mon Oct 26 16:37:19 2026 +0000
fix(deck.gl): scatterplot stops rendering points with empty value of
categorical color (#45585)
Co-authored-by: Damian Pendrak <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../src/CategoricalDeckGLContainer.test.tsx | 119 +++++++++++++-
.../src/components/Legend.test.tsx | 14 ++
.../preset-chart-deckgl/src/components/Legend.tsx | 5 +
.../src/layers/Arc/transformProps.test.ts | 183 +++++++++++++++++++++
.../src/layers/Arc/transformProps.ts | 14 +-
.../src/layers/Path/transformProps.test.ts | 52 ++++++
.../src/layers/Path/transformProps.ts | 14 +-
.../src/layers/Scatter/Scatter.test.tsx | 54 ++++++
.../src/layers/Scatter/Scatter.tsx | 4 +-
.../src/layers/Scatter/transformProps.test.ts | 73 ++++++++
.../src/layers/Scatter/transformProps.ts | 14 +-
.../src/utilities/tooltipUtils.test.tsx | 67 +++++++-
.../src/utilities/tooltipUtils.tsx | 14 +-
.../plugins/preset-chart-deckgl/src/utils.ts | 8 +
14 files changed, 615 insertions(+), 20 deletions(-)
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/CategoricalDeckGLContainer.test.tsx
b/superset-frontend/plugins/preset-chart-deckgl/src/CategoricalDeckGLContainer.test.tsx
index 31e34451754..bbb8a4d73b8 100644
---
a/superset-frontend/plugins/preset-chart-deckgl/src/CategoricalDeckGLContainer.test.tsx
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/CategoricalDeckGLContainer.test.tsx
@@ -16,9 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { ThemeProvider, supersetTheme } from '@apache-superset/core/theme';
-import { JsonObject, QueryFormData } from '@superset-ui/core';
+import {
+ ChartProps,
+ DatasourceType,
+ JsonObject,
+ QueryFormData,
+} from '@superset-ui/core';
import CategoricalDeckGLContainer, {
CategoricalDeckGLContainerProps,
getCategories,
@@ -26,6 +32,8 @@ import CategoricalDeckGLContainer, {
} from './CategoricalDeckGLContainer';
import { addColorToFeatures } from './utils/addColor';
import { COLOR_SCHEME_TYPES } from './utilities/utils';
+import { NULL_CATEGORY_KEY } from './utils';
+import transformScatterProps from './layers/Scatter/transformProps';
// maplibre-gl 6 is ESM-only, so jest can't resolve it; DeckGLContainer.tsx
// calls setWorkerUrl() on it at import time.
@@ -275,3 +283,110 @@ test('with autozoom a tilt written back by Explore keeps
the viewer on the same
);
expect(mockViewports.at(-1)).toEqual(fitted);
});
+
+test('rows with a null dimension get their own legend category', () => {
+ const fd = {
+ datasource: '1__table',
+ viz_type: 'deck_scatter',
+ color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
+ color_scheme: 'supersetColors',
+ dimension: 'category',
+ slice_id: 42,
+ color_picker: { r: 0, g: 0, b: 0, a: 1 },
+ } as unknown as QueryFormData;
+ const data = [{ cat_color: 'A' }, { cat_color: NULL_CATEGORY_KEY }];
+
+ const categories = getCategories(fd, data);
+
+ expect(categories[NULL_CATEGORY_KEY]?.enabled).toBe(true);
+ expect(categories[NULL_CATEGORY_KEY]?.color).toBeDefined();
+
+ // Without a category entry the container drops the feature entirely, which
is
+ // how null-dimension rows used to disappear from the map.
+ expect(data.filter(d => categories[d.cat_color]?.enabled)).toHaveLength(2);
+});
+
+test('null-dimension rows reach the layer with the N/A color and toggle with
the legend', async () => {
+ const fd = {
+ datasource: '1__table',
+ viz_type: 'deck_scatter',
+ color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
+ color_scheme: 'supersetColors',
+ dimension: 'category',
+ slice_id: 42,
+ color_picker: { r: 0, g: 0, b: 0, a: 1 },
+ } as unknown as QueryFormData;
+ // Real Scatter transformProps output, so the null-to-category handoff
between
+ // the transform and the container is exercised rather than a hand-built key.
+ const scatterPayload = transformScatterProps({
+ rawFormData: {
+ ...fd,
+ spatial: { type: 'latlong', latCol: 'LATITUDE', lonCol: 'LONGITUDE' },
+ point_radius_fixed: { type: 'fix', value: '1000' },
+ viewport: {},
+ },
+ queriesData: [
+ {
+ data: [
+ { LATITUDE: 37.8, LONGITUDE: -122.4, category: 'A' },
+ { LATITUDE: 37.9, LONGITUDE: -122.3, category: null },
+ ],
+ },
+ ],
+ datasource: {
+ type: DatasourceType.Table,
+ id: 1,
+ name: 'test_datasource',
+ columns: [],
+ metrics: [],
+ },
+ height: 300,
+ width: 400,
+ hooks: {},
+ filterState: {},
+ emitCrossFilters: false,
+ } as unknown as ChartProps).payload;
+ const getLayer = jest.fn(() => ({}));
+ const drawnCategories = () => {
+ const { payload } = (
+ getLayer.mock.calls.at(-1) as unknown as [JsonObject]
+ )[0];
+ return payload.data.features.map((f: JsonObject) => f.cat_color);
+ };
+
+ render(
+ <ThemeProvider theme={supersetTheme}>
+ <CategoricalDeckGLContainer
+ formData={fd}
+ getLayer={getLayer}
+ getPoints={() => []}
+ viewport={{ longitude: 0, latitude: 0, zoom: 1 }}
+ width={400}
+ height={300}
+ setControlValue={jest.fn()}
+ datasource={{} as CategoricalDeckGLContainerProps['datasource']}
+ filterState={{}}
+ setDataMask={jest.fn()}
+ onContextMenu={jest.fn()}
+ emitCrossFilters={false}
+ payload={scatterPayload as CategoricalDeckGLContainerProps['payload']}
+ />
+ </ThemeProvider>,
+ );
+
+ const { payload } = (
+ getLayer.mock.calls.at(-1) as unknown as [JsonObject]
+ )[0];
+ const nullFeature = payload.data.features.find(
+ (f: JsonObject) => f.cat_color === NULL_CATEGORY_KEY,
+ );
+ expect(drawnCategories()).toEqual(['A', NULL_CATEGORY_KEY]);
+ const aFeature = payload.data.features.find(
+ (f: JsonObject) => f.cat_color === 'A',
+ );
+ expect(nullFeature.color).toHaveLength(4);
+ expect(nullFeature.color).not.toEqual(aFeature.color);
+
+ await userEvent.click(screen.getByText('N/A'));
+ expect(drawnCategories()).toEqual(['A']);
+});
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.test.tsx
b/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.test.tsx
index 4d36b66efcd..f65322cb431 100644
---
a/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.test.tsx
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.test.tsx
@@ -22,7 +22,9 @@ import { createEvent, fireEvent, render, screen } from
'@testing-library/react';
import '@testing-library/jest-dom';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import type { ReactElement } from 'react';
+import { Constants } from '@superset-ui/core/components';
import Legend, { type LegendProps } from './Legend';
+import { NULL_CATEGORY_KEY } from '../utils';
const renderWithTheme = (component: ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
@@ -73,6 +75,18 @@ test('leaves labels untouched when no format is provided',
() => {
expect(screen.getByText('[1, 81)')).toBeInTheDocument();
});
+test('shows the null category as N/A instead of its internal key', () => {
+ renderWithTheme(
+ <Legend
+ format=",.2f"
+ categories={{ [NULL_CATEGORY_KEY]: { enabled: true, color: [0, 0, 0] } }}
+ />,
+ );
+
+ expect(screen.getByText(Constants.NULL_DISPLAY)).toBeInTheDocument();
+ expect(screen.queryByText(NULL_CATEGORY_KEY)).not.toBeInTheDocument();
+});
+
test('clicking a legend item toggles the category without triggering anchor
navigation', () => {
// Regression proof for #33576: legend items render as real <button>
// elements (not href="#" anchors), so a click has no default navigation
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.tsx
b/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.tsx
index 21056c6a166..bb6634201de 100644
--- a/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.tsx
+++ b/superset-frontend/plugins/preset-chart-deckgl/src/components/Legend.tsx
@@ -22,6 +22,7 @@ import { memo } from 'react';
import { formatNumber } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { Color } from '@deck.gl/core';
+import { NULL_CATEGORY_KEY, formatNullCategory } from '../utils';
const StyledLegend = styled.div`
${({ theme }) => `
@@ -120,6 +121,10 @@ const Legend = ({
};
const formatCategoryLabel = (k: string) => {
+ if (k === NULL_CATEGORY_KEY) {
+ return formatNullCategory(k);
+ }
+
if (!d3Format) {
return k;
}
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Arc/transformProps.test.ts
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Arc/transformProps.test.ts
new file mode 100644
index 00000000000..01c3532c4f9
--- /dev/null
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Arc/transformProps.test.ts
@@ -0,0 +1,183 @@
+/**
+ * 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 { ChartProps, DatasourceType } from '@superset-ui/core';
+import transformProps from './transformProps';
+import { NULL_CATEGORY_KEY } from '../../utils';
+
+interface ArcFeature {
+ sourcePosition: [number, number];
+ targetPosition: [number, number];
+ cat_color?: string;
+ extraProps?: Record<string, unknown>;
+}
+
+jest.mock('../spatialUtils', () => ({
+ ...jest.requireActual('../spatialUtils'),
+ getMapboxApiKey: jest.fn(() => 'mock-mapbox-key'),
+}));
+
+const mockChartProps: Partial<ChartProps> = {
+ rawFormData: {
+ start_spatial: {
+ type: 'latlong',
+ latCol: 'START_LAT',
+ lonCol: 'START_LON',
+ },
+ end_spatial: {
+ type: 'latlong',
+ latCol: 'END_LAT',
+ lonCol: 'END_LON',
+ },
+ viewport: {},
+ },
+ queriesData: [
+ {
+ data: [
+ {
+ START_LAT: 37.8,
+ START_LON: -122.4,
+ END_LAT: 37.9,
+ END_LON: -122.3,
+ category: 'A',
+ },
+ {
+ START_LAT: 38.0,
+ START_LON: -122.2,
+ END_LAT: 38.1,
+ END_LON: -122.1,
+ category: 'B',
+ },
+ ],
+ },
+ ],
+ datasource: {
+ type: DatasourceType.Table,
+ id: 1,
+ name: 'test_datasource',
+ columns: [],
+ metrics: [],
+ },
+ height: 400,
+ width: 600,
+ hooks: {},
+ filterState: {},
+ emitCrossFilters: false,
+};
+
+test('Arc transformProps should set cat_color from dimension column', () => {
+ const props = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ dimension: 'category',
+ },
+ };
+
+ const result = transformProps(props as ChartProps);
+ const features = result.payload.data.features as ArcFeature[];
+
+ expect(features).toHaveLength(2);
+ expect(features[0]?.cat_color).toBe('A');
+ expect(features[1]?.cat_color).toBe('B');
+});
+
+test('Arc transformProps should fall back to NULL_CATEGORY_KEY for empty
dimension values', () => {
+ const props = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ dimension: 'category',
+ },
+ queriesData: [
+ {
+ data: [
+ {
+ START_LAT: 37.8,
+ START_LON: -122.4,
+ END_LAT: 37.9,
+ END_LON: -122.3,
+ category: 'A',
+ },
+ {
+ START_LAT: 38.0,
+ START_LON: -122.2,
+ END_LAT: 38.1,
+ END_LON: -122.1,
+ category: null,
+ },
+ {
+ START_LAT: 38.2,
+ START_LON: -122.0,
+ END_LAT: 38.3,
+ END_LON: -121.9,
+ category: undefined,
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProps(props as ChartProps);
+ const features = result.payload.data.features as ArcFeature[];
+
+ expect(features).toHaveLength(3);
+ expect(features[0]?.cat_color).toBe('A');
+ expect(features[1]?.cat_color).toBe(NULL_CATEGORY_KEY);
+ expect(features[2]?.cat_color).toBe(NULL_CATEGORY_KEY);
+});
+
+test('Arc transformProps should not set cat_color when no dimension is
configured', () => {
+ const result = transformProps(mockChartProps as ChartProps);
+ const features = result.payload.data.features as ArcFeature[];
+
+ expect(features).toHaveLength(2);
+ expect(features[0]?.cat_color).toBeUndefined();
+ expect(features[1]?.cat_color).toBeUndefined();
+});
+
+test('Arc transformProps should not let a source column named cat_color
override the dimension fallback', () => {
+ const props = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ dimension: 'category',
+ },
+ queriesData: [
+ {
+ data: [
+ {
+ START_LAT: 37.8,
+ START_LON: -122.4,
+ END_LAT: 37.9,
+ END_LON: -122.3,
+ category: null,
+ cat_color: 'unrelated',
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProps(props as ChartProps);
+ const features = result.payload.data.features as ArcFeature[];
+
+ expect(features).toHaveLength(1);
+ expect(features[0]?.cat_color).toBe(NULL_CATEGORY_KEY);
+});
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Arc/transformProps.ts
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Arc/transformProps.ts
index 44e369d44e0..883198a99db 100644
---
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Arc/transformProps.ts
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Arc/transformProps.ts
@@ -24,6 +24,7 @@ import {
addPropertiesToFeature,
} from '../transformUtils';
import { DeckArcFormData } from './buildQuery';
+import { NULL_CATEGORY_KEY } from '../../utils';
interface ArcPoint {
sourcePosition: [number, number];
@@ -65,10 +66,6 @@ function processArcData(
extraProps: {},
};
- if (dimension && record[dimension] != null) {
- arcPoint.cat_color = String(record[dimension]);
- }
-
// eslint-disable-next-line no-underscore-dangle
if (record.__timestamp != null) {
// eslint-disable-next-line no-underscore-dangle
@@ -76,6 +73,15 @@ function processArcData(
}
arcPoint = addPropertiesToFeature(arcPoint, record, excludeKeys);
+
+ // Assigned after the property copy so a column named `cat_color` cannot
+ // overwrite the selected dimension's category.
+ if (dimension) {
+ arcPoint.cat_color =
+ record[dimension] != null
+ ? String(record[dimension])
+ : NULL_CATEGORY_KEY;
+ }
return arcPoint;
})
.filter((point): point is ArcPoint => point !== null);
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Path/transformProps.test.ts
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Path/transformProps.test.ts
index c8ba39d0e94..9712cc10b8f 100644
---
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Path/transformProps.test.ts
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Path/transformProps.test.ts
@@ -18,6 +18,7 @@
*/
import { ChartProps, DatasourceType } from '@superset-ui/core';
import transformProps from './transformProps';
+import { NULL_CATEGORY_KEY } from '../../utils';
interface PathFeature {
path: [number, number][];
@@ -320,6 +321,35 @@ test('Path transformProps should set cat_color from
dimension column', () => {
expect(features[2]?.cat_color).toBe('express');
});
+test('Path transformProps should fall back to NULL_CATEGORY_KEY for empty
dimension values', () => {
+ const props = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ dimension: 'route_type',
+ },
+ queriesData: [
+ {
+ data: [
+ { path_json: samplePath1, route_type: 'express' },
+ { path_json: samplePath2, route_type: null },
+ { path_json: samplePath3, route_type: undefined },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProps(props as ChartProps);
+ const features = result.payload.data.features as PathFeature[];
+
+ // Path emits features in reverse record order.
+ expect(features.map(f => f.cat_color)).toEqual([
+ NULL_CATEGORY_KEY,
+ NULL_CATEGORY_KEY,
+ 'express',
+ ]);
+});
+
test('Path transformProps should include metric labels when breakpoint_metric
is set', () => {
const props = {
...mockChartProps,
@@ -362,3 +392,25 @@ test('Path transformProps should have empty metric labels
when no metric is set'
expect(result.payload.data.metricLabels).toEqual([]);
});
+
+test('Path transformProps should not let a source column named cat_color
override the dimension fallback', () => {
+ const props = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ dimension: 'route_type',
+ },
+ queriesData: [
+ {
+ data: [
+ { path_json: samplePath1, route_type: null, cat_color: 'unrelated' },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProps(props as ChartProps);
+ const features = result.payload.data.features as PathFeature[];
+
+ expect(features.map(f => f.cat_color)).toEqual([NULL_CATEGORY_KEY]);
+});
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Path/transformProps.ts
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Path/transformProps.ts
index ae098956ca3..2d10923f6eb 100644
---
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Path/transformProps.ts
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Path/transformProps.ts
@@ -27,6 +27,7 @@ import {
} from '../transformUtils';
import { DeckPathFormData } from './buildQuery';
import { isFixedValue, getFixedValue } from '../utils/metricUtils';
+import { NULL_CATEGORY_KEY } from '../../utils';
declare global {
interface Window {
@@ -145,11 +146,16 @@ function processPathData(
}
}
- if (categoryColumn && record[categoryColumn] != null) {
- feature.cat_color = String(record[categoryColumn]);
- }
-
feature = addPropertiesToFeature(feature, record, excludeKeys);
+
+ // Assigned after the property copy so a column named `cat_color` cannot
+ // overwrite the selected dimension's category.
+ if (categoryColumn) {
+ feature.cat_color =
+ record[categoryColumn] != null
+ ? String(record[categoryColumn])
+ : NULL_CATEGORY_KEY;
+ }
return feature;
});
}
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/Scatter.test.tsx
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/Scatter.test.tsx
new file mode 100644
index 00000000000..5a2d551c40b
--- /dev/null
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/Scatter.test.tsx
@@ -0,0 +1,54 @@
+/**
+ * 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 { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { getLayer } from './Scatter';
+
+// maplibre-gl 6 is ESM-only, so jest can't resolve it; DeckGLContainer.tsx
+// (pulled in via factory.tsx) calls setWorkerUrl() on it at import time.
+jest.mock('maplibre-gl', () => ({ setWorkerUrl: jest.fn() }), {
+ virtual: true,
+});
+
+test('default tooltip shows N/A, not <NULL>, for the null category sentinel',
() => {
+ const setTooltip = jest.fn();
+ const layer = getLayer({
+ formData: { slice_id: 1 },
+ payload: { data: { features: [] } },
+ setTooltip,
+ setDataMask: () => {},
+ onContextMenu: () => {},
+ filterState: {},
+ datasource: {},
+ onSelect: () => {},
+ emitCrossFilters: false,
+ } as any);
+
+ (layer.props as any).onHover({
+ picked: true,
+ x: 1,
+ y: 2,
+ object: { position: [1, 2], cat_color: '<NULL>' },
+ });
+
+ const { content } = setTooltip.mock.calls[0][0];
+ render(content);
+ expect(screen.getByText('N/A')).toBeInTheDocument();
+ expect(screen.queryByText('<NULL>')).not.toBeInTheDocument();
+});
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/Scatter.tsx
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/Scatter.tsx
index 5209493a3b0..2eb7f46b2f1 100644
---
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/Scatter.tsx
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/Scatter.tsx
@@ -25,7 +25,7 @@ import { createCategoricalDeckGLComponent, GetLayerType }
from '../../factory';
import { createTooltipContent } from '../../utilities/tooltipUtils';
import TooltipRow from '../../TooltipRow';
import { unitToRadius } from '../../utils/geo';
-import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
+import { HIGHLIGHT_COLOR_ARRAY, formatNullCategory } from '../../utils';
import { isMetricValue, extractMetricKey } from '../utils/metricUtils';
function getMetricLabel(metric: any) {
@@ -67,7 +67,7 @@ function setTooltipContent(
{o.object?.cat_color && (
<TooltipRow
label={`${t('Category')}: `}
- value={`${o.object?.cat_color}`}
+ value={formatNullCategory(`${o.object?.cat_color}`)}
/>
)}
{o.object?.metric && label && (
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/transformProps.test.ts
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/transformProps.test.ts
index 9728425c937..a70b35fd8f6 100644
---
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/transformProps.test.ts
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/transformProps.test.ts
@@ -22,6 +22,7 @@ import { logging } from '@apache-superset/core/utils';
import transformProps, {
filterDrawableGeographicPoints,
} from './transformProps';
+import { NULL_CATEGORY_KEY } from '../../utils';
interface ScatterFeature {
position: [number, number];
@@ -197,6 +198,49 @@ test('Scatter transformProps should handle dimension for
category colors', () =>
expect(features[1]?.cat_color).toBe('B');
});
+test('Scatter transformProps should fall back to NULL_CATEGORY_KEY for empty
dimension values', () => {
+ const propsWithNullCategory = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ dimension: 'category',
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ },
+ queriesData: [
+ {
+ data: [
+ {
+ LATITUDE: 37.8,
+ LONGITUDE: -122.4,
+ category: 'A',
+ },
+ {
+ LATITUDE: 37.9,
+ LONGITUDE: -122.3,
+ category: null,
+ },
+ {
+ LATITUDE: 38.0,
+ LONGITUDE: -122.2,
+ category: undefined,
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProps(propsWithNullCategory as ChartProps);
+ const features = result.payload.data.features as ScatterFeature[];
+
+ expect(features).toHaveLength(3);
+ expect(features[0]?.cat_color).toBe('A');
+ expect(features[1]?.cat_color).toBe(NULL_CATEGORY_KEY);
+ expect(features[2]?.cat_color).toBe(NULL_CATEGORY_KEY);
+});
+
test('Scatter transformProps should not include metric labels for fixed
radius', () => {
const fixedProps = {
...mockChartProps,
@@ -623,3 +667,32 @@ test.each([undefined, false])(
expect(result.payload.data.metricLabels).toEqual(['100']);
},
);
+
+test('Scatter transformProps should not let a source column named cat_color
override the dimension fallback', () => {
+ const props = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ dimension: 'category',
+ point_radius_fixed: { type: 'fix', value: '1000' },
+ },
+ queriesData: [
+ {
+ data: [
+ {
+ LATITUDE: 37.8,
+ LONGITUDE: -122.4,
+ category: null,
+ cat_color: 'unrelated',
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProps(props as ChartProps);
+ const features = result.payload.data.features as ScatterFeature[];
+
+ expect(features).toHaveLength(1);
+ expect(features[0]?.cat_color).toBe(NULL_CATEGORY_KEY);
+});
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/transformProps.ts
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/transformProps.ts
index 6f2f2612d0a..0c496a74d3b 100644
---
a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/transformProps.ts
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Scatter/transformProps.ts
@@ -32,6 +32,7 @@ import {
getFixedValue,
getTypedFixedRadius,
} from '../utils/metricUtils';
+import { NULL_CATEGORY_KEY } from '../../utils';
interface ScatterPoint {
position: [number, number];
@@ -155,16 +156,19 @@ function processScatterData(
}
}
- const categoryValue = getSourceValue(categoryColumn);
- if (categoryValue != null) {
- scatterPoint.cat_color = String(categoryValue);
- }
-
scatterPoint = addPropertiesToFeature(
scatterPoint,
feature as DataRecord,
excludeKeys,
);
+
+ // Assigned after the property copy so a column named `cat_color` cannot
+ // overwrite the selected dimension's category.
+ if (categoryColumn) {
+ const categoryValue = getSourceValue(categoryColumn);
+ scatterPoint.cat_color =
+ categoryValue != null ? String(categoryValue) : NULL_CATEGORY_KEY;
+ }
return scatterPoint;
});
}
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/utilities/tooltipUtils.test.tsx
b/superset-frontend/plugins/preset-chart-deckgl/src/utilities/tooltipUtils.test.tsx
index 7b528a03b27..9b4fc40ff42 100644
---
a/superset-frontend/plugins/preset-chart-deckgl/src/utilities/tooltipUtils.test.tsx
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/utilities/tooltipUtils.test.tsx
@@ -18,8 +18,25 @@
*/
import { JsonObject, QueryFormData } from '@superset-ui/core';
+import { Constants } from '@superset-ui/core/components';
import { render } from '@testing-library/react';
-import { createTooltipContent } from './tooltipUtils';
+import {
+ createTooltipContent,
+ createHandlebarsTooltipData,
+ CommonTooltipRows,
+} from './tooltipUtils';
+import { NULL_CATEGORY_KEY } from '../utils';
+
+test('category row shows the null category as N/A, matching the legend', () =>
{
+ const { container } = render(
+ <>
+ {CommonTooltipRows.category({ object: { cat_color: NULL_CATEGORY_KEY }
})}
+ </>,
+ );
+
+ expect(container.textContent).toContain(Constants.NULL_DISPLAY);
+ expect(container.textContent).not.toContain(NULL_CATEGORY_KEY);
+});
test('buildFieldBasedTooltipItems does not append configured metric when
tooltip_contents already has string metric', () => {
const formData = {
@@ -88,3 +105,51 @@ test('buildFieldBasedTooltipItems preserves zero configured
metric fallback valu
expect(container.textContent).toContain('name: North');
expect(container.textContent).toContain('sum__value: 0');
});
+
+test.each([[undefined], [['cat_color']]])(
+ 'handlebars data shows the null category as N/A (tooltip_contents=%j)',
+ tooltipContents => {
+ const formData = {
+ datasource: '1__table',
+ viz_type: 'deck_scatter',
+ tooltip_contents: tooltipContents,
+ } as QueryFormData;
+
+ const data = createHandlebarsTooltipData(
+ { object: { cat_color: NULL_CATEGORY_KEY } },
+ formData,
+ );
+
+ expect(data.cat_color).toBe(Constants.NULL_DISPLAY);
+ },
+);
+
+test('field-based tooltip shows the null category as N/A', () => {
+ const formData = {
+ datasource: '1__table',
+ viz_type: 'deck_scatter',
+ tooltip_contents: ['cat_color'],
+ } as QueryFormData;
+
+ const content = createTooltipContent(formData, () => <div>default</div>);
+ const { container } = render(
+ content({ object: { cat_color: NULL_CATEGORY_KEY } }),
+ );
+
+ expect(container.textContent).toContain(Constants.NULL_DISPLAY);
+ expect(container.textContent).not.toContain(NULL_CATEGORY_KEY);
+});
+
+test('handlebars data leaves a non-sentinel cat_color value untouched', () => {
+ const formData = {
+ datasource: '1__table',
+ viz_type: 'deck_scatter',
+ } as QueryFormData;
+
+ const data = createHandlebarsTooltipData(
+ { object: { cat_color: ['A', 'B'] } },
+ formData,
+ );
+
+ expect(data.cat_color).toEqual(['A', 'B']);
+});
diff --git
a/superset-frontend/plugins/preset-chart-deckgl/src/utilities/tooltipUtils.tsx
b/superset-frontend/plugins/preset-chart-deckgl/src/utilities/tooltipUtils.tsx
index b1c4a1442ef..103f71583c4 100644
---
a/superset-frontend/plugins/preset-chart-deckgl/src/utilities/tooltipUtils.tsx
+++
b/superset-frontend/plugins/preset-chart-deckgl/src/utilities/tooltipUtils.tsx
@@ -23,6 +23,7 @@ import { useMemo, memo } from 'react';
import { HandlebarsRenderer } from './HandlebarsRenderer';
import TooltipRow from '../TooltipRow';
import { createDefaultTemplateWithLimits } from './multiValueUtils';
+import { NULL_CATEGORY_KEY, formatNullCategory } from '../utils';
const MemoizedHandlebarsRenderer = memo(HandlebarsRenderer);
@@ -58,7 +59,7 @@ export const CommonTooltipRows = {
o.object?.cat_color ? (
<TooltipRow
label={`${t('Category')}: `}
- value={`${o.object.cat_color}`}
+ value={formatNullCategory(`${o.object.cat_color}`)}
/>
) : null,
@@ -165,7 +166,10 @@ function buildFieldBasedTooltipItems(
}
if (value !== '') {
- const formattedValue = formatValue(value);
+ const formattedValue =
+ value === NULL_CATEGORY_KEY
+ ? formatNullCategory(value)
+ : formatValue(value);
tooltipItems.push(
<TooltipRow
key={`tooltip-${index}`}
@@ -348,6 +352,12 @@ export function createHandlebarsTooltipData(
data = { ...data, ...tooltipData };
}
+ // Applied after the tooltip-contents merge so a configured `cat_color` item
+ // cannot reintroduce the raw null sentinel.
+ if (data.cat_color === NULL_CATEGORY_KEY) {
+ data = { ...data, cat_color: formatNullCategory(data.cat_color) };
+ }
+
return data;
}
diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/utils.ts
b/superset-frontend/plugins/preset-chart-deckgl/src/utils.ts
index b33dad7517e..8ed2b2050df 100644
--- a/superset-frontend/plugins/preset-chart-deckgl/src/utils.ts
+++ b/superset-frontend/plugins/preset-chart-deckgl/src/utils.ts
@@ -25,12 +25,20 @@ import {
QueryFormData,
SequentialScheme,
} from '@superset-ui/core';
+import { Constants } from '@superset-ui/core/components';
import { Color } from '@deck.gl/core';
import { hexToRGB } from './utils/colors';
import { ColorBreakpointType } from './types';
export const TRANSPARENT_COLOR_ARRAY = [0, 0, 0, 0] as Color;
export const HIGHLIGHT_COLOR_ARRAY = [255, 0, 0, 255] as Color;
+// Stands in for a null dimension value so those rows still get a category, a
+// color and a legend entry. Kept distinct from NULL_DISPLAY so the key stays
+// stable across locales.
+export const NULL_CATEGORY_KEY = '<NULL>';
+
+export const formatNullCategory = (value: string) =>
+ value === NULL_CATEGORY_KEY ? Constants.NULL_DISPLAY : value;
const DEFAULT_NUM_BUCKETS = 10;