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;
 

Reply via email to