This is an automated email from the ASF dual-hosted git repository.
msyavuz 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 c76ddcbbec fix(deck.gl): Fix Scatterplot chart error when using fixed
point size (#36890)
c76ddcbbec is described below
commit c76ddcbbecc68d457ee339c30259857dfeaf8696
Author: Mehmet Salih Yavuz <[email protected]>
AuthorDate: Mon Jan 5 15:55:43 2026 +0300
fix(deck.gl): Fix Scatterplot chart error when using fixed point size
(#36890)
Co-authored-by: Claude <[email protected]>
---
.../src/layers/Scatter/Scatter.tsx | 18 +-
.../src/layers/Scatter/buildQuery.test.ts | 312 +++++++++++++++++++++
.../src/layers/Scatter/buildQuery.ts | 26 +-
.../src/layers/Scatter/transformProps.test.ts | 303 ++++++++++++++++++++
.../src/layers/Scatter/transformProps.ts | 19 +-
.../src/layers/transformUtils.test.ts | 184 ++++++++++++
.../src/layers/transformUtils.ts | 12 +-
.../src/layers/utils/metricUtils.test.ts | 121 ++++++++
.../src/layers/utils/metricUtils.ts | 120 ++++++++
9 files changed, 1100 insertions(+), 15 deletions(-)
diff --git
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/Scatter.tsx
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/Scatter.tsx
index 3ecd3eac0c..2b72231626 100644
---
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/Scatter.tsx
+++
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/Scatter.tsx
@@ -25,6 +25,7 @@ import { createTooltipContent } from
'../../utilities/tooltipUtils';
import TooltipRow from '../../TooltipRow';
import { unitToRadius } from '../../utils/geo';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
+import { isMetricValue, extractMetricKey } from '../utils/metricUtils';
function getMetricLabel(metric: any) {
if (typeof metric === 'string') {
@@ -44,9 +45,18 @@ function setTooltipContent(
verboseMap?: Record<string, string>,
) {
const defaultTooltipGenerator = (o: JsonObject) => {
- const label =
- verboseMap?.[formData.point_radius_fixed.value] ||
- getMetricLabel(formData.point_radius_fixed?.value);
+ // Only show metric info if point_radius_fixed is metric-based
+ let metricKey = null;
+ if (isMetricValue(formData.point_radius_fixed)) {
+ metricKey = extractMetricKey(formData.point_radius_fixed?.value);
+ }
+
+ // Normalize metricKey for verboseMap lookup
+ const lookupKey = typeof metricKey === 'string' ? metricKey : null;
+ const label = lookupKey
+ ? verboseMap?.[lookupKey] || getMetricLabel(lookupKey)
+ : null;
+
return (
<div className="deckgl-tooltip">
<TooltipRow
@@ -59,7 +69,7 @@ function setTooltipContent(
value={`${o.object?.cat_color}`}
/>
)}
- {o.object?.metric && (
+ {o.object?.metric && label && (
<TooltipRow label={`${label}: `} value={`${o.object?.metric}`} />
)}
</div>
diff --git
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/buildQuery.test.ts
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/buildQuery.test.ts
new file mode 100644
index 0000000000..830d2c26b5
--- /dev/null
+++
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/buildQuery.test.ts
@@ -0,0 +1,312 @@
+/**
+ * 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 buildQuery, { DeckScatterFormData } from './buildQuery';
+
+const baseFormData: DeckScatterFormData = {
+ datasource: '1__table',
+ viz_type: 'deck_scatter',
+ spatial: {
+ type: 'latlong',
+ latCol: 'LATITUDE',
+ lonCol: 'LONGITUDE',
+ },
+ row_limit: 100,
+};
+
+test('Scatter buildQuery should not include metric when point_radius_fixed is
fixed type', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.metrics).toEqual([]);
+ expect(query.orderby).toEqual([]);
+});
+
+test('Scatter buildQuery should include metric when point_radius_fixed is
metric type', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ point_radius_fixed: {
+ type: 'metric',
+ value: 'AVG(radius_value)',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.metrics).toContain('AVG(radius_value)');
+ expect(query.orderby).toEqual([['AVG(radius_value)', false]]);
+});
+
+test('Scatter buildQuery should handle numeric value in fixed type', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ point_radius_fixed: {
+ type: 'fix',
+ value: 500,
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ // Fixed numeric value should not be included as a metric
+ expect(query.metrics).toEqual([]);
+ expect(query.orderby).toEqual([]);
+});
+
+test('Scatter buildQuery should handle missing point_radius_fixed', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ // no point_radius_fixed
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.metrics).toEqual([]);
+ expect(query.orderby).toEqual([]);
+});
+
+test('Scatter buildQuery should include spatial columns in query', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.columns).toContain('LATITUDE');
+ expect(query.columns).toContain('LONGITUDE');
+});
+
+test('Scatter buildQuery should include dimension column when specified', ()
=> {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ dimension: 'category',
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.columns).toContain('category');
+});
+
+test('Scatter buildQuery should add spatial null filters', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ const latFilter = query.filters?.find(
+ f => f.col === 'LATITUDE' && f.op === 'IS NOT NULL',
+ );
+ const lonFilter = query.filters?.find(
+ f => f.col === 'LONGITUDE' && f.op === 'IS NOT NULL',
+ );
+
+ expect(latFilter).toBeDefined();
+ expect(lonFilter).toBeDefined();
+});
+
+test('Scatter buildQuery should throw error when spatial is missing', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ spatial: undefined,
+ };
+
+ expect(() => buildQuery(formData)).toThrow(
+ 'Spatial configuration is required for Scatter charts',
+ );
+});
+
+test('Scatter buildQuery should handle geohash spatial type', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ spatial: {
+ type: 'geohash',
+ geohashCol: 'geohash_column',
+ },
+ point_radius_fixed: {
+ type: 'metric',
+ value: 'COUNT(*)',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.columns).toContain('geohash_column');
+ expect(query.metrics).toContain('COUNT(*)');
+});
+
+test('Scatter buildQuery should handle tooltip_contents', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ tooltip_contents: ['name', 'description'],
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.columns).toContain('name');
+ expect(query.columns).toContain('description');
+});
+
+test('Scatter buildQuery should handle js_columns', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ js_columns: ['custom_col1', 'custom_col2'],
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.columns).toContain('custom_col1');
+ expect(query.columns).toContain('custom_col2');
+});
+
+test('Scatter buildQuery should convert numeric metric value to string', () =>
{
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ point_radius_fixed: {
+ type: 'metric',
+ value: 123, // numeric metric (edge case)
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.metrics).toContain('123');
+ expect(query.orderby).toEqual([['123', false]]);
+});
+
+test('Scatter buildQuery should set is_timeseries to false', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.is_timeseries).toBe(false);
+});
+
+test('Scatter buildQuery should preserve row_limit', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ row_limit: 5000,
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.row_limit).toBe(5000);
+});
+
+test('Scatter buildQuery should preserve existing metrics when adding radius
metric', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ metrics: ['COUNT(*)'],
+ point_radius_fixed: {
+ type: 'metric',
+ value: 'AVG(radius_value)',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.metrics).toContain('COUNT(*)');
+ expect(query.metrics).toContain('AVG(radius_value)');
+ expect(query.metrics).toHaveLength(2);
+});
+
+test('Scatter buildQuery should not modify existing metrics for fixed radius',
() => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ metrics: ['COUNT(*)', 'SUM(value)'],
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ expect(query.metrics).toEqual(['COUNT(*)', 'SUM(value)']);
+});
+
+test('Scatter buildQuery should deduplicate metrics when radius metric already
exists', () => {
+ const formData: DeckScatterFormData = {
+ ...baseFormData,
+ metrics: ['COUNT(*)', 'AVG(price)'],
+ point_radius_fixed: {
+ type: 'metric',
+ value: 'AVG(price)',
+ },
+ };
+
+ const queryContext = buildQuery(formData);
+ const [query] = queryContext.queries;
+
+ // Should not have duplicate AVG(price)
+ expect(query.metrics).toEqual(['COUNT(*)', 'AVG(price)']);
+ expect(query.metrics).toHaveLength(2);
+});
diff --git
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/buildQuery.ts
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/buildQuery.ts
index 598bbfdce9..e1976c6544 100644
---
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/buildQuery.ts
+++
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/buildQuery.ts
@@ -34,11 +34,13 @@ import {
processMetricsArray,
addTooltipColumnsToQuery,
} from '../buildQueryUtils';
+import { isMetricValue, extractMetricKey } from '../utils/metricUtils';
export interface DeckScatterFormData
extends Omit<SpatialFormData, 'color_picker'>, SqlaFormData {
point_radius_fixed?: {
- value?: string;
+ type?: 'fix' | 'metric';
+ value?: string | number;
};
multiplier?: number;
point_unit?: string;
@@ -78,15 +80,29 @@ export default function buildQuery(formData:
DeckScatterFormData) {
columns = withJsColumns as QueryFormColumn[];
columns = addTooltipColumnsToQuery(columns, tooltip_contents);
- const metrics = processMetricsArray([point_radius_fixed?.value]);
+ // Only add metric if point_radius_fixed is a metric type
+ const isMetric = isMetricValue(point_radius_fixed);
+ const metricValue = isMetric
+ ? extractMetricKey(point_radius_fixed?.value)
+ : null;
+
+ // Preserve existing metrics and only add radius metric if it's
metric-based
+ const existingMetrics = baseQueryObject.metrics || [];
+ const radiusMetrics = processMetricsArray(
+ metricValue ? [metricValue] : [],
+ );
+ // Deduplicate metrics to avoid adding the same metric twice
+ const metricsSet = new Set([...existingMetrics, ...radiusMetrics]);
+ const metrics = Array.from(metricsSet);
const filters = addSpatialNullFilters(
spatial,
ensureIsArray(baseQueryObject.filters || []),
);
- const orderby = point_radius_fixed?.value
- ? ([[point_radius_fixed.value, false]] as QueryFormOrderBy[])
- : (baseQueryObject.orderby as QueryFormOrderBy[]) || [];
+ const orderby =
+ isMetric && metricValue
+ ? ([[metricValue, false]] as QueryFormOrderBy[])
+ : (baseQueryObject.orderby as QueryFormOrderBy[]) || [];
return [
{
diff --git
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/transformProps.test.ts
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/transformProps.test.ts
new file mode 100644
index 0000000000..bbdf7ced7b
--- /dev/null
+++
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/transformProps.test.ts
@@ -0,0 +1,303 @@
+/**
+ * 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';
+
+interface ScatterFeature {
+ position: [number, number];
+ radius?: number;
+ metric?: 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: {
+ spatial: {
+ type: 'latlong',
+ latCol: 'LATITUDE',
+ lonCol: 'LONGITUDE',
+ },
+ viewport: {},
+ },
+ queriesData: [
+ {
+ data: [
+ {
+ LATITUDE: 37.8,
+ LONGITUDE: -122.4,
+ population: 50000,
+ 'AVG(radius_value)': 100,
+ },
+ {
+ LATITUDE: 37.9,
+ LONGITUDE: -122.3,
+ population: 75000,
+ 'AVG(radius_value)': 200,
+ },
+ ],
+ },
+ ],
+ datasource: {
+ type: DatasourceType.Table,
+ id: 1,
+ name: 'test_datasource',
+ columns: [],
+ metrics: [],
+ },
+ height: 400,
+ width: 600,
+ hooks: {},
+ filterState: {},
+ emitCrossFilters: false,
+};
+
+test('Scatter transformProps should use fixed radius value when
point_radius_fixed type is "fix"', () => {
+ const fixedProps = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ },
+ };
+
+ const result = transformProps(fixedProps as ChartProps);
+ const features = result.payload.data.features as ScatterFeature[];
+
+ expect(features).toHaveLength(2);
+ expect(features[0]?.radius).toBe(1000);
+ expect(features[1]?.radius).toBe(1000);
+ // metric should not be set for fixed radius
+ expect(features[0]?.metric).toBeUndefined();
+ expect(features[1]?.metric).toBeUndefined();
+});
+
+test('Scatter transformProps should use metric value for radius when
point_radius_fixed type is "metric"', () => {
+ const metricProps = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ point_radius_fixed: {
+ type: 'metric',
+ value: 'AVG(radius_value)',
+ },
+ },
+ };
+
+ const result = transformProps(metricProps as ChartProps);
+ const features = result.payload.data.features as ScatterFeature[];
+
+ expect(features).toHaveLength(2);
+ expect(features[0]?.radius).toBe(100);
+ expect(features[0]?.metric).toBe(100);
+ expect(features[1]?.radius).toBe(200);
+ expect(features[1]?.metric).toBe(200);
+});
+
+test('Scatter transformProps should handle numeric fixed radius value', () => {
+ const fixedProps = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ point_radius_fixed: {
+ type: 'fix',
+ value: 500, // numeric value
+ },
+ },
+ };
+
+ const result = transformProps(fixedProps as ChartProps);
+ const features = result.payload.data.features as ScatterFeature[];
+
+ expect(features).toHaveLength(2);
+ expect(features[0]?.radius).toBe(500);
+ expect(features[1]?.radius).toBe(500);
+});
+
+test('Scatter transformProps should handle missing point_radius_fixed', () => {
+ const propsWithoutRadius = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ // no point_radius_fixed
+ },
+ };
+
+ const result = transformProps(propsWithoutRadius as ChartProps);
+ const features = result.payload.data.features as ScatterFeature[];
+
+ expect(features).toHaveLength(2);
+ // radius should not be set
+ expect(features[0]?.radius).toBeUndefined();
+ expect(features[1]?.radius).toBeUndefined();
+});
+
+test('Scatter transformProps should handle dimension for category colors', ()
=> {
+ const propsWithDimension = {
+ ...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: 'B',
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProps(propsWithDimension as ChartProps);
+ const features = result.payload.data.features as ScatterFeature[];
+
+ expect(features).toHaveLength(2);
+ expect(features[0]?.cat_color).toBe('A');
+ expect(features[1]?.cat_color).toBe('B');
+});
+
+test('Scatter transformProps should not include metric labels for fixed
radius', () => {
+ const fixedProps = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ },
+ };
+
+ const result = transformProps(fixedProps as ChartProps);
+
+ // metricLabels should be empty for fixed radius
+ expect(result.payload.data.metricLabels).toEqual([]);
+});
+
+test('Scatter transformProps should include metric labels for metric-based
radius', () => {
+ const metricProps = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ point_radius_fixed: {
+ type: 'metric',
+ value: 'AVG(radius_value)',
+ },
+ },
+ };
+
+ const result = transformProps(metricProps as ChartProps);
+
+ // metricLabels should include the metric name
+ expect(result.payload.data.metricLabels).toContain('AVG(radius_value)');
+});
+
+test('Scatter transformProps should handle no records', () => {
+ const noDataProps = {
+ ...mockChartProps,
+ queriesData: [{ data: [] }],
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ },
+ };
+
+ const result = transformProps(noDataProps as ChartProps);
+ const features = result.payload.data.features as ScatterFeature[];
+
+ expect(features).toHaveLength(0);
+});
+
+test('Scatter transformProps should handle missing spatial configuration
gracefully', () => {
+ const noSpatialProps = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ spatial: undefined,
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ },
+ };
+
+ const result = transformProps(noSpatialProps as ChartProps);
+ const features = result.payload.data.features as ScatterFeature[];
+
+ expect(features).toHaveLength(0);
+});
+
+test('Scatter transformProps should preserve extra properties from records',
() => {
+ const propsWithExtraData = {
+ ...mockChartProps,
+ rawFormData: {
+ ...mockChartProps.rawFormData,
+ point_radius_fixed: {
+ type: 'fix',
+ value: '1000',
+ },
+ },
+ queriesData: [
+ {
+ data: [
+ {
+ LATITUDE: 37.8,
+ LONGITUDE: -122.4,
+ custom_field: 'value1',
+ another_field: 123,
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProps(propsWithExtraData as ChartProps);
+ const features = result.payload.data.features as ScatterFeature[];
+
+ expect(features).toHaveLength(1);
+ expect(features[0]).toMatchObject({
+ custom_field: 'value1',
+ another_field: 123,
+ });
+});
diff --git
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/transformProps.ts
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/transformProps.ts
index 7f4a300886..a168bd821e 100644
---
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/transformProps.ts
+++
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Scatter/transformProps.ts
@@ -26,6 +26,7 @@ import {
addPropertiesToFeature,
} from '../transformUtils';
import { DeckScatterFormData } from './buildQuery';
+import { isFixedValue, getFixedValue } from '../utils/metricUtils';
interface ScatterPoint {
position: [number, number];
@@ -43,6 +44,7 @@ function processScatterData(
radiusMetricLabel?: string,
categoryColumn?: string,
jsColumns?: string[],
+ fixedRadiusValue?: number | string | null,
): ScatterPoint[] {
if (!spatial || !records.length) {
return [];
@@ -72,7 +74,15 @@ function processScatterData(
extraProps: feature.extraProps || {},
};
- if (radiusMetricLabel && feature[radiusMetricLabel] != null) {
+ // Handle radius: either from metric or fixed value
+ if (fixedRadiusValue != null) {
+ // Use fixed radius value for all points
+ const parsedFixedRadius = parseMetricValue(fixedRadiusValue);
+ if (parsedFixedRadius !== undefined) {
+ scatterPoint.radius = parsedFixedRadius;
+ }
+ } else if (radiusMetricLabel && feature[radiusMetricLabel] != null) {
+ // Use metric value for radius
const radiusValue = parseMetricValue(feature[radiusMetricLabel]);
if (radiusValue !== undefined) {
scatterPoint.radius = radiusValue;
@@ -98,14 +108,21 @@ export default function transformProps(chartProps:
ChartProps) {
const { spatial, point_radius_fixed, dimension, js_columns } =
formData as DeckScatterFormData;
+ // Check if this is a fixed value or metric
+ const fixedRadiusValue = isFixedValue(point_radius_fixed)
+ ? getFixedValue(point_radius_fixed)
+ : null;
+
const radiusMetricLabel = getMetricLabelFromFormData(point_radius_fixed);
const records = getRecordsFromQuery(chartProps.queriesData);
+
const features = processScatterData(
records,
spatial,
radiusMetricLabel,
dimension,
js_columns,
+ fixedRadiusValue,
);
return createBaseTransformResult(
diff --git
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/transformUtils.test.ts
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/transformUtils.test.ts
new file mode 100644
index 0000000000..211bd9e6fd
--- /dev/null
+++
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/transformUtils.test.ts
@@ -0,0 +1,184 @@
+/**
+ * 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 { getMetricLabel } from '@superset-ui/core';
+import { getMetricLabelFromFormData, parseMetricValue } from
'./transformUtils';
+
+jest.mock('@superset-ui/core', () => ({
+ ...jest.requireActual('@superset-ui/core'),
+ getMetricLabel: jest.fn((metric: string) => metric),
+}));
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+test('getMetricLabelFromFormData should return undefined for undefined input',
() => {
+ const result = getMetricLabelFromFormData(undefined);
+ expect(result).toBeUndefined();
+});
+
+test('getMetricLabelFromFormData should return undefined for null input', ()
=> {
+ const result = getMetricLabelFromFormData(null as any);
+ expect(result).toBeUndefined();
+});
+
+test('getMetricLabelFromFormData should handle string metric directly', () => {
+ const result = getMetricLabelFromFormData('AVG(value)');
+ expect(result).toBe('AVG(value)');
+ expect(getMetricLabel).toHaveBeenCalledWith('AVG(value)');
+});
+
+test('getMetricLabelFromFormData should return undefined for fixed type', ()
=> {
+ const result = getMetricLabelFromFormData({
+ type: 'fix',
+ value: '1000',
+ });
+ expect(result).toBeUndefined();
+ expect(getMetricLabel).not.toHaveBeenCalled();
+});
+
+test('getMetricLabelFromFormData should return undefined for fixed type with
numeric value', () => {
+ const result = getMetricLabelFromFormData({
+ type: 'fix',
+ value: 1000,
+ });
+ expect(result).toBeUndefined();
+ expect(getMetricLabel).not.toHaveBeenCalled();
+});
+
+test('getMetricLabelFromFormData should return metric label for metric type
with string value', () => {
+ const result = getMetricLabelFromFormData({
+ type: 'metric',
+ value: 'SUM(amount)',
+ });
+ expect(result).toBe('SUM(amount)');
+ expect(getMetricLabel).toHaveBeenCalledWith('SUM(amount)');
+});
+
+test('getMetricLabelFromFormData should handle object metric values', () => {
+ const result = getMetricLabelFromFormData({
+ type: 'metric',
+ value: {
+ label: 'Total Sales',
+ sqlExpression: 'SUM(sales)',
+ },
+ });
+ expect(result).toBe('Total Sales');
+ expect(getMetricLabel).toHaveBeenCalledWith('Total Sales');
+});
+
+test('getMetricLabelFromFormData should use sqlExpression if label is
missing', () => {
+ const result = getMetricLabelFromFormData({
+ type: 'metric',
+ value: {
+ sqlExpression: 'COUNT(*)',
+ },
+ });
+ expect(result).toBe('COUNT(*)');
+ expect(getMetricLabel).toHaveBeenCalledWith('COUNT(*)');
+});
+
+test('getMetricLabelFromFormData should use value field as fallback', () => {
+ const result = getMetricLabelFromFormData({
+ type: 'metric',
+ value: {
+ value: 'AVG(price)',
+ },
+ });
+ expect(result).toBe('AVG(price)');
+ expect(getMetricLabel).toHaveBeenCalledWith('AVG(price)');
+});
+
+test('getMetricLabelFromFormData should handle metric type with numeric
value', () => {
+ const result = getMetricLabelFromFormData({
+ type: 'metric',
+ value: 123,
+ });
+ expect(result).toBe('123');
+ expect(getMetricLabel).toHaveBeenCalledWith('123');
+});
+
+test('getMetricLabelFromFormData should return undefined for object without
type', () => {
+ const result = getMetricLabelFromFormData({
+ value: 'AVG(value)',
+ });
+ expect(result).toBeUndefined();
+});
+
+test('getMetricLabelFromFormData should return undefined for empty object', ()
=> {
+ const result = getMetricLabelFromFormData({});
+ expect(result).toBeUndefined();
+});
+
+test('getMetricLabelFromFormData should return undefined for metric type
without value', () => {
+ const result = getMetricLabelFromFormData({
+ type: 'metric',
+ });
+ expect(result).toBeUndefined();
+});
+
+test('parseMetricValue should parse numeric strings', () => {
+ expect(parseMetricValue('123')).toBe(123);
+ expect(parseMetricValue('123.45')).toBe(123.45);
+ expect(parseMetricValue('0')).toBe(0);
+ expect(parseMetricValue('-123')).toBe(-123);
+});
+
+test('parseMetricValue should handle numbers directly', () => {
+ expect(parseMetricValue(123)).toBe(123);
+ expect(parseMetricValue(123.45)).toBe(123.45);
+ expect(parseMetricValue(0)).toBe(0);
+ expect(parseMetricValue(-123)).toBe(-123);
+});
+
+test('parseMetricValue should return undefined for null', () => {
+ expect(parseMetricValue(null)).toBeUndefined();
+});
+
+test('parseMetricValue should return undefined for undefined', () => {
+ expect(parseMetricValue(undefined)).toBeUndefined();
+});
+
+test('parseMetricValue should return undefined for non-numeric strings', () =>
{
+ expect(parseMetricValue('abc')).toBeUndefined();
+ expect(parseMetricValue('12a34')).toBe(12); // parseFloat returns 12
+ expect(parseMetricValue('')).toBeUndefined();
+});
+
+test('parseMetricValue should handle edge cases', () => {
+ expect(parseMetricValue('Infinity')).toBe(Infinity);
+ expect(parseMetricValue('-Infinity')).toBe(-Infinity);
+ expect(parseMetricValue('NaN')).toBeUndefined();
+});
+
+test('parseMetricValue should handle boolean values', () => {
+ expect(parseMetricValue(true as any)).toBeUndefined();
+ expect(parseMetricValue(false as any)).toBeUndefined();
+});
+
+test('parseMetricValue should handle objects', () => {
+ expect(parseMetricValue({} as any)).toBeUndefined();
+ expect(parseMetricValue({ value: 123 } as any)).toBeUndefined();
+});
+
+test('parseMetricValue should handle arrays', () => {
+ expect(parseMetricValue([] as any)).toBeUndefined();
+ expect(parseMetricValue([123] as any)).toBe(123); // String([123]) = '123'
+});
diff --git
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/transformUtils.ts
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/transformUtils.ts
index 6427db900a..7cea61183a 100644
---
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/transformUtils.ts
+++
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/transformUtils.ts
@@ -16,8 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ChartProps, getMetricLabel } from '@superset-ui/core';
+import { ChartProps } from '@superset-ui/core';
import { getMapboxApiKey, DataRecord } from './spatialUtils';
+import {
+ getMetricLabelFromValue,
+ FixedOrMetricValue,
+} from './utils/metricUtils';
const NOOP = () => {};
@@ -134,9 +138,7 @@ export function addPropertiesToFeature<T extends
Record<string, unknown>>(
}
export function getMetricLabelFromFormData(
- metric: string | { value?: string } | undefined,
+ metric: string | FixedOrMetricValue | undefined | null,
): string | undefined {
- if (!metric) return undefined;
- if (typeof metric === 'string') return getMetricLabel(metric);
- return metric.value ? getMetricLabel(metric.value) : undefined;
+ return getMetricLabelFromValue(metric);
}
diff --git
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/utils/metricUtils.test.ts
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/utils/metricUtils.test.ts
new file mode 100644
index 0000000000..dfd5878e54
--- /dev/null
+++
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/utils/metricUtils.test.ts
@@ -0,0 +1,121 @@
+/**
+ * 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 { getMetricLabel } from '@superset-ui/core';
+import {
+ isMetricValue,
+ isFixedValue,
+ extractMetricKey,
+ getMetricLabelFromValue,
+ getFixedValue,
+} from './metricUtils';
+
+jest.mock('@superset-ui/core', () => ({
+ ...jest.requireActual('@superset-ui/core'),
+ getMetricLabel: jest.fn((metric: string) => metric),
+}));
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+test('isMetricValue should identify metric values correctly', () => {
+ expect(isMetricValue({ type: 'metric', value: 'COUNT(*)' })).toBe(true);
+ expect(isMetricValue({ type: 'fix', value: '1000' })).toBe(false);
+ expect(isMetricValue('AVG(value)')).toBe(true); // legacy string format
+ expect(isMetricValue(undefined)).toBe(false);
+ expect(isMetricValue(null)).toBe(false);
+});
+
+test('isFixedValue should identify fixed values correctly', () => {
+ expect(isFixedValue({ type: 'fix', value: '1000' })).toBe(true);
+ expect(isFixedValue({ type: 'metric', value: 'COUNT(*)' })).toBe(false);
+ expect(isFixedValue('AVG(value)')).toBe(false); // legacy string format
+ expect(isFixedValue(undefined)).toBe(false);
+ expect(isFixedValue(null)).toBe(false);
+});
+
+test('extractMetricKey should handle string values', () => {
+ expect(extractMetricKey('COUNT(*)')).toBe('COUNT(*)');
+ expect(extractMetricKey('')).toBe('');
+});
+
+test('extractMetricKey should handle number values', () => {
+ expect(extractMetricKey(123)).toBe('123');
+ expect(extractMetricKey(0)).toBe('0');
+});
+
+test('extractMetricKey should extract from object properties', () => {
+ expect(extractMetricKey({ label: 'Total Sales' })).toBe('Total Sales');
+ expect(extractMetricKey({ sqlExpression: 'SUM(sales)' })).toBe('SUM(sales)');
+ expect(extractMetricKey({ value: 'AVG(price)' })).toBe('AVG(price)');
+ expect(
+ extractMetricKey({ label: 'Label', sqlExpression: 'SQL', value: 'Value' }),
+ ).toBe('Label'); // priority order
+});
+
+test('extractMetricKey should handle null/undefined', () => {
+ expect(extractMetricKey(null)).toBeUndefined();
+ expect(extractMetricKey(undefined)).toBeUndefined();
+ expect(extractMetricKey({})).toBeUndefined();
+});
+
+test('getMetricLabelFromValue should return label for metric values', () => {
+ getMetricLabelFromValue({ type: 'metric', value: 'COUNT(*)' });
+ expect(getMetricLabel).toHaveBeenCalledWith('COUNT(*)');
+
+ getMetricLabelFromValue({ type: 'metric', value: { label: 'Total Sales' } });
+ expect(getMetricLabel).toHaveBeenCalledWith('Total Sales');
+});
+
+test('getMetricLabelFromValue should return undefined for fixed values', () =>
{
+ const result = getMetricLabelFromValue({ type: 'fix', value: '1000' });
+ expect(result).toBeUndefined();
+ expect(getMetricLabel).not.toHaveBeenCalled();
+});
+
+test('getMetricLabelFromValue should handle legacy string format', () => {
+ getMetricLabelFromValue('AVG(value)');
+ expect(getMetricLabel).toHaveBeenCalledWith('AVG(value)');
+});
+
+test('getFixedValue should return value for fixed types', () => {
+ expect(getFixedValue({ type: 'fix', value: '1000' })).toBe('1000');
+ expect(getFixedValue({ type: 'fix', value: 500 })).toBe(500);
+});
+
+test('getFixedValue should return undefined for metric types', () => {
+ expect(getFixedValue({ type: 'metric', value: 'COUNT(*)' })).toBeUndefined();
+});
+
+test('getFixedValue should return undefined for string values', () => {
+ expect(getFixedValue('AVG(value)')).toBeUndefined();
+});
+
+test('getFixedValue should handle object values', () => {
+ expect(
+ getFixedValue({ type: 'fix', value: { label: 'object' } }),
+ ).toBeUndefined();
+});
+
+test('getFixedValue should handle missing values', () => {
+ expect(getFixedValue({ type: 'fix' })).toBeUndefined();
+ expect(getFixedValue(undefined)).toBeUndefined();
+ expect(getFixedValue(null)).toBeUndefined();
+});
diff --git
a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/utils/metricUtils.ts
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/utils/metricUtils.ts
new file mode 100644
index 0000000000..688ef78e08
--- /dev/null
+++
b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/utils/metricUtils.ts
@@ -0,0 +1,120 @@
+/**
+ * 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 { getMetricLabel } from '@superset-ui/core';
+
+export type MetricFormValue =
+ | string
+ | number
+ | { label?: string; sqlExpression?: string; value?: string }
+ | undefined
+ | null;
+
+export interface FixedOrMetricValue {
+ type?: 'fix' | 'metric';
+ value?: MetricFormValue;
+}
+
+/**
+ * Checks if a value is configured as a metric (vs fixed value)
+ */
+export function isMetricValue(
+ fixedOrMetric: string | FixedOrMetricValue | undefined | null,
+): boolean {
+ if (!fixedOrMetric) return false;
+ if (typeof fixedOrMetric === 'string') return true;
+ return fixedOrMetric.type === 'metric';
+}
+
+/**
+ * Checks if a value is configured as a fixed value (vs metric)
+ */
+export function isFixedValue(
+ fixedOrMetric: string | FixedOrMetricValue | undefined | null,
+): boolean {
+ if (!fixedOrMetric) return false;
+ if (typeof fixedOrMetric === 'string') return false;
+ return fixedOrMetric.type === 'fix';
+}
+
+/**
+ * Extracts the metric key from a metric value object
+ * Handles label, sqlExpression, and value properties
+ */
+export function extractMetricKey(value: MetricFormValue): string | undefined {
+ if (value == null) return undefined;
+ if (typeof value === 'string') return value;
+ if (typeof value === 'number') return String(value);
+
+ // Handle object metrics (adhoc or saved metrics)
+ const metricObj = value as {
+ label?: string;
+ sqlExpression?: string;
+ value?: string;
+ };
+ return metricObj.label || metricObj.sqlExpression || metricObj.value;
+}
+
+/**
+ * Gets the metric label from a fixed/metric form value
+ * Returns undefined for fixed values, metric label for metric values
+ */
+export function getMetricLabelFromValue(
+ fixedOrMetric: string | FixedOrMetricValue | undefined | null,
+): string | undefined {
+ if (!fixedOrMetric) return undefined;
+
+ // Legacy string format - treat as metric
+ if (typeof fixedOrMetric === 'string') {
+ return getMetricLabel(fixedOrMetric);
+ }
+
+ // Only return metric label if it's a metric type, not a fixed value
+ if (isMetricValue(fixedOrMetric) && fixedOrMetric.value) {
+ const metricKey = extractMetricKey(fixedOrMetric.value);
+ if (metricKey && typeof metricKey === 'string') {
+ return getMetricLabel(metricKey);
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Gets the fixed value from a fixed/metric form value
+ * Returns the value for fixed types, undefined for metrics
+ */
+export function getFixedValue(
+ fixedOrMetric: string | FixedOrMetricValue | undefined | null,
+): string | number | undefined {
+ if (!fixedOrMetric || typeof fixedOrMetric === 'string') {
+ return undefined;
+ }
+
+ if (isFixedValue(fixedOrMetric) && fixedOrMetric.value) {
+ if (
+ typeof fixedOrMetric.value === 'string' ||
+ typeof fixedOrMetric.value === 'number'
+ ) {
+ return fixedOrMetric.value;
+ }
+ }
+
+ return undefined;
+}