You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/03/17 15:00:12 UTC
(superset) 28/29: fix(map-box): make opacity, lon, lat, and zoom controls functional (#38374)
This is an automated email from the ASF dual-hosted git repository.
michaelsmolina pushed a commit to branch 6.1
in repository https://gitbox.apache.org/repos/asf/superset.git
commit cffca7367cd9da34762a93346e741d26d8a12af3
Author: João Pedro Alves Barbosa <93...@users.noreply.github.com>
AuthorDate: Mon Mar 16 21:55:49 2026 -0300
fix(map-box): make opacity, lon, lat, and zoom controls functional (#38374)
Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
Co-authored-by: codeant-ai-for-open-source[bot] <24...@users.noreply.github.com>
(cherry picked from commit 96705c156a965f6a4fe77f483d7692d2838e83f1)
---
.../legacy-plugin-chart-map-box/src/MapBox.tsx | 96 ++++--
.../src/controlPanel.ts | 7 +-
.../src/transformProps.ts | 44 ++-
.../test/MapBox.test.tsx | 381 +++++++++++++++++++++
.../test/controlPanel.test.ts | 81 +++++
.../test/transformProps.test.ts | 230 +++++++++++++
6 files changed, 813 insertions(+), 26 deletions(-)
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx
index 361a27610c9..a69659de0b3 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx
@@ -61,6 +61,9 @@ interface MapBoxProps {
renderWhileDragging?: boolean;
rgb?: (string | number)[];
bounds?: [[number, number], [number, number]]; // May be undefined for empty datasets
+ viewportLongitude?: number;
+ viewportLatitude?: number;
+ viewportZoom?: number;
}
interface MapBoxState {
@@ -82,30 +85,10 @@ class MapBox extends Component<MapBoxProps, MapBoxState> {
constructor(props: MapBoxProps) {
super(props);
- const { width = 400, height = 400, bounds } = this.props;
- // Get a viewport that fits the given bounds, which all marks to be clustered.
- // Derive lat, lon and zoom from this viewport. This is only done on initial
- // render as the bounds don't update as we pan/zoom in the current design.
-
- let latitude = 0;
- let longitude = 0;
- let zoom = 1;
-
- // Guard against empty datasets where bounds may be undefined
- if (bounds && bounds[0] && bounds[1]) {
- const mercator = new WebMercatorViewport({
- width,
- height,
- }).fitBounds(bounds);
- ({ latitude, longitude, zoom } = mercator);
- }
+ const fitBounds = this.computeFitBoundsViewport();
this.state = {
- viewport: {
- longitude,
- latitude,
- zoom,
- },
+ viewport: this.mergeViewportWithProps(fitBounds),
};
this.handleViewportChange = this.handleViewportChange.bind(this);
}
@@ -116,6 +99,75 @@ class MapBox extends Component<MapBoxProps, MapBoxState> {
onViewportChange!(viewport);
}
+ mergeViewportWithProps(
+ fitBounds: Viewport,
+ viewport: Viewport = fitBounds,
+ props: MapBoxProps = this.props,
+ useFitBoundsForUnset = true,
+ ): Viewport {
+ const { viewportLongitude, viewportLatitude, viewportZoom } = props;
+
+ return {
+ ...viewport,
+ longitude:
+ viewportLongitude ??
+ (useFitBoundsForUnset ? fitBounds.longitude : viewport.longitude),
+ latitude:
+ viewportLatitude ??
+ (useFitBoundsForUnset ? fitBounds.latitude : viewport.latitude),
+ zoom:
+ viewportZoom ?? (useFitBoundsForUnset ? fitBounds.zoom : viewport.zoom),
+ };
+ }
+
+ computeFitBoundsViewport(): Viewport {
+ const { width = 400, height = 400, bounds } = this.props;
+ if (bounds && bounds[0] && bounds[1]) {
+ const mercator = new WebMercatorViewport({ width, height }).fitBounds(
+ bounds,
+ );
+ return {
+ latitude: mercator.latitude,
+ longitude: mercator.longitude,
+ zoom: mercator.zoom,
+ };
+ }
+ return { latitude: 0, longitude: 0, zoom: 1 };
+ }
+
+ componentDidUpdate(prevProps: MapBoxProps) {
+ const { viewport } = this.state;
+ const fitBoundsInputsChanged =
+ prevProps.width !== this.props.width ||
+ prevProps.height !== this.props.height ||
+ prevProps.bounds !== this.props.bounds;
+ const viewportPropsChanged =
+ prevProps.viewportLongitude !== this.props.viewportLongitude ||
+ prevProps.viewportLatitude !== this.props.viewportLatitude ||
+ prevProps.viewportZoom !== this.props.viewportZoom;
+
+ if (!fitBoundsInputsChanged && !viewportPropsChanged) {
+ return;
+ }
+
+ const fitBounds = this.computeFitBoundsViewport();
+ const nextViewport = this.mergeViewportWithProps(
+ fitBounds,
+ viewport,
+ this.props,
+ fitBoundsInputsChanged || viewportPropsChanged,
+ );
+
+ const viewportChanged =
+ nextViewport.longitude !== viewport.longitude ||
+ nextViewport.latitude !== viewport.latitude ||
+ nextViewport.zoom !== viewport.zoom;
+
+ if (viewportChanged) {
+ this.setState({ viewport: nextViewport });
+ }
+ }
+
render() {
const {
width,
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/controlPanel.ts b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/controlPanel.ts
index 80d6ab0c7f6..8b1c846e048 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/controlPanel.ts
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/controlPanel.ts
@@ -241,6 +241,7 @@ const config: ControlPanelConfig = {
label: t('Opacity'),
default: 1,
isFloat: true,
+ renderTrigger: true,
description: t(
'Opacity of all clusters, points, and labels. Between 0 and 1.',
),
@@ -273,7 +274,7 @@ const config: ControlPanelConfig = {
type: 'TextControl',
label: t('Default longitude'),
renderTrigger: true,
- default: -122.405293,
+ default: '',
isFloat: true,
description: t('Longitude of default viewport'),
places: 8,
@@ -287,7 +288,7 @@ const config: ControlPanelConfig = {
type: 'TextControl',
label: t('Default latitude'),
renderTrigger: true,
- default: 37.772123,
+ default: '',
isFloat: true,
description: t('Latitude of default viewport'),
places: 8,
@@ -304,7 +305,7 @@ const config: ControlPanelConfig = {
label: t('Zoom'),
renderTrigger: true,
isFloat: true,
- default: 11,
+ default: '',
description: t('Zoom level of the map'),
places: 8,
// Viewport zoom shouldn't prompt user to re-run query
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/transformProps.ts b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/transformProps.ts
index caaeb9b3f55..45eca8d4448 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/transformProps.ts
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/transformProps.ts
@@ -23,6 +23,30 @@ import { ChartProps } from '@superset-ui/core';
import { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapBox';
const NOOP = () => {};
+const MIN_LONGITUDE = -180;
+const MAX_LONGITUDE = 180;
+const MIN_LATITUDE = -90;
+const MAX_LATITUDE = 90;
+const MIN_ZOOM = 0;
+
+function toFiniteNumber(
+ value: string | number | null | undefined,
+): number | undefined {
+ if (value === null || value === undefined) return undefined;
+ const normalizedValue = typeof value === 'string' ? value.trim() : value;
+ if (normalizedValue === '') return undefined;
+ const num = Number(normalizedValue);
+ return Number.isFinite(num) ? num : undefined;
+}
+
+function clampNumber(
+ value: number | undefined,
+ min: number,
+ max: number,
+): number | undefined {
+ if (value === undefined) return undefined;
+ return Math.min(max, Math.max(min, value));
+}
interface ClusterProperties {
metric: number;
@@ -45,6 +69,9 @@ export default function transformProps(chartProps: ChartProps) {
pandasAggfunc,
pointRadiusUnit,
renderWhileDragging,
+ viewportLongitude,
+ viewportLatitude,
+ viewportZoom,
} = formData;
// Validate mapbox color
@@ -93,7 +120,6 @@ export default function transformProps(chartProps: ChartProps) {
aggregatorName: pandasAggfunc,
bounds,
clusterer,
- globalOpacity,
hasCustomMetric,
mapboxApiKey,
mapStyle: mapboxStyle,
@@ -116,5 +142,21 @@ export default function transformProps(chartProps: ChartProps) {
pointRadiusUnit,
renderWhileDragging,
rgb,
+ viewportLongitude: clampNumber(
+ toFiniteNumber(viewportLongitude),
+ MIN_LONGITUDE,
+ MAX_LONGITUDE,
+ ),
+ viewportLatitude: clampNumber(
+ toFiniteNumber(viewportLatitude),
+ MIN_LATITUDE,
+ MAX_LATITUDE,
+ ),
+ viewportZoom: clampNumber(
+ toFiniteNumber(viewportZoom),
+ MIN_ZOOM,
+ DEFAULT_MAX_ZOOM,
+ ),
+ globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)),
};
}
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/test/MapBox.test.tsx b/superset-frontend/plugins/legacy-plugin-chart-map-box/test/MapBox.test.tsx
new file mode 100644
index 00000000000..f0782f5fc14
--- /dev/null
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/test/MapBox.test.tsx
@@ -0,0 +1,381 @@
+/*
+ * 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 { type ReactNode } from 'react';
+import { render } from '@testing-library/react';
+import MapBox from '../src/MapBox';
+
+// Capture the most recent viewport props passed to MapGL
+let lastMapGLProps: Record<string, unknown> = {};
+const mockFitBounds = jest.fn();
+
+jest.mock('react-map-gl', () => {
+ const MockMapGL = (props: Record<string, unknown>) => {
+ lastMapGLProps = props;
+ return <div data-test="map-gl">{props.children as ReactNode}</div>;
+ };
+ return { __esModule: true, default: MockMapGL };
+});
+
+jest.mock('@math.gl/web-mercator', () => ({
+ WebMercatorViewport: jest
+ .fn()
+ .mockImplementation(
+ ({ width, height }: { width: number; height: number }) => ({
+ fitBounds: (bounds: [[number, number], [number, number]]) =>
+ mockFitBounds(bounds, width, height),
+ }),
+ ),
+}));
+
+jest.mock('../src/ScatterPlotGlowOverlay', () => {
+ const MockOverlay = (props: Record<string, unknown>) => (
+ <div data-test="scatter-overlay" data-opacity={props.globalOpacity} />
+ );
+ return { __esModule: true, default: MockOverlay };
+});
+
+const defaultProps = {
+ width: 800,
+ height: 600,
+ clusterer: {
+ getClusters: jest.fn().mockReturnValue([]),
+ },
+ globalOpacity: 1,
+ mapboxApiKey: 'test-key',
+ mapStyle: 'mapbox://styles/mapbox/light-v9',
+ pointRadius: 60,
+ pointRadiusUnit: 'Pixels',
+ renderWhileDragging: true,
+ rgb: ['', 255, 0, 0] as (string | number)[],
+ hasCustomMetric: false,
+ bounds: [
+ [-74.0, 40.7],
+ [-73.9, 40.8],
+ ] as [[number, number], [number, number]],
+ onViewportChange: jest.fn(),
+};
+
+beforeEach(() => {
+ lastMapGLProps = {};
+ jest.clearAllMocks();
+ mockFitBounds.mockImplementation(
+ (
+ bounds: [[number, number], [number, number]],
+ width: number,
+ height: number,
+ ) => ({
+ latitude: Number(((bounds[0][1] + bounds[1][1]) / 2).toFixed(2)),
+ longitude: Number(((bounds[0][0] + bounds[1][0]) / 2).toFixed(2)),
+ zoom: Number((10 + width / 1000 + height / 10000).toFixed(2)),
+ }),
+ );
+});
+
+test('initializes viewport from bounds', () => {
+ render(<MapBox {...defaultProps} />);
+ expect(lastMapGLProps.latitude).toBe(40.75);
+ expect(lastMapGLProps.longitude).toBe(-73.95);
+ expect(lastMapGLProps.zoom).toBe(10.86);
+});
+
+test('updates viewport when viewport props change', () => {
+ const { rerender } = render(
+ <MapBox
+ {...defaultProps}
+ viewportLongitude={-73.95}
+ viewportLatitude={40.75}
+ viewportZoom={10}
+ />,
+ );
+
+ rerender(
+ <MapBox
+ {...defaultProps}
+ viewportLongitude={-122.4}
+ viewportLatitude={37.8}
+ viewportZoom={5}
+ />,
+ );
+
+ expect(lastMapGLProps.longitude).toBe(-122.4);
+ expect(lastMapGLProps.latitude).toBe(37.8);
+ expect(lastMapGLProps.zoom).toBe(5);
+});
+
+test('does not loop when viewport state matches new props', () => {
+ const { rerender } = render(
+ <MapBox
+ {...defaultProps}
+ viewportLongitude={-73.95}
+ viewportLatitude={40.75}
+ viewportZoom={10}
+ />,
+ );
+
+ // Re-render with same props that match the initial viewport state
+ rerender(
+ <MapBox
+ {...defaultProps}
+ viewportLongitude={-73.95}
+ viewportLatitude={40.75}
+ viewportZoom={10}
+ />,
+ );
+
+ // Viewport should still be the fitBounds-computed values since props didn't change
+ expect(lastMapGLProps.longitude).toBe(-73.95);
+ expect(lastMapGLProps.latitude).toBe(40.75);
+ expect(lastMapGLProps.zoom).toBe(10);
+});
+
+test('passes globalOpacity to ScatterPlotGlowOverlay', () => {
+ const { getByTestId } = render(
+ <MapBox {...defaultProps} globalOpacity={0.5} />,
+ );
+ const overlay = getByTestId('scatter-overlay');
+ expect(overlay.dataset.opacity).toBe('0.5');
+});
+
+test('initializes viewport from props when provided', () => {
+ render(
+ <MapBox
+ {...defaultProps}
+ viewportLongitude={-122.4}
+ viewportLatitude={37.8}
+ viewportZoom={5}
+ />,
+ );
+ expect(lastMapGLProps.longitude).toBe(-122.4);
+ expect(lastMapGLProps.latitude).toBe(37.8);
+ expect(lastMapGLProps.zoom).toBe(5);
+});
+
+test('handles undefined bounds gracefully', () => {
+ render(<MapBox {...defaultProps} bounds={undefined} />);
+ expect(lastMapGLProps.longitude).toBe(0);
+ expect(lastMapGLProps.latitude).toBe(0);
+ expect(lastMapGLProps.zoom).toBe(1);
+});
+
+test('applies partial viewport props on update', () => {
+ const { rerender } = render(<MapBox {...defaultProps} />);
+
+ rerender(<MapBox {...defaultProps} viewportLongitude={-122.4} />);
+
+ expect(lastMapGLProps.longitude).toBe(-122.4);
+ expect(lastMapGLProps.latitude).toBe(40.75);
+ expect(lastMapGLProps.zoom).toBe(10.86);
+});
+
+test('restores fitBounds when viewport props are cleared', () => {
+ const { rerender } = render(
+ <MapBox
+ {...defaultProps}
+ viewportLongitude={-122.4}
+ viewportLatitude={37.8}
+ viewportZoom={5}
+ />,
+ );
+
+ // Clear all viewport props (simulates user clearing the controls)
+ rerender(<MapBox {...defaultProps} />);
+
+ // Should revert to fitBounds values
+ expect(lastMapGLProps.longitude).toBe(-73.95);
+ expect(lastMapGLProps.latitude).toBe(40.75);
+ expect(lastMapGLProps.zoom).toBe(10.86);
+});
+
+test('restores only cleared viewport props, keeps the rest', () => {
+ const { rerender } = render(
+ <MapBox
+ {...defaultProps}
+ viewportLongitude={-122.4}
+ viewportLatitude={37.8}
+ viewportZoom={5}
+ />,
+ );
+
+ // Clear only longitude, keep lat/zoom
+ rerender(
+ <MapBox {...defaultProps} viewportLatitude={37.8} viewportZoom={5} />,
+ );
+
+ // Longitude reverts to fitBounds, lat/zoom stay
+ expect(lastMapGLProps.longitude).toBe(-73.95);
+ expect(lastMapGLProps.latitude).toBe(37.8);
+ expect(lastMapGLProps.zoom).toBe(5);
+});
+
+test('applies changed viewport props even when another is cleared simultaneously', () => {
+ const { rerender } = render(
+ <MapBox
+ {...defaultProps}
+ viewportLongitude={-122.4}
+ viewportLatitude={37.8}
+ viewportZoom={5}
+ />,
+ );
+
+ // Clear longitude, change latitude simultaneously
+ rerender(
+ <MapBox {...defaultProps} viewportLatitude={40.0} viewportZoom={5} />,
+ );
+
+ // Longitude reverts to fitBounds, latitude should be the NEW value
+ expect(lastMapGLProps.longitude).toBe(-73.95);
+ expect(lastMapGLProps.latitude).toBe(40.0);
+ expect(lastMapGLProps.zoom).toBe(5);
+});
+
+test('falls back to default viewport when cleared with undefined bounds', () => {
+ const { rerender } = render(
+ <MapBox
+ {...defaultProps}
+ bounds={undefined}
+ viewportLongitude={-122.4}
+ viewportLatitude={37.8}
+ viewportZoom={5}
+ />,
+ );
+
+ // Clear viewport props — no bounds to fitBounds to
+ rerender(<MapBox {...defaultProps} bounds={undefined} />);
+
+ // Should fall back to {0, 0, 1}
+ expect(lastMapGLProps.longitude).toBe(0);
+ expect(lastMapGLProps.latitude).toBe(0);
+ expect(lastMapGLProps.zoom).toBe(1);
+});
+
+test('recomputes fitBounds when bounds change and no explicit viewport is set', () => {
+ const { rerender } = render(<MapBox {...defaultProps} />);
+
+ rerender(
+ <MapBox
+ {...defaultProps}
+ bounds={[
+ [-123.2, 36.5],
+ [-121.8, 38.1],
+ ]}
+ />,
+ );
+
+ expect(lastMapGLProps.longitude).toBe(-122.5);
+ expect(lastMapGLProps.latitude).toBe(37.3);
+ expect(lastMapGLProps.zoom).toBe(10.86);
+});
+
+test('recomputes fitBounds when chart size changes and no explicit viewport is set', () => {
+ const { rerender } = render(<MapBox {...defaultProps} />);
+
+ rerender(<MapBox {...defaultProps} width={1200} height={900} />);
+
+ expect(lastMapGLProps.longitude).toBe(-73.95);
+ expect(lastMapGLProps.latitude).toBe(40.75);
+ expect(lastMapGLProps.zoom).toBe(11.29);
+});
+
+test('recomputes only implicit viewport fields when bounds change', () => {
+ const { rerender } = render(
+ <MapBox {...defaultProps} viewportLongitude={-122.4} />,
+ );
+
+ rerender(
+ <MapBox
+ {...defaultProps}
+ viewportLongitude={-122.4}
+ bounds={[
+ [-123.2, 36.5],
+ [-121.8, 38.1],
+ ]}
+ />,
+ );
+
+ expect(lastMapGLProps.longitude).toBe(-122.4);
+ expect(lastMapGLProps.latitude).toBe(37.3);
+ expect(lastMapGLProps.zoom).toBe(10.86);
+});
+
+test('recomputes only implicit viewport fields when chart size changes', () => {
+ const { rerender } = render(
+ <MapBox {...defaultProps} viewportLatitude={37.8} />,
+ );
+
+ rerender(
+ <MapBox
+ {...defaultProps}
+ viewportLatitude={37.8}
+ width={1200}
+ height={900}
+ />,
+ );
+
+ expect(lastMapGLProps.longitude).toBe(-73.95);
+ expect(lastMapGLProps.latitude).toBe(37.8);
+ expect(lastMapGLProps.zoom).toBe(11.29);
+});
+
+test('recomputes implicit position when zoom stays explicit across bounds changes', () => {
+ const { rerender } = render(<MapBox {...defaultProps} viewportZoom={5} />);
+
+ rerender(
+ <MapBox
+ {...defaultProps}
+ viewportZoom={5}
+ bounds={[
+ [-123.2, 36.5],
+ [-121.8, 38.1],
+ ]}
+ />,
+ );
+
+ expect(lastMapGLProps.longitude).toBe(-122.5);
+ expect(lastMapGLProps.latitude).toBe(37.3);
+ expect(lastMapGLProps.zoom).toBe(5);
+});
+
+test('does not recompute fitBounds on bounds change when an explicit viewport is set', () => {
+ const { rerender } = render(
+ <MapBox
+ {...defaultProps}
+ viewportLongitude={-122.4}
+ viewportLatitude={37.8}
+ viewportZoom={5}
+ />,
+ );
+
+ rerender(
+ <MapBox
+ {...defaultProps}
+ viewportLongitude={-122.4}
+ viewportLatitude={37.8}
+ viewportZoom={5}
+ bounds={[
+ [-123.2, 36.5],
+ [-121.8, 38.1],
+ ]}
+ />,
+ );
+
+ expect(lastMapGLProps.longitude).toBe(-122.4);
+ expect(lastMapGLProps.latitude).toBe(37.8);
+ expect(lastMapGLProps.zoom).toBe(5);
+});
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/test/controlPanel.test.ts b/superset-frontend/plugins/legacy-plugin-chart-map-box/test/controlPanel.test.ts
new file mode 100644
index 00000000000..2a72db46366
--- /dev/null
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/test/controlPanel.test.ts
@@ -0,0 +1,81 @@
+/**
+ * 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 type {
+ ControlPanelConfig,
+ CustomControlItem,
+} from '@superset-ui/chart-controls';
+import controlPanel from '../src/controlPanel';
+
+type ControlConfig = Required<CustomControlItem['config']>;
+
+function isCustomControlItem(
+ controlItem: unknown,
+): controlItem is CustomControlItem & { config: ControlConfig } {
+ return (
+ typeof controlItem === 'object' &&
+ controlItem !== null &&
+ 'name' in controlItem &&
+ 'config' in controlItem
+ );
+}
+
+function getControl(
+ panel: ControlPanelConfig,
+ controlName: string,
+): CustomControlItem & { config: ControlConfig } {
+ const item = (panel.controlPanelSections || [])
+ .flatMap(section => section?.controlSetRows || [])
+ .flat()
+ .find(
+ controlItem =>
+ isCustomControlItem(controlItem) && controlItem.name === controlName,
+ );
+
+ if (!isCustomControlItem(item)) {
+ throw new Error(`Control "${controlName}" not found`);
+ }
+
+ return item;
+}
+
+test('viewport controls default to empty values and rerender without query refresh', () => {
+ const longitudeControl = getControl(controlPanel, 'viewport_longitude');
+ const latitudeControl = getControl(controlPanel, 'viewport_latitude');
+ const zoomControl = getControl(controlPanel, 'viewport_zoom');
+
+ expect(longitudeControl.config.default).toBe('');
+ expect(latitudeControl.config.default).toBe('');
+ expect(zoomControl.config.default).toBe('');
+
+ expect(longitudeControl.config.renderTrigger).toBe(true);
+ expect(latitudeControl.config.renderTrigger).toBe(true);
+ expect(zoomControl.config.renderTrigger).toBe(true);
+
+ expect(longitudeControl.config.dontRefreshOnChange).toBe(true);
+ expect(latitudeControl.config.dontRefreshOnChange).toBe(true);
+ expect(zoomControl.config.dontRefreshOnChange).toBe(true);
+});
+
+test('opacity control rerenders immediately when changed', () => {
+ const opacityControl = getControl(controlPanel, 'global_opacity');
+
+ expect(opacityControl.config.default).toBe(1);
+ expect(opacityControl.config.renderTrigger).toBe(true);
+ expect(opacityControl.config.isFloat).toBe(true);
+});
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/test/transformProps.test.ts b/superset-frontend/plugins/legacy-plugin-chart-map-box/test/transformProps.test.ts
new file mode 100644
index 00000000000..483f64963dc
--- /dev/null
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/test/transformProps.test.ts
@@ -0,0 +1,230 @@
+/*
+ * 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 } from '@superset-ui/core';
+import { supersetTheme } from '@apache-superset/core/theme';
+
+jest.mock('supercluster', () => {
+ const MockSupercluster = jest.fn().mockImplementation(() => ({
+ load: jest.fn(),
+ getClusters: jest.fn().mockReturnValue([]),
+ }));
+ return { __esModule: true, default: MockSupercluster };
+});
+
+// Import after mocking supercluster to avoid ESM parse error
+// eslint-disable-next-line import/first
+import transformProps from '../src/transformProps';
+
+type TransformPropsResult = {
+ globalOpacity?: number;
+ onViewportChange?: (viewport: {
+ latitude: number;
+ longitude: number;
+ zoom: number;
+ }) => void;
+ viewportLongitude?: number;
+ viewportLatitude?: number;
+ viewportZoom?: number;
+};
+
+const baseFormData = {
+ clusteringRadius: 60,
+ globalOpacity: 0.8,
+ mapboxColor: 'rgb(0, 139, 139)',
+ mapboxStyle: 'mapbox://styles/mapbox/light-v9',
+ pandasAggfunc: 'sum',
+ pointRadiusUnit: 'Pixels',
+ renderWhileDragging: true,
+ viewportLongitude: -73.935242,
+ viewportLatitude: 40.73061,
+ viewportZoom: 9,
+};
+
+const baseQueriesData = [
+ {
+ data: {
+ bounds: [
+ [-74.0, 40.7],
+ [-73.9, 40.8],
+ ] as [[number, number], [number, number]],
+ geoJSON: { features: [] },
+ hasCustomMetric: false,
+ mapboxApiKey: 'test-api-key',
+ },
+ },
+];
+
+function createChartProps(overrides: Record<string, unknown> = {}) {
+ return new ChartProps({
+ formData: { ...baseFormData, ...overrides },
+ width: 800,
+ height: 600,
+ queriesData: baseQueriesData,
+ theme: supersetTheme,
+ });
+}
+
+function getTransformPropsResult(
+ overrides: Record<string, unknown> = {},
+): TransformPropsResult {
+ return transformProps(createChartProps(overrides)) as TransformPropsResult;
+}
+
+test('extracts globalOpacity from formData', () => {
+ const result = getTransformPropsResult({ globalOpacity: 0.5 });
+ expect(result.globalOpacity).toBe(0.5);
+});
+
+test('extracts viewport values from formData', () => {
+ const result = getTransformPropsResult({
+ viewportLongitude: -122.4,
+ viewportLatitude: 37.8,
+ viewportZoom: 12,
+ });
+ expect(result).toEqual(
+ expect.objectContaining({
+ viewportLongitude: -122.4,
+ viewportLatitude: 37.8,
+ viewportZoom: 12,
+ }),
+ );
+});
+
+test('clamps viewport values to safe map ranges', () => {
+ const result = getTransformPropsResult({
+ viewportLongitude: 190,
+ viewportLatitude: -100,
+ viewportZoom: 99,
+ });
+ expect(result).toEqual(
+ expect.objectContaining({
+ viewportLongitude: 180,
+ viewportLatitude: -90,
+ viewportZoom: 16,
+ }),
+ );
+});
+
+test('provides onViewportChange callback that updates control values', () => {
+ const setControlValue = jest.fn();
+ const chartProps = new ChartProps({
+ formData: baseFormData,
+ width: 800,
+ height: 600,
+ queriesData: baseQueriesData,
+ hooks: { setControlValue },
+ theme: supersetTheme,
+ });
+ const result = transformProps(chartProps) as TransformPropsResult;
+ expect(result.onViewportChange).toBeDefined();
+
+ result.onViewportChange!({
+ latitude: 51.5,
+ longitude: -0.12,
+ zoom: 10,
+ });
+
+ expect(setControlValue).toHaveBeenCalledWith('viewport_longitude', -0.12);
+ expect(setControlValue).toHaveBeenCalledWith('viewport_latitude', 51.5);
+ expect(setControlValue).toHaveBeenCalledWith('viewport_zoom', 10);
+});
+
+test('normalizes string viewport values to numbers', () => {
+ const result = getTransformPropsResult({
+ viewportLongitude: '-122.4',
+ viewportLatitude: '37.8',
+ viewportZoom: '12',
+ });
+ expect(result.viewportLongitude).toBe(-122.4);
+ expect(result.viewportLatitude).toBe(37.8);
+ expect(result.viewportZoom).toBe(12);
+});
+
+test('normalizes empty viewport values to undefined', () => {
+ const result = getTransformPropsResult({
+ viewportLongitude: '',
+ viewportLatitude: '',
+ viewportZoom: '',
+ });
+ expect(result.viewportLongitude).toBeUndefined();
+ expect(result.viewportLatitude).toBeUndefined();
+ expect(result.viewportZoom).toBeUndefined();
+});
+
+test('normalizes whitespace-only viewport values to undefined', () => {
+ const result = getTransformPropsResult({
+ viewportLongitude: ' ',
+ viewportLatitude: '\t',
+ viewportZoom: ' \n ',
+ });
+ expect(result.viewportLongitude).toBeUndefined();
+ expect(result.viewportLatitude).toBeUndefined();
+ expect(result.viewportZoom).toBeUndefined();
+});
+
+test('normalizes string opacity to number', () => {
+ const result = getTransformPropsResult({ globalOpacity: '0.5' });
+ expect(result.globalOpacity).toBe(0.5);
+});
+
+test('defaults empty opacity to 1', () => {
+ const result = getTransformPropsResult({ globalOpacity: '' });
+ expect(result.globalOpacity).toBe(1);
+});
+
+test('defaults whitespace-only opacity to 1', () => {
+ const result = getTransformPropsResult({ globalOpacity: ' ' });
+ expect(result.globalOpacity).toBe(1);
+});
+
+test('clamps opacity to [0, 1] range', () => {
+ expect(getTransformPropsResult({ globalOpacity: 5 }).globalOpacity).toBe(1);
+ expect(getTransformPropsResult({ globalOpacity: -1 }).globalOpacity).toBe(0);
+});
+
+test('passes through numeric values unchanged', () => {
+ const result = getTransformPropsResult({
+ viewportLongitude: -122.4,
+ viewportLatitude: 37.8,
+ viewportZoom: 12,
+ globalOpacity: 0.8,
+ });
+ expect(result.viewportLongitude).toBe(-122.4);
+ expect(result.viewportLatitude).toBe(37.8);
+ expect(result.viewportZoom).toBe(12);
+ expect(result.globalOpacity).toBe(0.8);
+});
+
+test('calls onError and returns empty object for invalid color', () => {
+ const onError = jest.fn();
+ const chartProps = new ChartProps({
+ formData: { ...baseFormData, mapboxColor: 'invalid-color' },
+ width: 800,
+ height: 600,
+ queriesData: baseQueriesData,
+ hooks: { onError },
+ theme: supersetTheme,
+ });
+ const result = transformProps(chartProps);
+ expect(onError).toHaveBeenCalledWith(
+ "Color field must be of form 'rgb(%d, %d, %d)'",
+ );
+ expect(result).toEqual({});
+});